From c8de7bab674ef2faa8bf3f843793479bcb238e8e Mon Sep 17 00:00:00 2001 From: Aleksandr Slokov Date: Tue, 1 Sep 2026 17:52:54 +0700 Subject: [PATCH 1/3] Introduce oxidiris-core crate and workspace Create a Cargo workspace and add a new terminal-free crate oxidiris-core containing core RSVP/ORP engine code. Added modules: encoding (encoding detection/decoding), segment (sanitisation & word splitting), token (document/token types), orp (ORP layout), sentence (sentence-break classification), parser (markdown & plaintext parsers), pacing (token timing & tokenization) and player (playback state machine). Moved the binary into crates/oxidiris and updated workspace Cargo.toml; updated Cargo.lock accordingly. Adds tests across modules and declares dependencies (encoding_rs, pulldown-cmark, thiserror, unicode-*, proptest, etc.). Purpose: separate reusable, wasm-compatible core logic from TUI/terminal concerns. --- oxidiris/Cargo.lock => Cargo.lock | 336 +++++++++++++++- Cargo.toml | 35 ++ crates/oxidiris-core/Cargo.toml | 23 ++ crates/oxidiris-core/src/encoding.rs | 151 ++++++++ crates/oxidiris-core/src/lib.rs | 105 +++++ crates/oxidiris-core/src/orp.rs | 193 ++++++++++ crates/oxidiris-core/src/pacing.rs | 248 ++++++++++++ crates/oxidiris-core/src/parser/markdown.rs | 313 +++++++++++++++ crates/oxidiris-core/src/parser/mod.rs | 83 ++++ crates/oxidiris-core/src/parser/plaintext.rs | 135 +++++++ crates/oxidiris-core/src/player.rs | 385 +++++++++++++++++++ crates/oxidiris-core/src/segment.rs | 168 ++++++++ crates/oxidiris-core/src/sentence.rs | 157 ++++++++ crates/oxidiris-core/src/token.rs | 260 +++++++++++++ crates/oxidiris/Cargo.toml | 19 + {oxidiris => crates/oxidiris}/src/main.rs | 0 oxidiris/Cargo.toml | 8 - 17 files changed, 2607 insertions(+), 12 deletions(-) rename oxidiris/Cargo.lock => Cargo.lock (83%) create mode 100644 Cargo.toml create mode 100644 crates/oxidiris-core/Cargo.toml create mode 100644 crates/oxidiris-core/src/encoding.rs create mode 100644 crates/oxidiris-core/src/lib.rs create mode 100644 crates/oxidiris-core/src/orp.rs create mode 100644 crates/oxidiris-core/src/pacing.rs create mode 100644 crates/oxidiris-core/src/parser/markdown.rs create mode 100644 crates/oxidiris-core/src/parser/mod.rs create mode 100644 crates/oxidiris-core/src/parser/plaintext.rs create mode 100644 crates/oxidiris-core/src/player.rs create mode 100644 crates/oxidiris-core/src/segment.rs create mode 100644 crates/oxidiris-core/src/sentence.rs create mode 100644 crates/oxidiris-core/src/token.rs create mode 100644 crates/oxidiris/Cargo.toml rename {oxidiris => crates/oxidiris}/src/main.rs (100%) delete mode 100644 oxidiris/Cargo.toml diff --git a/oxidiris/Cargo.lock b/Cargo.lock similarity index 83% rename from oxidiris/Cargo.lock rename to Cargo.lock index 72ae5e7..29d7687 100644 --- a/oxidiris/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,56 @@ 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" @@ -59,7 +109,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -68,6 +127,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -128,6 +193,52 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compact_str" version = "0.9.1" @@ -306,6 +417,15 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -337,10 +457,16 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -495,6 +621,12 @@ dependencies = [ "syn 3.0.4", ] +[[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.14.0" @@ -714,6 +846,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "ordered-float" version = "4.6.0" @@ -727,9 +865,26 @@ dependencies = [ name = "oxidiris" version = "0.1.0" dependencies = [ + "anyhow", + "clap", + "crossterm", + "oxidiris-core", "ratatui", ] +[[package]] +name = "oxidiris-core" +version = "0.1.0" +dependencies = [ + "encoding_rs", + "proptest", + "pulldown-cmark", + "thiserror 2.0.20", + "unicode-normalization", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "palette" version = "0.7.7" @@ -855,7 +1010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.8", ] [[package]] @@ -898,6 +1053,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -907,6 +1071,42 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.1", + "num-traits", + "rand 0.9.5", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "memchr", + "unicase", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.47" @@ -934,7 +1134,27 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -943,6 +1163,24 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "ratatui" version = "0.30.2" @@ -1110,6 +1348,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1284,6 +1534,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "termina" version = "0.3.3" @@ -1421,6 +1684,21 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "typenum" version = "1.20.1" @@ -1433,12 +1711,33 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -1495,6 +1794,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1669,3 +1977,23 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2271bb4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[workspace] +members = ["crates/*"] +resolver = "3" + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +authors = ["Aleksandr Slokov"] +repository = "https://github.com/dinhtandung/oxidiris" +readme = "README.md" +keywords = ["rsvp", "speed-reading", "tui", "accessibility", "reader"] +categories = ["command-line-utilities", "text-processing", "accessibility"] + +[workspace.dependencies] +# --- core (must stay terminal-free, wasm-compatible) --- +unicode-segmentation = "1.12" +unicode-normalization = "0.1" +unicode-width = "0.2" +pulldown-cmark = { version = "0.13", default-features = false } +thiserror = "2" + +# --- binary only --- +ratatui = "0.30.2" +crossterm = "0.29" +clap = { version = "4", features = ["derive"] } +anyhow = "1" +encoding_rs = "0.8" + +# --- dev --- +proptest = "1" + +[profile.release] +lto = "thin" +strip = true \ No newline at end of file diff --git a/crates/oxidiris-core/Cargo.toml b/crates/oxidiris-core/Cargo.toml new file mode 100644 index 0000000..fb80fac --- /dev/null +++ b/crates/oxidiris-core/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "oxidiris-core" +description = "Core RSVP/ORP text engine for Oxidiris: tokenization, optimal recognition point, pacing, and document parsing." +license = "GPL-3.0-or-later" # TODO(DEC-01): dual-license MIT OR Apache-2.0 pending human decision +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +unicode-segmentation.workspace = true +unicode-normalization.workspace = true +unicode-width.workspace = true +pulldown-cmark.workspace = true +thiserror.workspace = true +encoding_rs.workspace = true + +[dev-dependencies] +proptest.workspace = true \ No newline at end of file diff --git a/crates/oxidiris-core/src/encoding.rs b/crates/oxidiris-core/src/encoding.rs new file mode 100644 index 0000000..7324b9f --- /dev/null +++ b/crates/oxidiris-core/src/encoding.rs @@ -0,0 +1,151 @@ +//! Byte-to-text decoding with encoding detection. +//! +//! Implements OXD-015. See spec §4.3. +//! +//! Assuming UTF-8 is wrong often enough to matter: RFCs and man pages are frequently Latin-1, and +//! Windows-authored notes arrive as UTF-16 with a BOM. A panic on those files would be a poor +//! showing for a reading tool. + +use thiserror::Error; + +/// Why a byte stream could not be turned into text. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum DecodeError { + /// The input looks like a binary file rather than a document. + #[error("input appears to be binary (found {nul_bytes} NUL bytes in the first {scanned} bytes)")] + Binary { + /// How many NUL bytes were seen. + nul_bytes: usize, + /// How many bytes were inspected. + scanned: usize, + }, +} + +/// The encoding that was detected for an input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetectedEncoding { + /// Valid UTF-8 without a byte order mark. + Utf8, + /// UTF-8 preceded by a byte order mark. + Utf8Bom, + /// UTF-16 little endian, identified by its byte order mark. + Utf16Le, + /// UTF-16 big endian, identified by its byte order mark. + Utf16Be, + /// Fallback for byte streams that are not valid UTF-8. + Windows1252, +} + +impl DetectedEncoding { + /// Human-readable name, for the status bar and error messages. + pub const fn name(self) -> &'static str { + match self { + DetectedEncoding::Utf8 => "UTF-8", + DetectedEncoding::Utf8Bom => "UTF-8 (BOM)", + DetectedEncoding::Utf16Le => "UTF-16LE", + DetectedEncoding::Utf16Be => "UTF-16BE", + DetectedEncoding::Windows1252 => "Windows-1252", + } + } +} + +/// Number of leading bytes inspected when deciding whether input is binary. +const BINARY_SCAN_LEN: usize = 8192; + +/// Decode `bytes` into a `String`, detecting the encoding. +/// +/// Returns the decoded text together with the encoding that was used. Malformed sequences are +/// replaced rather than rejected, so a partially corrupt file still reads. +pub fn decode(bytes: &[u8]) -> Result<(String, DetectedEncoding), DecodeError> { + if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + let (text, _) = encoding_rs::UTF_8.decode_with_bom_removal(rest); + return Ok((text.into_owned(), DetectedEncoding::Utf8Bom)); + } + if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) { + let (text, _, _) = encoding_rs::UTF_16LE.decode(rest); + return Ok((text.into_owned(), DetectedEncoding::Utf16Le)); + } + if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) { + let (text, _, _) = encoding_rs::UTF_16BE.decode(rest); + return Ok((text.into_owned(), DetectedEncoding::Utf16Be)); + } + + // No BOM. Reject obvious binary before guessing at a text encoding, otherwise an executable + // would decode into thousands of replacement characters and "read" as a document. + let scanned = bytes.len().min(BINARY_SCAN_LEN); + let nul_bytes = bytes[..scanned].iter().filter(|b| **b == 0).count(); + if nul_bytes > 0 { + return Err(DecodeError::Binary { nul_bytes, scanned }); + } + + match core::str::from_utf8(bytes) { + Ok(text) => Ok((text.to_string(), DetectedEncoding::Utf8)), + Err(_) => { + let (text, _, _) = encoding_rs::WINDOWS_1252.decode(bytes); + Ok((text.into_owned(), DetectedEncoding::Windows1252)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_utf8_is_detected() { + let (text, enc) = decode("tiếng Việt".as_bytes()).unwrap(); + assert_eq!(text, "tiếng Việt"); + assert_eq!(enc, DetectedEncoding::Utf8); + } + + #[test] + fn utf8_bom_is_stripped() { + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend_from_slice(b"hello"); + let (text, enc) = decode(&bytes).unwrap(); + assert_eq!(text, "hello"); + assert_eq!(enc, DetectedEncoding::Utf8Bom); + } + + #[test] + fn utf16_le_with_bom_round_trips() { + let mut bytes = vec![0xFF, 0xFE]; + for unit in "hi ế".encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + let (text, enc) = decode(&bytes).unwrap(); + assert_eq!(text, "hi ế"); + assert_eq!(enc, DetectedEncoding::Utf16Le); + } + + #[test] + fn utf16_be_with_bom_round_trips() { + let mut bytes = vec![0xFE, 0xFF]; + for unit in "hi".encode_utf16() { + bytes.extend_from_slice(&unit.to_be_bytes()); + } + let (text, enc) = decode(&bytes).unwrap(); + assert_eq!(text, "hi"); + assert_eq!(enc, DetectedEncoding::Utf16Be); + } + + #[test] + fn latin1_falls_back_without_error() { + // 0xE9 is "é" in Windows-1252 but invalid on its own in UTF-8. + let (text, enc) = decode(&[b'c', b'a', b'f', 0xE9]).unwrap(); + assert_eq!(text, "café"); + assert_eq!(enc, DetectedEncoding::Windows1252); + } + + #[test] + fn binary_input_is_rejected_rather_than_decoded() { + let bytes = [0x7F, b'E', b'L', b'F', 0x00, 0x01, 0x00, 0x02]; + assert!(matches!(decode(&bytes), Err(DecodeError::Binary { .. }))); + } + + #[test] + fn empty_input_decodes_to_empty_text() { + let (text, _) = decode(b"").unwrap(); + assert!(text.is_empty()); + } +} \ No newline at end of file diff --git a/crates/oxidiris-core/src/lib.rs b/crates/oxidiris-core/src/lib.rs new file mode 100644 index 0000000..77b5af2 --- /dev/null +++ b/crates/oxidiris-core/src/lib.rs @@ -0,0 +1,105 @@ +//! Core text engine for Oxidiris: RSVP tokenization, Optimal Recognition Point, pacing and +//! document parsing. +//! +//! # Architectural constraint +//! +//! This crate must never depend on `ratatui`, `crossterm`, or any other terminal I/O crate. It is +//! the piece that gets reused for the WebAssembly build (OXD-080), a future GUI, and editor +//! plugins. CI enforces the rule by building this crate for `wasm32-unknown-unknown`; if a +//! terminal dependency sneaks in, that job fails. +//! +//! # Pipeline +//! +//! ```text +//! bytes -> encoding::decode -> segment::sanitize -> parser::parse -> Document +//! -> pacing::tokenize -> Vec -> Player +//! ``` +//! +//! Sanitisation happens exactly once, before parsing. Everything downstream then indexes into the +//! normalized [`Document::source`], which is what makes NFC and NFD inputs produce byte-identical +//! results. +//! +//! # Example +//! +//! ``` +//! use oxidiris_core::{pacing::PacingMode, parser, player::Player, segment}; +//! +//! let text = segment::sanitize("# Title\n\nHello there, reader.\n"); +//! let doc = parser::parse_markdown(&text); +//! let mut player = Player::from_document(&doc, 300, PacingMode::Natural); +//! +//! player.play(); +//! assert_eq!(player.current().unwrap().text, "Title"); +//! ``` + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +pub mod encoding; +pub mod orp; +pub mod pacing; +pub mod parser; +pub mod player; +pub mod segment; +pub mod sentence; +pub mod token; + +pub use pacing::{DEFAULT_WPM, MAX_WPM, MIN_WPM, PacingMode}; +pub use player::{PlayState, Player}; +pub use token::{Block, BlockKind, Document, Heading, Token, TokenKind}; + +/// Load a document from raw bytes: decode, sanitize, and parse. +/// +/// `format` may be `None`, in which case the format is sniffed from the content. +pub fn load( + bytes: &[u8], + format: Option, +) -> Result { + let (raw, _encoding) = encoding::decode(bytes)?; + let text = segment::sanitize(&raw); + let format = format.unwrap_or_else(|| parser::Format::sniff(&text)); + Ok(parser::parse(&text, format)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_sniffs_markdown_and_builds_a_document() { + let src = b"# Title\n\n- a\n- b\n\n> quote\n\nBody text.\n"; + let doc = load(src, None).unwrap(); + assert_eq!(doc.meta.format, Some("markdown")); + assert_eq!(doc.headings.len(), 1); + } + + #[test] + fn load_rejects_binary_input() { + assert!(load(&[0x00, 0x01, 0x02, 0x03], None).is_err()); + } + + /// End-to-end: the two Unicode normalization forms must be indistinguishable downstream. + #[test] + fn nfc_and_nfd_documents_are_byte_identical() { + let nfc = "Ti\u{1EBF}ng Vi\u{1EC7}t r\u{1EA5}t hay.\n"; + let nfd = "Tie\u{302}\u{301}ng Vie\u{323}\u{302}t ra\u{301}\u{302}t hay.\n"; + let a = load(nfc.as_bytes(), Some(parser::Format::PlainText)).unwrap(); + let b = load(nfd.as_bytes(), Some(parser::Format::PlainText)).unwrap(); + assert_eq!(a.source, b.source); + + let ta = pacing::tokenize(&a, PacingMode::Natural); + let tb = pacing::tokenize(&b, PacingMode::Natural); + assert_eq!(ta, tb); + } + + /// Every token span must be a valid slice of the document source, on real content. + #[test] + fn token_spans_are_always_valid_slices() { + let doc = load(include_bytes!("../../../BACKLOG.md"), None).unwrap(); + let tokens = pacing::tokenize(&doc, PacingMode::Natural); + assert!(tokens.len() > 500, "BACKLOG.md should yield a substantial stream"); + for t in &tokens { + assert!(doc.source.get(t.byte_span.clone()).is_some(), "invalid span for {:?}", t.text); + } + } +} \ No newline at end of file diff --git a/crates/oxidiris-core/src/orp.rs b/crates/oxidiris-core/src/orp.rs new file mode 100644 index 0000000..6c2c91a --- /dev/null +++ b/crates/oxidiris-core/src/orp.rs @@ -0,0 +1,193 @@ +//! Optimal Recognition Point: where the eye should be anchored inside a word. +//! +//! Implements OXD-012. See spec §3.1.2 and §3.1.3. +//! +//! This is the single most load-bearing algorithm in the project. If the ORP column drifts even +//! by one cell between words, the eye has to move, and the entire premise of the tool collapses. + +use unicode_segmentation::UnicodeSegmentation; + +use crate::segment::display_width; + +/// Lookup table mapping word length (in grapheme clusters) to an ORP index. +/// +/// Read as: "a word of at most `max_len` graphemes anchors on grapheme `index`". Longer words +/// fall through to [`ORP_INDEX_CAP`]. +/// +/// Encoded as data rather than nested conditionals so that DEC-03 (empirical validation against +/// Vietnamese and CJK readers) can be applied by editing one array. +const ORP_TABLE: &[(usize, usize)] = &[(1, 0), (5, 1), (9, 2), (13, 3)]; + +/// Ceiling for the ORP index on very long words. +/// +/// Pushing the anchor further right on a 20-character chemical term or function name would drag +/// the tail of the word out of foveal vision (roughly 2 degrees), which costs more than the +/// slightly off-centre anchor saves. +const ORP_INDEX_CAP: usize = 4; + +/// ORP index for a word of `len` grapheme clusters. +pub const fn orp_index_for_len(len: usize) -> usize { + let mut i = 0; + while i < ORP_TABLE.len() { + let (max_len, index) = ORP_TABLE[i]; + if len <= max_len { + return index; + } + i += 1; + } + ORP_INDEX_CAP +} + +/// ORP index for `word`, counted in grapheme clusters. +/// +/// Guaranteed to be a valid index into the word's graphemes for any non-empty input. +pub fn orp_index(word: &str) -> usize { + orp_index_for_len(word.graphemes(true).count()) +} + +/// Terminal column offset of the ORP grapheme from the start of `word`. +/// +/// The renderer subtracts this from the fixed anchor column to find where to start drawing, which +/// is what pins the ORP grapheme to the same column for every word (spec §3.1.3). +pub fn orp_offset(word: &str) -> u16 { + let idx = orp_index(word); + word.graphemes(true).take(idx).map(display_width).sum() +} + +/// Everything the renderer needs to draw one word: the split points and the widths. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrpLayout { + /// Graphemes before the ORP. + pub prefix: String, + /// The ORP grapheme itself. Empty only for empty input. + pub focus: String, + /// Graphemes after the ORP. + pub suffix: String, + /// Column offset of `focus` from the start of the word. + pub orp_offset: u16, + /// Total column width of the word. + pub total_width: u16, +} + +/// Compute the full render layout for `word`. +pub fn layout(word: &str) -> OrpLayout { + let graphemes: Vec<&str> = word.graphemes(true).collect(); + if graphemes.is_empty() { + return OrpLayout { + prefix: String::new(), + focus: String::new(), + suffix: String::new(), + orp_offset: 0, + total_width: 0, + }; + } + let idx = orp_index_for_len(graphemes.len()); + let prefix: String = graphemes[..idx].concat(); + let focus = graphemes[idx].to_string(); + let suffix: String = graphemes[idx + 1..].concat(); + let orp_offset = display_width(&prefix); + let total_width = display_width(word); + + OrpLayout { prefix, focus, suffix, orp_offset, total_width } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::segment::sanitize; + + #[test] + fn lookup_table_matches_the_specified_buckets() { + assert_eq!(orp_index("a"), 0); + assert_eq!(orp_index("at"), 1); + assert_eq!(orp_index("hello"), 1); + assert_eq!(orp_index("reading"), 2); + assert_eq!(orp_index("javascript"), 3); + assert_eq!(orp_index("internationalization"), ORP_INDEX_CAP); + } + + #[test] + fn single_grapheme_word_anchors_on_index_zero() { + assert_eq!(orp_index("I"), 0); + assert_eq!(orp_offset("I"), 0); + } + + #[test] + fn twenty_character_word_is_capped() { + let long = "a".repeat(20); + assert_eq!(orp_index(&long), ORP_INDEX_CAP); + } + + #[test] + fn nfc_and_nfd_give_the_same_orp() { + let nfc = sanitize("ti\u{1EBF}ng"); + let nfd = sanitize("tie\u{302}\u{301}ng"); + assert_eq!(orp_index(&nfc), orp_index(&nfd)); + assert_eq!(orp_offset(&nfc), orp_offset(&nfd)); + } + + #[test] + fn cjk_offset_is_measured_in_columns_not_characters() { + // 4 graphemes -> index 1. One full-width grapheme precedes the ORP, so the offset is 2 + // columns, not 1 character. + assert_eq!(orp_index("日本語文"), 1); + assert_eq!(orp_offset("日本語文"), 2); + } + + #[test] + fn zwj_emoji_does_not_shift_the_index() { + let family = sanitize("\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"); + assert_eq!(orp_index(&family), 0); + assert_eq!(orp_offset(&family), 0); + } + + #[test] + fn layout_reassembles_the_original_word() { + for word in ["a", "at", "hello", "javascript", "日本語文", "Oxidiris"] { + let l = layout(word); + assert_eq!(format!("{}{}{}", l.prefix, l.focus, l.suffix), word); + } + } + + #[test] + fn empty_word_does_not_panic() { + let l = layout(""); + assert_eq!(l.total_width, 0); + assert!(l.focus.is_empty()); + } +} + +/// Property tests for the invariant that keeps the anchor column stable. +#[cfg(test)] +mod properties { + use super::*; + use crate::segment::{display_width, grapheme_count, sanitize}; + use proptest::prelude::*; + + proptest! { + /// The core invariant: for *any* Unicode input, the ORP must be a real grapheme of the + /// word and must never sit further right than the word is wide. A violation here is + /// exactly the "text jumps horizontally" bug. + #[test] + fn orp_is_always_inside_the_word(raw in ".{0,64}") { + let word = sanitize(&raw); + let n = grapheme_count(&word); + prop_assume!(n > 0); + + prop_assert!(orp_index(&word) < n); + prop_assert!(orp_offset(&word) <= display_width(&word)); + } + + /// The layout must be a lossless partition of the word. + #[test] + fn layout_is_lossless(raw in ".{0,64}") { + let word = sanitize(&raw); + prop_assume!(grapheme_count(&word) > 0); + + let l = layout(&word); + prop_assert_eq!(format!("{}{}{}", l.prefix, l.focus, l.suffix), word.clone()); + prop_assert_eq!(l.orp_offset, display_width(&l.prefix)); + prop_assert_eq!(l.total_width, display_width(&word)); + } + } +} \ No newline at end of file diff --git a/crates/oxidiris-core/src/pacing.rs b/crates/oxidiris-core/src/pacing.rs new file mode 100644 index 0000000..fbf68db --- /dev/null +++ b/crates/oxidiris-core/src/pacing.rs @@ -0,0 +1,248 @@ +//! Per-token timing: how long each word stays on screen. +//! +//! Implements OXD-013. See spec §3.2. +//! +//! Showing every word for the same duration is the single easiest way to make RSVP exhausting: +//! the brain needs measurably different amounts of time for a three-letter function word and a +//! twenty-letter technical term. + +use crate::sentence::{self, Break}; +use crate::token::{Block, Document, Fragment, Token, TokenKind}; +use crate::{orp, segment}; + +/// Word length beyond which the length penalty starts to apply, in graphemes. +const LENGTH_FREE_GRAPHEMES: usize = 6; +/// Extra multiplier per grapheme past [`LENGTH_FREE_GRAPHEMES`]. +const LENGTH_STEP: f32 = 0.05; +/// Ceiling on the length multiplier, so a pathological token cannot stall the stream. +const LENGTH_CAP: f32 = 2.0; + +/// Lower bound on reading speed, in words per minute. +pub const MIN_WPM: u16 = 50; +/// Upper bound on reading speed, in words per minute. +pub const MAX_WPM: u16 = 1500; +/// Default reading speed. +/// +/// Deliberately conservative. High-speed flashing text is a photosensitivity risk under WCAG +/// SC 2.3.1, so the default is a safe speed rather than a marketing number (spec §3.4.2). +pub const DEFAULT_WPM: u16 = 300; +/// Speed above which the UI warns the reader once (spec §3.4.2). +pub const FLASH_WARNING_WPM: u16 = 700; + +/// How aggressively to vary per-word timing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PacingMode { + /// Vary timing by word length, punctuation and structure. + #[default] + Natural, + /// Give every token exactly the same duration. + Linear, +} + +/// Clamp a requested speed into the supported range. +pub const fn clamp_wpm(wpm: u16) -> u16 { + if wpm < MIN_WPM { + MIN_WPM + } else if wpm > MAX_WPM { + MAX_WPM + } else { + wpm + } +} + +/// Length multiplier for a word of `graphemes` clusters. +fn length_factor(graphemes: usize) -> f32 { + let over = graphemes.saturating_sub(LENGTH_FREE_GRAPHEMES); + (1.0 + over as f32 * LENGTH_STEP).min(LENGTH_CAP) +} + +/// Turn a parsed [`Document`] into the RSVP token stream. +/// +/// Blocks whose kind is not readable (tables) are skipped entirely, and headings contribute an +/// extra pause on the token that precedes them so the reader feels the section change. +pub fn tokenize(doc: &Document, mode: PacingMode) -> Vec { + let mut tokens: Vec = Vec::new(); + + for block in &doc.blocks { + if !block.kind.is_readable() { + continue; + } + let first_of_block = tokens.len(); + for fragment in &block.fragments { + push_fragment(&mut tokens, block, fragment, mode); + } + if tokens.len() == first_of_block { + continue; // Block contributed nothing readable. + } + // Structural pause lands on the last token of the block. + let pause = block.kind.trailing_pause_ms(); + if let Some(last) = tokens.last_mut() { + last.pause_ms = last.pause_ms.max(pause); + } + } + + // A heading deserves a longer run-up than an ordinary paragraph break, so the token *before* + // a heading gets upgraded (spec §3.2.1). + apply_pre_heading_pauses(doc, &mut tokens); + + if mode == PacingMode::Linear { + for t in &mut tokens { + t.weight = 1.0; + t.pause_ms = 0; + } + } + + resolve_sentence_breaks(&mut tokens, mode); + tokens +} + +/// Split one fragment into tokens and append them. +fn push_fragment(tokens: &mut Vec, block: &Block, fragment: &Fragment, _mode: PacingMode) { + for word in segment::split_words(&fragment.text) { + let graphemes = segment::grapheme_count(word.text); + if graphemes == 0 { + continue; + } + let layout = orp::layout(word.text); + // Clamp into the fragment: Markdown escapes and entities make a fragment's text shorter + // than the source range it came from, so a naive offset could point past the fragment. + // Review Mode slices `Document::source` with these spans, so they must always be valid. + let start = (fragment.byte_span.start + word.start).min(fragment.byte_span.end); + let end = (fragment.byte_span.start + word.end).min(fragment.byte_span.end); + + tokens.push(Token { + text: word.text.to_string(), + orp_index: orp::orp_index_for_len(graphemes), + display_width: layout.total_width, + orp_offset: layout.orp_offset, + weight: length_factor(graphemes) * fragment.kind.pacing_factor(), + pause_ms: 0, + kind: fragment.kind, + block_id: block.id, + byte_span: start..end, + }); + } +} + +/// Add a longer pause to the token immediately preceding each heading. +fn apply_pre_heading_pauses(doc: &Document, tokens: &mut [Token]) { + const PRE_HEADING_PAUSE_MS: u32 = 400; + + for heading in &doc.headings { + // Find the last token belonging to a block before this heading. + let idx = tokens.iter().rposition(|t| t.block_id < heading.block_id); + if let Some(idx) = idx { + tokens[idx].pause_ms = tokens[idx].pause_ms.max(PRE_HEADING_PAUSE_MS); + } + } +} + +/// Apply punctuation multipliers, which need each token's successor to disambiguate periods. +fn resolve_sentence_breaks(tokens: &mut [Token], mode: PacingMode) { + if mode == PacingMode::Linear { + return; + } + for i in 0..tokens.len() { + let next = tokens.get(i + 1).map(|t| t.text.as_str()); + let brk = match tokens[i].kind { + // Code and math are not prose; their punctuation carries no rhythm. + TokenKind::Code | TokenKind::Math => Break::None, + _ => sentence::classify(&tokens[i].text, next), + }; + tokens[i].weight *= brk.factor(); + } +} + +/// Total time to read `tokens` end to end at `wpm`, in milliseconds. +pub fn total_duration_ms(tokens: &[Token], wpm: u16) -> u64 { + tokens.iter().map(|t| u64::from(t.duration_ms(wpm))).sum() +} + +/// Actual reading speed once every multiplier and pause is accounted for. +/// +/// This is always lower than the configured WPM, typically by 15-25%. Both numbers are shown in +/// the status bar so the configured speed is not mistaken for the real one (spec §3.2.4). +pub fn effective_wpm(tokens: &[Token], wpm: u16) -> u16 { + let total = total_duration_ms(tokens, wpm); + if total == 0 || tokens.is_empty() { + return wpm; + } + let wpm_f = tokens.len() as f64 * 60_000.0 / total as f64; + wpm_f.round().clamp(1.0, f64::from(u16::MAX)) as u16 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser; + + fn tokens_of(md: &str, mode: PacingMode) -> Vec { + let doc = parser::parse_markdown(&segment::sanitize(md)); + tokenize(&doc, mode) + } + + #[test] + fn wpm_is_clamped_to_the_supported_range() { + assert_eq!(clamp_wpm(0), MIN_WPM); + assert_eq!(clamp_wpm(9_000), MAX_WPM); + assert_eq!(clamp_wpm(400), 400); + } + + #[test] + fn length_factor_is_capped_at_two() { + assert_eq!(length_factor(3), 1.0); + assert_eq!(length_factor(6), 1.0); + assert!((length_factor(10) - 1.2).abs() < 1e-6); + assert_eq!(length_factor(1_000), LENGTH_CAP); + } + + #[test] + fn linear_mode_gives_every_token_the_same_duration() { + let tokens = tokens_of("# Title\n\nA short one and a considerably longer one.\n", PacingMode::Linear); + assert!(tokens.len() > 3); + let first = tokens[0].duration_ms(300); + assert!(tokens.iter().all(|t| t.duration_ms(300) == first)); + } + + #[test] + fn sentence_end_costs_about_two_and_a_quarter_times_a_plain_word() { + let tokens = tokens_of("alpha bravo. charlie delta\n", PacingMode::Natural); + let plain = tokens.iter().find(|t| t.text == "alpha").unwrap(); + let stop = tokens.iter().find(|t| t.text == "bravo.").unwrap(); + // Same length bucket, so the ratio isolates the punctuation factor. + let ratio = f32::from(u16::try_from(stop.duration_ms(300) - stop.pause_ms).unwrap()) + / f32::from(u16::try_from(plain.duration_ms(300)).unwrap()); + assert!((ratio - 2.25).abs() < 0.1, "ratio was {ratio}"); + } + + #[test] + fn effective_wpm_never_exceeds_the_configured_speed() { + let tokens = tokens_of( + "The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.\n", + PacingMode::Natural, + ); + let eff = effective_wpm(&tokens, 300); + assert!(eff <= 300, "effective {eff} must not exceed configured 300"); + assert!(eff >= 200, "effective {eff} unexpectedly far below configured 300"); + } + + #[test] + fn effective_wpm_on_an_empty_document_is_the_configured_speed() { + assert_eq!(effective_wpm(&[], 400), 400); + } + + #[test] + fn tables_contribute_no_tokens() { + let md = "Intro text here.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n"; + let tokens = tokens_of(md, PacingMode::Natural); + assert!(tokens.iter().all(|t| t.kind != TokenKind::Table)); + assert!(tokens.iter().all(|t| t.text != "1" && t.text != "2")); + } + + #[test] + fn heading_gets_a_run_up_pause_on_the_preceding_token() { + let tokens = tokens_of("First paragraph here\n\n## Section\n\nBody\n", PacingMode::Natural); + let last_before = tokens.iter().find(|t| t.text == "here").unwrap(); + assert!(last_before.pause_ms >= 400, "pause was {}", last_before.pause_ms); + } +} \ No newline at end of file diff --git a/crates/oxidiris-core/src/parser/markdown.rs b/crates/oxidiris-core/src/parser/markdown.rs new file mode 100644 index 0000000..e30b3ad --- /dev/null +++ b/crates/oxidiris-core/src/parser/markdown.rs @@ -0,0 +1,313 @@ +//! Markdown parser built on `pulldown-cmark`. +//! +//! Implements OXD-017. See spec §8.1. +//! +//! The job is to flatten the event stream into readable blocks while throwing away everything +//! that would pollute the reading rhythm: URLs, list bullets, fence markers. + +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; + +use crate::token::{Block, BlockKind, Document, DocumentMeta, Fragment, Heading, TokenKind}; + +/// A block under construction. +struct OpenBlock { + kind: BlockKind, + start: usize, + end: usize, + fragments: Vec, +} + +#[derive(Default)] +struct Builder { + blocks: Vec, + headings: Vec, + open: Option, + quote_depth: usize, + in_code_block: bool, + in_table: bool, + link_depth: usize, +} + +impl Builder { + fn open_block(&mut self, kind: BlockKind, start: usize) { + self.close_block(); + self.open = Some(OpenBlock { kind, start, end: start, fragments: Vec::new() }); + } + + fn close_block(&mut self) { + let Some(open) = self.open.take() else { return }; + if open.fragments.is_empty() { + return; + } + let text = open + .fragments + .iter() + .map(|f| f.text.trim()) + .filter(|s| !s.is_empty()) + .collect::>() + .join(" "); + if text.is_empty() { + return; + } + let id = self.blocks.len(); + if let BlockKind::Heading(level) = open.kind { + self.headings.push(Heading { level, text: text.clone(), block_id: id }); + } + self.blocks.push(Block { + id, + kind: open.kind, + text, + byte_span: open.start..open.end, + fragments: open.fragments, + }); + } + + fn push_text(&mut self, text: &str, span: core::ops::Range, kind: TokenKind) { + if text.trim().is_empty() { + return; + } + // Drop bare URLs and autolinks. The link *label* is kept; the target never is, because a + // URL read aloud one token at a time is pure noise (spec §8.1). + if looks_like_url(text) { + return; + } + let Some(open) = self.open.as_mut() else { return }; + open.end = open.end.max(span.end); + open.fragments.push(Fragment { text: text.to_string(), byte_span: span, kind }); + } +} + +/// Whether a piece of text is a URL rather than prose. +fn looks_like_url(text: &str) -> bool { + let t = text.trim(); + t.contains("://") || t.starts_with("www.") || t.starts_with("mailto:") +} + +const fn level_of(level: HeadingLevel) -> u8 { + match level { + HeadingLevel::H1 => 1, + HeadingLevel::H2 => 2, + HeadingLevel::H3 => 3, + HeadingLevel::H4 => 4, + HeadingLevel::H5 => 5, + HeadingLevel::H6 => 6, + } +} + +/// Parse Markdown `text` (already sanitized) into a [`Document`]. +pub fn parse(text: &str) -> Document { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_TASKLISTS); + + let mut b = Builder::default(); + + for (event, span) in Parser::new_ext(text, options).into_offset_iter() { + match event { + Event::Start(Tag::Heading { level, .. }) => { + b.open_block(BlockKind::Heading(level_of(level)), span.start); + } + Event::End(TagEnd::Heading(_)) => b.close_block(), + + Event::Start(Tag::CodeBlock(kind)) => { + b.in_code_block = true; + let _ = matches!(kind, CodeBlockKind::Fenced(_)); + b.open_block(BlockKind::CodeBlock, span.start); + } + Event::End(TagEnd::CodeBlock) => { + b.in_code_block = false; + b.close_block(); + } + + Event::Start(Tag::BlockQuote(_)) => b.quote_depth += 1, + Event::End(TagEnd::BlockQuote(_)) => b.quote_depth = b.quote_depth.saturating_sub(1), + + Event::Start(Tag::Item) => b.open_block(BlockKind::ListItem, span.start), + Event::End(TagEnd::Item) => b.close_block(), + + Event::Start(Tag::Table(_)) => { + b.in_table = true; + b.open_block(BlockKind::Table, span.start); + } + Event::End(TagEnd::Table) => { + b.in_table = false; + b.close_block(); + } + + // Inside a list item or table cell the surrounding block already exists; a loose list + // would otherwise split each item into a separate paragraph block. + Event::Start(Tag::Paragraph) if b.open.is_none() => { + let kind = + if b.quote_depth > 0 { BlockKind::Quote } else { BlockKind::Paragraph }; + b.open_block(kind, span.start); + } + Event::End(TagEnd::Paragraph) => { + let inside_container = matches!( + b.open.as_ref().map(|o| o.kind), + Some(BlockKind::ListItem | BlockKind::Table) + ); + if !inside_container { + b.close_block(); + } + } + + Event::Start(Tag::Link { .. }) => b.link_depth += 1, + Event::End(TagEnd::Link) => b.link_depth = b.link_depth.saturating_sub(1), + + Event::Text(t) => { + let kind = if b.in_code_block { + TokenKind::Code + } else if b.in_table { + TokenKind::Table + } else { + TokenKind::Word + }; + b.push_text(&t, span, kind); + } + Event::Code(t) => { + let kind = if b.in_table { TokenKind::Table } else { TokenKind::Code }; + b.push_text(&t, span, kind); + } + Event::InlineMath(t) | Event::DisplayMath(t) => { + b.push_text(&t, span, TokenKind::Math); + } + _ => {} + } + } + b.close_block(); + + let title = b + .headings + .iter() + .find(|h| h.level == 1) + .map(|h| h.text.clone()) + .or_else(|| b.headings.first().map(|h| h.text.clone())); + + Document { + source: text.to_string(), + blocks: b.blocks, + headings: b.headings, + meta: DocumentMeta { title, format: Some("markdown") }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::segment::sanitize; + + const SAMPLE: &str = "\ +# Oxidiris + +A tool for reading [the docs](https://example.com/docs) fast. + +## Features + +- Blazing fast +- No eye movement + +```rust +fn main() { println!(\"hi\"); } +``` + +| Column | Value | +|---|---| +| a | 1 | + +> A quoted remark. +"; + + #[test] + fn heading_tree_is_extracted_with_levels() { + let doc = parse(SAMPLE); + let levels: Vec = doc.headings.iter().map(|h| h.level).collect(); + let texts: Vec<&str> = doc.headings.iter().map(|h| h.text.as_str()).collect(); + assert_eq!(levels, vec![1, 2]); + assert_eq!(texts, vec!["Oxidiris", "Features"]); + assert_eq!(doc.meta.title.as_deref(), Some("Oxidiris")); + } + + /// OXD-017 acceptance: no URL may reach the token stream. + #[test] + fn urls_are_stripped_but_link_labels_survive() { + let doc = parse(SAMPLE); + let all: String = doc.blocks.iter().map(|b| b.text.clone()).collect::>().join(" "); + assert!(!all.contains("://"), "a URL leaked into the token stream: {all}"); + assert!(!all.contains("example.com")); + assert!(all.contains("the docs"), "link label was lost"); + } + + #[test] + fn autolinks_are_dropped_entirely() { + let doc = parse("See for details.\n"); + let all: String = doc.blocks.iter().map(|b| b.text.clone()).collect(); + assert!(!all.contains("://")); + assert!(all.contains("for details")); + } + + #[test] + fn code_block_is_kept_whole_and_marked_as_code() { + let doc = parse(SAMPLE); + let code = doc.blocks.iter().find(|b| b.kind == BlockKind::CodeBlock).unwrap(); + assert!(code.text.contains("fn main()")); + assert!(code.fragments.iter().all(|f| f.kind == TokenKind::Code)); + } + + #[test] + fn list_items_become_separate_blocks() { + let doc = parse(SAMPLE); + let items: Vec<&str> = doc + .blocks + .iter() + .filter(|b| b.kind == BlockKind::ListItem) + .map(|b| b.text.as_str()) + .collect(); + assert_eq!(items, vec!["Blazing fast", "No eye movement"]); + } + + #[test] + fn tables_are_parsed_but_marked_unreadable() { + let doc = parse(SAMPLE); + let table = doc.blocks.iter().find(|b| b.kind == BlockKind::Table).unwrap(); + assert!(!table.kind.is_readable()); + assert!(table.text.contains("Column")); + } + + #[test] + fn block_quotes_are_recognised() { + let doc = parse(SAMPLE); + assert!(doc.blocks.iter().any(|b| b.kind == BlockKind::Quote)); + } + + #[test] + fn fragment_spans_stay_within_the_source() { + let text = sanitize(SAMPLE); + let doc = parse(&text); + for block in &doc.blocks { + assert!(block.byte_span.end <= doc.source.len()); + for f in &block.fragments { + assert!(f.byte_span.end <= doc.source.len(), "fragment span out of bounds"); + assert!(f.byte_span.start <= f.byte_span.end); + } + } + } + + #[test] + fn plain_text_fragments_address_their_own_bytes() { + // Without escapes or entities, a Text event maps verbatim onto the source. + let text = sanitize("Một đoạn tiếng Việt bình thường.\n"); + let doc = parse(&text); + let f = &doc.blocks[0].fragments[0]; + assert_eq!(&doc.source[f.byte_span.clone()], f.text); + } + + #[test] + fn empty_document_is_handled() { + let doc = parse(""); + assert!(doc.blocks.is_empty()); + assert!(doc.headings.is_empty()); + assert!(doc.meta.title.is_none()); + } +} \ No newline at end of file diff --git a/crates/oxidiris-core/src/parser/mod.rs b/crates/oxidiris-core/src/parser/mod.rs new file mode 100644 index 0000000..49a1b1e --- /dev/null +++ b/crates/oxidiris-core/src/parser/mod.rs @@ -0,0 +1,83 @@ +//! Format-specific parsers producing a [`Document`]. +//! +//! Implements OXD-016 and OXD-017. See spec §8. + +pub mod markdown; +pub mod plaintext; + +use crate::token::Document; + +pub use markdown::parse as parse_markdown; +pub use plaintext::parse as parse_plaintext; + +/// Document formats the engine can currently read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + /// CommonMark and its common extensions. + Markdown, + /// Unstructured text, paragraphs separated by blank lines. + PlainText, +} + +impl Format { + /// Guess a format from a file extension. + pub fn from_extension(ext: Option<&str>) -> Format { + match ext.map(str::to_ascii_lowercase).as_deref() { + Some("md" | "markdown" | "mdx") => Format::Markdown, + _ => Format::PlainText, + } + } + + /// Guess a format from the content itself, for stdin and extensionless files. + pub fn sniff(text: &str) -> Format { + let markdownish = text + .lines() + .take(200) + .filter(|l| { + l.starts_with('#') + || l.starts_with("- ") + || l.starts_with("* ") + || l.starts_with("```") + || l.starts_with("> ") + || l.starts_with('|') + }) + .count(); + if markdownish >= 3 { Format::Markdown } else { Format::PlainText } + } + + /// Short identifier stored in [`crate::token::DocumentMeta::format`]. + pub const fn name(self) -> &'static str { + match self { + Format::Markdown => "markdown", + Format::PlainText => "plaintext", + } + } +} + +/// Parse `text` (already sanitized) using the given format. +pub fn parse(text: &str, format: Format) -> Document { + match format { + Format::Markdown => markdown::parse(text), + Format::PlainText => plaintext::parse(text), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extension_drives_format_selection() { + assert_eq!(Format::from_extension(Some("md")), Format::Markdown); + assert_eq!(Format::from_extension(Some("MD")), Format::Markdown); + assert_eq!(Format::from_extension(Some("txt")), Format::PlainText); + assert_eq!(Format::from_extension(None), Format::PlainText); + } + + #[test] + fn sniffing_recognises_markdown_structure() { + let md = "# Title\n\n- one\n- two\n\n```rust\nfn main() {}\n```\n"; + assert_eq!(Format::sniff(md), Format::Markdown); + assert_eq!(Format::sniff("Just a plain sentence.\n\nAnother one.\n"), Format::PlainText); + } +} \ No newline at end of file diff --git a/crates/oxidiris-core/src/parser/plaintext.rs b/crates/oxidiris-core/src/parser/plaintext.rs new file mode 100644 index 0000000..90ac69a --- /dev/null +++ b/crates/oxidiris-core/src/parser/plaintext.rs @@ -0,0 +1,135 @@ +//! Plain-text parser. +//! +//! Implements OXD-016. See spec §8.5. +//! +//! Simple, but it is also the reference implementation for every later parser: it establishes +//! that a block's readable text is assembled from [`Fragment`]s that each map back to an exact +//! byte range in the source. + +use crate::token::{Block, BlockKind, Document, DocumentMeta, Fragment, TokenKind}; + +/// Parse `text` into paragraphs separated by blank lines. +/// +/// # Hard-wrapped input +/// +/// RFCs and man pages wrap at column 72 and use blank lines for real paragraph breaks. Treating +/// every newline as a paragraph boundary would shatter such a file into dozens of one-line +/// pseudo-paragraphs, so lines inside a paragraph are rejoined (spec §8.5). +pub fn parse(text: &str) -> Document { + let mut blocks = Vec::new(); + let mut current: Vec<(usize, usize)> = Vec::new(); // line ranges of the paragraph being built + let mut offset = 0usize; + + let flush = |lines: &mut Vec<(usize, usize)>, blocks: &mut Vec| { + if lines.is_empty() { + return; + } + let id = blocks.len(); + let span_start = lines[0].0; + let span_end = lines[lines.len() - 1].1; + let fragments: Vec = lines + .iter() + .map(|(s, e)| Fragment { + text: text[*s..*e].to_string(), + byte_span: *s..*e, + kind: TokenKind::Word, + }) + .collect(); + let joined = fragments.iter().map(|f| f.text.as_str()).collect::>().join(" "); + blocks.push(Block { + id, + kind: BlockKind::Paragraph, + text: joined, + byte_span: span_start..span_end, + fragments, + }); + lines.clear(); + }; + + for line in text.split_inclusive('\n') { + let start = offset; + offset += line.len(); + let trimmed = line.trim_end_matches(['\n', '\r']); + let content = trimmed.trim(); + + if content.is_empty() { + flush(&mut current, &mut blocks); + continue; + } + // Record the trimmed span so byte offsets stay tight around the actual words. + let lead = trimmed.len() - trimmed.trim_start().len(); + let trail = trimmed.len() - trimmed.trim_end().len(); + current.push((start + lead, start + trimmed.len() - trail)); + } + flush(&mut current, &mut blocks); + + let title = blocks.first().map(|b| b.text.chars().take(80).collect::()); + + Document { + source: text.to_string(), + blocks, + headings: Vec::new(), + meta: DocumentMeta { title, format: Some("plaintext") }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::segment::sanitize; + + #[test] + fn blank_lines_separate_paragraphs() { + let doc = parse("First para.\n\nSecond para.\n"); + assert_eq!(doc.blocks.len(), 2); + assert_eq!(doc.blocks[0].text, "First para."); + assert_eq!(doc.blocks[1].text, "Second para."); + } + + /// The OXD-016 acceptance case: an RFC-style file must not explode into one block per line. + #[test] + fn hard_wrapped_lines_are_rejoined_into_one_paragraph() { + let rfc = "This document specifies an Internet standards track\n\ + protocol for the Internet community, and requests\n\ + discussion and suggestions for improvements.\n\ + \n\ + Distribution of this memo is unlimited.\n"; + let doc = parse(rfc); + assert_eq!(doc.blocks.len(), 2, "expected 2 paragraphs, got {}", doc.blocks.len()); + assert!(doc.blocks[0].text.contains("standards track protocol")); + } + + #[test] + fn multiple_blank_lines_do_not_create_empty_blocks() { + let doc = parse("A.\n\n\n\n\nB.\n"); + assert_eq!(doc.blocks.len(), 2); + } + + #[test] + fn empty_input_yields_no_blocks() { + let doc = parse(""); + assert!(doc.blocks.is_empty()); + assert!(doc.headings.is_empty()); + } + + /// The invariant every parser must uphold: a fragment's byte span must address exactly the + /// text it claims, inside `Document::source`. + #[test] + fn fragment_spans_address_their_own_text() { + let text = sanitize("Tiếng Việt có dấu.\n\nDòng thứ hai ở đây.\n"); + let doc = parse(&text); + for block in &doc.blocks { + for f in &block.fragments { + assert_eq!(&doc.source[f.byte_span.clone()], f.text); + } + } + } + + #[test] + fn leading_whitespace_is_excluded_from_spans() { + let doc = parse(" indented line\n"); + let f = &doc.blocks[0].fragments[0]; + assert_eq!(f.text, "indented line"); + assert_eq!(&doc.source[f.byte_span.clone()], "indented line"); + } +} \ No newline at end of file diff --git a/crates/oxidiris-core/src/player.rs b/crates/oxidiris-core/src/player.rs new file mode 100644 index 0000000..2fc1b4e --- /dev/null +++ b/crates/oxidiris-core/src/player.rs @@ -0,0 +1,385 @@ +//! Playback state machine for the RSVP stream. +//! +//! Implements OXD-018. See spec §4.1. +//! +//! # Why there is no clock in here +//! +//! [`Player`] never calls `Instant::now`, never sleeps, and holds no timer. It reports *how long* +//! the current token wants to be on screen and leaves the scheduling to the event loop. +//! +//! That split buys two things: the whole state machine is testable without real time passing, and +//! the same code runs unmodified under WebAssembly (OXD-080), where the host owns the clock. + +use crate::pacing::{self, PacingMode}; +use crate::token::{Document, Token}; + +/// Whether the stream is advancing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlayState { + /// Tokens are advancing. + Playing, + /// Cursor is held on the current token. + Paused, + /// Cursor has passed the last token. + Finished, +} + +/// Words moved per backstep / skip action (spec §7.2). +pub const SEEK_WORDS: usize = 5; +/// WPM change per coarse adjustment. +pub const WPM_STEP_COARSE: u16 = 25; +/// WPM change per fine adjustment. +pub const WPM_STEP_FINE: u16 = 5; + +/// Cursor and speed over a token stream. +#[derive(Debug, Clone)] +pub struct Player { + tokens: Vec, + cursor: usize, + wpm: u16, + state: PlayState, +} + +impl Player { + /// Build a player over an already-tokenized stream. + pub fn new(tokens: Vec, wpm: u16) -> Self { + let state = if tokens.is_empty() { PlayState::Finished } else { PlayState::Paused }; + Player { tokens, cursor: 0, wpm: pacing::clamp_wpm(wpm), state } + } + + /// Build a player straight from a parsed document. + pub fn from_document(doc: &Document, wpm: u16, mode: PacingMode) -> Self { + Self::new(pacing::tokenize(doc, mode), wpm) + } + + /// The full token stream. + pub fn tokens(&self) -> &[Token] { + &self.tokens + } + + /// Current playback state. + pub fn state(&self) -> PlayState { + self.state + } + + /// Whether the stream is currently advancing. + pub fn is_playing(&self) -> bool { + self.state == PlayState::Playing + } + + /// Configured reading speed, in words per minute. + pub fn wpm(&self) -> u16 { + self.wpm + } + + /// Actual speed once pacing multipliers are accounted for (spec §3.2.4). + pub fn effective_wpm(&self) -> u16 { + pacing::effective_wpm(&self.tokens, self.wpm) + } + + /// Token currently on screen, if any. + pub fn current(&self) -> Option<&Token> { + self.tokens.get(self.cursor) + } + + /// How long the current token should remain visible. + pub fn current_duration_ms(&self) -> u32 { + self.current().map_or(0, |t| t.duration_ms(self.wpm)) + } + + /// `(current index, total tokens)`, where the index is 1-based for display. + pub fn progress(&self) -> (usize, usize) { + (self.cursor.min(self.tokens.len().saturating_sub(1)) + 1, self.tokens.len()) + } + + /// Fraction of the document already consumed, in `0.0..=1.0`. + pub fn progress_ratio(&self) -> f64 { + if self.tokens.is_empty() { + return 1.0; + } + self.cursor as f64 / self.tokens.len() as f64 + } + + /// Start advancing. Restarts from the top if the stream had finished. + pub fn play(&mut self) { + if self.tokens.is_empty() { + return; + } + if self.state == PlayState::Finished { + self.cursor = 0; + } + self.state = PlayState::Playing; + } + + /// Hold the cursor on the current token. + pub fn pause(&mut self) { + if self.state == PlayState::Playing { + self.state = PlayState::Paused; + } + } + + /// Flip between playing and paused. + pub fn toggle(&mut self) { + match self.state { + PlayState::Playing => self.pause(), + PlayState::Paused | PlayState::Finished => self.play(), + } + } + + /// Advance one token. Returns the token now on screen, or `None` at the end of the stream. + pub fn advance(&mut self) -> Option<&Token> { + if self.tokens.is_empty() { + self.state = PlayState::Finished; + return None; + } + if self.cursor + 1 >= self.tokens.len() { + self.cursor = self.tokens.len() - 1; + self.state = PlayState::Finished; + return None; + } + self.cursor += 1; + self.tokens.get(self.cursor) + } + + /// Jump to the beginning and pause. + pub fn restart(&mut self) { + self.cursor = 0; + self.state = if self.tokens.is_empty() { PlayState::Finished } else { PlayState::Paused }; + } + + /// Move the cursor by `delta` words, clamped to the document. + pub fn seek_words(&mut self, delta: isize) { + if self.tokens.is_empty() { + return; + } + let last = self.tokens.len() - 1; + let target = self.cursor as isize + delta; + self.cursor = target.clamp(0, last as isize) as usize; + if self.state == PlayState::Finished && self.cursor < last { + self.state = PlayState::Paused; + } + } + + /// Move to the first token of the previous or next block. + /// + /// Block identity comes from the parser, not from guessing at the text: paragraph boundaries + /// in LaTeX or PDF look nothing like Markdown's (spec §3.3). + pub fn seek_blocks(&mut self, delta: isize) { + if self.tokens.is_empty() || delta == 0 { + return; + } + let current_block = self.tokens[self.cursor].block_id; + let target = if delta > 0 { + self.tokens.iter().position(|t| t.block_id > current_block) + } else { + // First token of the current block; if already there, of the previous one. + let first_of_current = + self.tokens.iter().position(|t| t.block_id == current_block).unwrap_or(0); + if self.cursor > first_of_current { + Some(first_of_current) + } else { + self.tokens + .iter() + .rposition(|t| t.block_id < current_block) + .map(|idx| { + let b = self.tokens[idx].block_id; + self.tokens.iter().position(|t| t.block_id == b).unwrap_or(idx) + }) + .or(Some(0)) + } + }; + if let Some(idx) = target { + self.cursor = idx; + self.state = match self.state { + PlayState::Finished => PlayState::Paused, + other => other, + }; + } + } + + /// Jump to a fraction of the document, `0.0..=1.0`. + pub fn seek_ratio(&mut self, ratio: f64) { + if self.tokens.is_empty() { + return; + } + let last = self.tokens.len() - 1; + let idx = (ratio.clamp(0.0, 1.0) * last as f64).round() as usize; + self.cursor = idx.min(last); + if self.state == PlayState::Finished { + self.state = PlayState::Paused; + } + } + + /// Jump to the first token. + pub fn goto_start(&mut self) { + self.seek_ratio(0.0); + } + + /// Jump to the last token. + pub fn goto_end(&mut self) { + self.seek_ratio(1.0); + } + + /// Set the reading speed. Clamped to the supported range. + /// + /// Only affects tokens not yet shown: durations are derived from the token weight at read + /// time rather than baked in when the document was parsed. + pub fn set_wpm(&mut self, wpm: u16) { + self.wpm = pacing::clamp_wpm(wpm); + } + + /// Change the reading speed by `delta`, saturating at the supported bounds. + pub fn adjust_wpm(&mut self, delta: i32) { + let next = i32::from(self.wpm) + delta; + self.set_wpm(next.clamp(0, i32::from(u16::MAX)) as u16); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{parser, segment}; + + fn player_from(md: &str) -> Player { + let doc = parser::parse_markdown(&segment::sanitize(md)); + Player::from_document(&doc, 300, PacingMode::Natural) + } + + const DOC: &str = "First block has several words here.\n\nSecond block also has words.\n\nThird block ends it.\n"; + + #[test] + fn empty_document_is_safe_for_every_operation() { + let mut p = Player::new(Vec::new(), 300); + assert_eq!(p.state(), PlayState::Finished); + assert!(p.current().is_none()); + assert_eq!(p.current_duration_ms(), 0); + p.play(); + p.advance(); + p.seek_words(-10); + p.seek_blocks(1); + p.seek_ratio(0.5); + p.restart(); + assert!(p.current().is_none()); + } + + #[test] + fn advance_walks_to_the_end_then_finishes() { + let mut p = player_from(DOC); + let total = p.tokens().len(); + assert!(total > 5); + p.play(); + let mut steps = 0; + while p.advance().is_some() { + steps += 1; + assert!(steps < total + 5, "advance failed to terminate"); + } + assert_eq!(p.state(), PlayState::Finished); + assert_eq!(p.progress().0, total); + } + + #[test] + fn seek_is_clamped_at_both_ends() { + let mut p = player_from(DOC); + p.seek_words(-1_000); + assert_eq!(p.progress().0, 1); + p.seek_words(1_000_000); + assert_eq!(p.progress().0, p.tokens().len()); + } + + #[test] + fn seek_words_does_not_overflow_on_extreme_deltas() { + let mut p = player_from(DOC); + p.seek_words(isize::MAX); + p.seek_words(isize::MIN); + assert_eq!(p.progress().0, 1); + } + + #[test] + fn block_navigation_uses_block_ids() { + let mut p = player_from(DOC); + let first_block = p.current().unwrap().block_id; + p.seek_blocks(1); + assert!(p.current().unwrap().block_id > first_block); + p.seek_blocks(-1); + assert_eq!(p.current().unwrap().block_id, first_block); + } + + #[test] + fn block_navigation_at_the_start_stays_at_the_start() { + let mut p = player_from(DOC); + p.seek_blocks(-1); + p.seek_blocks(-1); + assert_eq!(p.progress().0, 1); + } + + #[test] + fn seek_ratio_lands_where_expected() { + let mut p = player_from(DOC); + p.seek_ratio(1.0); + assert_eq!(p.progress().0, p.tokens().len()); + p.seek_ratio(0.0); + assert_eq!(p.progress().0, 1); + p.seek_ratio(-5.0); + assert_eq!(p.progress().0, 1); + } + + /// Changing speed mid-document must not rewrite the timing of tokens already read. + #[test] + fn set_wpm_is_not_retroactive() { + let mut p = player_from(DOC); + let before = p.current_duration_ms(); + p.advance(); + p.set_wpm(600); + let after = p.current_duration_ms(); + assert!(after < before, "faster WPM should shorten upcoming tokens"); + // The token stream itself is untouched: only the derived duration changed. + assert_eq!(p.tokens()[0].weight, p.tokens()[0].weight); + } + + #[test] + fn wpm_is_clamped_by_the_player() { + let mut p = player_from(DOC); + p.set_wpm(5); + assert_eq!(p.wpm(), pacing::MIN_WPM); + p.adjust_wpm(100_000); + assert_eq!(p.wpm(), pacing::MAX_WPM); + p.adjust_wpm(-100_000); + assert_eq!(p.wpm(), pacing::MIN_WPM); + } + + #[test] + fn toggle_cycles_play_and_pause() { + let mut p = player_from(DOC); + assert_eq!(p.state(), PlayState::Paused); + p.toggle(); + assert_eq!(p.state(), PlayState::Playing); + p.toggle(); + assert_eq!(p.state(), PlayState::Paused); + } + + #[test] + fn play_after_finishing_restarts_from_the_top() { + let mut p = player_from(DOC); + p.goto_end(); + while p.advance().is_some() {} + assert_eq!(p.state(), PlayState::Finished); + p.play(); + assert_eq!(p.progress().0, 1); + assert_eq!(p.state(), PlayState::Playing); + } + + /// The whole state machine must be exercisable without any real time passing. + #[test] + fn no_wall_clock_is_needed_to_drive_playback() { + let mut p = player_from(DOC); + p.play(); + let mut virtual_elapsed_ms: u64 = 0; + while p.current().is_some() { + virtual_elapsed_ms += u64::from(p.current_duration_ms()); + if p.advance().is_none() { + break; + } + } + assert!(virtual_elapsed_ms > 0); + } +} \ No newline at end of file diff --git a/crates/oxidiris-core/src/segment.rs b/crates/oxidiris-core/src/segment.rs new file mode 100644 index 0000000..34a5e46 --- /dev/null +++ b/crates/oxidiris-core/src/segment.rs @@ -0,0 +1,168 @@ +//! Unicode-correct text sanitisation and word splitting. +//! +//! Implements OXD-011. See spec §3.1.1 and §8.6. +//! +//! # The three traps this module exists to avoid +//! +//! 1. **Grapheme != char != byte.** Vietnamese `ế` is one code point in NFC and three in NFD. +//! Counting `char`s would give the same word two different ORP indices depending on which +//! form the file happened to use. Everything is therefore normalized to NFC once, up front. +//! 2. **Display width != grapheme count.** CJK and most emoji occupy two terminal columns. +//! Anchoring by grapheme count would make the text drift horizontally, destroying the exact +//! spatial consistency the tool is built to provide. +//! 3. **Zero-width and control characters.** Bidi overrides and control codes have zero or +//! undefined width, so they are stripped before anything else looks at the text. + +use unicode_normalization::UnicodeNormalization; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +/// A word extracted from a text fragment, with its byte range inside that fragment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Word<'a> { + /// The word itself, punctuation included. + pub text: &'a str, + /// Byte offset of the word within the fragment it was split from. + pub start: usize, + /// Byte offset just past the word. + pub end: usize, +} + +/// Returns true for characters that must never reach the renderer. +/// +/// Keeps `\n` and `\t` out of the "strip" set: they are meaningful to the parsers, which convert +/// them to structure before tokenization happens. +fn is_stripped(c: char) -> bool { + match c { + '\n' | '\t' | '\r' => false, + // C0/C1 controls. + c if c.is_control() => true, + // Bidi embedding, override and isolate controls. + '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' => true, + // Zero-width space / non-joiner. ZWJ (U+200D) is kept: it welds emoji sequences into a + // single grapheme cluster, so removing it would split them. + '\u{200B}' | '\u{200C}' | '\u{FEFF}' => true, + _ => false, + } +} + +/// Normalize to NFC and remove characters that must not be rendered. +/// +/// This is the single entry point that makes NFC and NFD inputs converge: run it once when the +/// document is loaded, and every downstream byte offset refers to the normalized string. +pub fn sanitize(input: &str) -> String { + input.nfc().filter(|c| !is_stripped(*c)).collect() +} + +/// Number of grapheme clusters in `text`. +/// +/// Emoji joined with ZWJ (families, flags, skin-tone sequences) count as one. +pub fn grapheme_count(text: &str) -> usize { + text.graphemes(true).count() +} + +/// Terminal column width of `text` per UAX #11. +/// +/// This is what the renderer must use for alignment: `"日本語"` is 3 graphemes but 6 columns. +pub fn display_width(text: &str) -> u16 { + UnicodeWidthStr::width(text).min(u16::MAX as usize) as u16 +} + +/// Split a text fragment into RSVP display units. +/// +/// Splitting is whitespace-based rather than UAX #29 word-boundary based, because the display +/// unit must keep its trailing punctuation attached: the pacing engine needs to see `"end."` as +/// one unit to know a sentence just closed. UAX #29 would hand back `"end"` and `"."` separately. +/// +/// # Scripts without spaces +/// +/// For CJK this yields one enormous "word" per run of text, which is not useful. That is a known +/// gap, deliberately left open pending DEC-02 rather than papered over with a half-working +/// heuristic (spec §8.6). +pub fn split_words(text: &str) -> Vec> { + let mut words = Vec::new(); + let mut start: Option = None; + + for (idx, c) in text.char_indices() { + if c.is_whitespace() { + if let Some(s) = start.take() { + words.push(Word { text: &text[s..idx], start: s, end: idx }); + } + } else if start.is_none() { + start = Some(idx); + } + } + if let Some(s) = start { + words.push(Word { text: &text[s..], start: s, end: text.len() }); + } + words +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nfc_and_nfd_converge_to_identical_text() { + // "tiếng Việt" written in composed and decomposed form. + let nfc = "ti\u{1EBF}ng Vi\u{1EC7}t"; + let nfd = "tie\u{302}\u{301}ng Vie\u{323}\u{302}t"; + assert_ne!(nfc, nfd, "inputs must genuinely differ before normalization"); + assert_eq!(sanitize(nfc), sanitize(nfd)); + } + + #[test] + fn nfc_and_nfd_produce_identical_word_streams() { + let nfc = sanitize("ti\u{1EBF}ng Vi\u{1EC7}t"); + let nfd = sanitize("tie\u{302}\u{301}ng Vie\u{323}\u{302}t"); + let a: Vec<_> = split_words(&nfc).iter().map(|w| w.text.to_string()).collect(); + let b: Vec<_> = split_words(&nfd).iter().map(|w| w.text.to_string()).collect(); + assert_eq!(a, b); + } + + #[test] + fn cjk_is_three_graphemes_but_six_columns() { + assert_eq!(grapheme_count("日本語"), 3); + assert_eq!(display_width("日本語"), 6); + } + + #[test] + fn zwj_emoji_stays_one_grapheme() { + // Family: man + ZWJ + woman + ZWJ + girl. + let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; + assert_eq!(grapheme_count(&sanitize(family)), 1); + } + + #[test] + fn regional_indicator_flag_stays_one_grapheme() { + let flag = "\u{1F1FB}\u{1F1F3}"; // VN + assert_eq!(grapheme_count(&sanitize(flag)), 1); + } + + #[test] + fn control_and_bidi_characters_are_stripped() { + let dirty = "a\u{202E}b\u{0007}c\u{200B}d"; + assert_eq!(sanitize(dirty), "abcd"); + } + + #[test] + fn empty_input_yields_no_words() { + assert!(split_words("").is_empty()); + assert!(split_words(" \n\t ").is_empty()); + assert_eq!(sanitize(""), ""); + } + + #[test] + fn word_ranges_index_back_into_the_input() { + let text = "alpha beta gamma"; + for w in split_words(text) { + assert_eq!(&text[w.start..w.end], w.text); + } + } + + #[test] + fn punctuation_stays_attached_to_its_word() { + let words: Vec<_> = split_words("Hello, world.").iter().map(|w| w.text).collect(); + assert_eq!(words, vec!["Hello,", "world."]); + } +} \ No newline at end of file diff --git a/crates/oxidiris-core/src/sentence.rs b/crates/oxidiris-core/src/sentence.rs new file mode 100644 index 0000000..ad648eb --- /dev/null +++ b/crates/oxidiris-core/src/sentence.rs @@ -0,0 +1,157 @@ +//! Sentence-boundary detection for pacing. +//! +//! Implements OXD-014. See spec §3.2.2. +//! +//! A period is not always a full stop. In technical and academic prose, `Fig. 3`, `et al.`, +//! `i.e.`, `v1.2.3` and `Dr.` would each earn a 2.25x pause under a naive rule, turning a smooth +//! reading rhythm into a stutter. + +/// Abbreviations that end in a period without ending a sentence. +/// +/// Matched case-insensitively against the word with surrounding punctuation trimmed. +const ABBREVIATIONS: &[&str] = &[ + "al.", "approx.", "ca.", "cf.", "dr.", "e.g.", "ed.", "eds.", "eq.", "esp.", "est.", "etc.", + "et.", "fig.", "figs.", "i.e.", "inc.", "jr.", "ltd.", "mr.", "mrs.", "ms.", "no.", "nos.", + "op.", "p.", "pp.", "prof.", "resp.", "sec.", "sr.", "st.", "vol.", "vs.", "viz.", +]; + +/// Closing characters that may trail a sentence-ending mark. +const CLOSERS: &[char] = &[')', ']', '}', '"', '\'', '\u{201D}', '\u{2019}', '\u{00BB}']; + +/// Strength of the pause a word earns from its trailing punctuation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Break { + /// No punctuation break. + None, + /// Comma, semicolon or colon. + Clause, + /// Full stop, question mark or exclamation mark. + Sentence, +} + +impl Break { + /// Multiplier applied to the base word duration (spec §3.2.1). + pub const fn factor(self) -> f32 { + match self { + Break::None => 1.0, + Break::Clause => 1.5, + Break::Sentence => 2.25, + } + } +} + +/// Classify the punctuation break after `word`, given the word that follows it. +/// +/// `next` is required because the decisive signal for an ambiguous period is what comes after it: +/// a lowercase letter or a digit means the sentence is still running. +pub fn classify(word: &str, next: Option<&str>) -> Break { + let trimmed = word.trim_end_matches(CLOSERS); + let Some(last) = trimmed.chars().last() else { + return Break::None; + }; + + match last { + '!' | '?' | '\u{2026}' => Break::Sentence, + ',' | ';' | ':' => Break::Clause, + '.' => { + if is_abbreviation(trimmed) || is_numeric_period(trimmed) || continues_after(next) { + Break::None + } else { + Break::Sentence + } + } + _ => Break::None, + } +} + +/// Whether `word` is a known abbreviation or a single-letter initial such as `K.`. +fn is_abbreviation(word: &str) -> bool { + let lower = word.trim_start_matches(['(', '[', '{', '"', '\'']).to_lowercase(); + if ABBREVIATIONS.contains(&lower.as_str()) { + return true; + } + // Single-letter initials: "K.", "J." in author lists. + let mut chars = lower.chars(); + matches!((chars.next(), chars.next(), chars.next()), (Some(c), Some('.'), None) if c.is_alphabetic()) +} + +/// Whether the period belongs to a number or version string rather than to a sentence. +/// +/// Catches `3.` inside enumerations and `v1.2.3.` at the end of a clause. +fn is_numeric_period(word: &str) -> bool { + let body = word.trim_end_matches('.'); + !body.is_empty() && body.chars().any(|c| c.is_ascii_digit()) && body.contains('.') +} + +/// Whether the following word signals that the sentence has not ended. +fn continues_after(next: Option<&str>) -> bool { + let Some(next) = next else { + // End of block. Treat as a genuine stop. + return false; + }; + let Some(first) = next.chars().find(|c| c.is_alphanumeric()) else { + return false; + }; + first.is_numeric() || first.is_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_full_stop_ends_a_sentence() { + assert_eq!(classify("thúc.", Some("Câu")), Break::Sentence); + assert_eq!(classify("done.", Some("Next")), Break::Sentence); + } + + #[test] + fn question_and_exclamation_end_a_sentence() { + assert_eq!(classify("really?", Some("yes")), Break::Sentence); + assert_eq!(classify("wow!", Some("that")), Break::Sentence); + } + + #[test] + fn commas_and_colons_are_clause_breaks() { + assert_eq!(classify("first,", Some("second")), Break::Clause); + assert_eq!(classify("note:", Some("this")), Break::Clause); + } + + /// The acceptance case for OXD-014: none of these may produce a sentence pause. + #[test] + fn technical_abbreviations_do_not_end_sentences() { + let cases = [ + ("Fig.", Some("3")), + ("et", Some("al.")), + ("al.", Some("showed")), + ("i.e.", Some("the")), + ("e.g.", Some("Rust")), + ("vs.", Some("the")), + ("No.", Some("5")), + ("Dr.", Some("Rayner")), + ("v1.2.3", Some("release")), + ("std::fmt", Some("module")), + ("3.14", Some("approx")), + ]; + for (word, next) in cases { + assert_eq!(classify(word, next), Break::None, "word {word:?} must not end a sentence"); + } + } + + #[test] + fn lowercase_follower_suppresses_the_break() { + assert_eq!(classify("Approx.", Some("twenty")), Break::None); + } + + #[test] + fn closing_quote_after_a_stop_still_counts() { + assert_eq!(classify("\"done.\"", Some("Then")), Break::Sentence); + assert_eq!(classify("(done.)", Some("Then")), Break::Sentence); + } + + #[test] + fn empty_and_punctuation_only_words_are_safe() { + assert_eq!(classify("", None), Break::None); + assert_eq!(classify(")", None), Break::None); + } +} \ No newline at end of file diff --git a/crates/oxidiris-core/src/token.rs b/crates/oxidiris-core/src/token.rs new file mode 100644 index 0000000..535128f --- /dev/null +++ b/crates/oxidiris-core/src/token.rs @@ -0,0 +1,260 @@ +//! Core data types: [`Token`], [`Block`], [`Heading`] and [`Document`]. +//! +//! Implements OXD-010. See spec §4.1. + +use core::ops::Range; + +/// Semantic class of a token, used to pick a pacing multiplier and a render style. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TokenKind { + /// Ordinary prose word. + Word, + /// Inline code span or a line inside a fenced code block. + Code, + /// Math expression, kept whole rather than split into meaningless fragments. + Math, + /// Heading text, carrying its level (1..=6). + Heading(u8), + /// A word inside a list item. + ListItem, + /// A citation label such as `[12]` or a rendered `\cite{...}`. + Citation, + /// Table content. Excluded from the RSVP stream (see spec §8.1). + Table, +} + +impl TokenKind { + /// Pacing multiplier applied on top of the base word duration. + /// + /// See spec §3.2.1 (`kind_factor`). + pub const fn pacing_factor(self) -> f32 { + match self { + TokenKind::Word | TokenKind::ListItem => 1.0, + TokenKind::Heading(_) => 1.15, + TokenKind::Citation => 0.8, + TokenKind::Code | TokenKind::Math => 1.5, + TokenKind::Table => 1.0, + } + } +} + +/// One display unit of the RSVP stream. +/// +/// # Timing model +/// +/// A token deliberately does **not** store a fixed `duration_ms`. It stores the +/// WPM-independent [`Token::weight`] and [`Token::pause_ms`], and the final duration is derived +/// on demand via [`Token::duration_ms`]. +/// +/// This is what makes a mid-document speed change non-retroactive: tokens already consumed keep +/// the timing they were shown with, and only upcoming tokens pick up the new WPM. Baking an +/// absolute duration into the token would force a full re-pace of the document on every keypress. +/// +/// > Deviation from spec §4.1, which listed a plain `duration_ms` field. Recorded in +/// > `docs/decisions/token-timing.md`. +#[derive(Debug, Clone, PartialEq)] +pub struct Token { + /// Text as displayed, already NFC-normalized and stripped of control characters. + pub text: String, + /// Index of the ORP grapheme within [`Token::text`], counted in grapheme clusters. + pub orp_index: usize, + /// Total terminal column width of [`Token::text`] (UAX #11), not the character count. + pub display_width: u16, + /// Column width of the graphemes *before* the ORP grapheme. + /// + /// The renderer left-pads by `anchor_column - orp_offset` so the ORP grapheme always starts + /// at the same absolute column, no matter how long the word is (spec §3.1.3). + pub orp_offset: u16, + /// WPM-independent duration multiplier (length x punctuation x kind). + pub weight: f32, + /// Structural pause added *after* this token, in milliseconds. Not scaled by WPM. + pub pause_ms: u32, + /// Semantic class of this token. + pub kind: TokenKind, + /// Index of the [`Block`] this token belongs to. Drives paragraph navigation. + pub block_id: usize, + /// Byte range of this token inside [`Document::source`]. + /// + /// This is the hook that lets the full-text panel highlight the word currently being shown + /// (OXD-031) and lets Review Mode rebuild an entire paragraph verbatim (OXD-034). + pub byte_span: Range, +} + +impl Token { + /// Duration this token should stay on screen at the given speed. + /// + /// `wpm` is expected to be pre-clamped by [`crate::pacing::clamp_wpm`]. + pub fn duration_ms(&self, wpm: u16) -> u32 { + let base = 60_000.0 / f32::from(wpm.max(1)); + (base * self.weight) as u32 + self.pause_ms + } +} + +/// Structural class of a [`Block`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BlockKind { + /// Ordinary prose paragraph. + Paragraph, + /// Section heading of the given level. + Heading(u8), + /// Fenced or indented code block. + CodeBlock, + /// A single item of a bulleted or numbered list. + ListItem, + /// Block quote. + Quote, + /// Tabular data, rendered whole rather than read word by word. + Table, +} + +impl BlockKind { + /// Whether blocks of this kind contribute tokens to the RSVP stream. + /// + /// Tables are rendered whole in the side panel instead of being read word by word, + /// because a table read sequentially is meaningless (spec §8.1). + pub const fn is_readable(self) -> bool { + !matches!(self, BlockKind::Table) + } + + /// Pause inserted after a block of this kind, in milliseconds (spec §3.2.1). + pub const fn trailing_pause_ms(self) -> u32 { + match self { + BlockKind::Paragraph | BlockKind::Quote => 250, + BlockKind::Heading(_) => 250, + BlockKind::ListItem => 150, + BlockKind::CodeBlock => 300, + BlockKind::Table => 0, + } + } +} + +/// A structural unit of the document: a paragraph, heading, list item, code block, ... +#[derive(Debug, Clone, PartialEq)] +pub struct Block { + /// Position of this block in [`Document::blocks`]. + pub id: usize, + /// Structural class of this block. + pub kind: BlockKind, + /// Readable text of this block, with markup already stripped. + pub text: String, + /// Byte range covering this block inside [`Document::source`]. + pub byte_span: Range, + /// Text fragments making up this block, each mapped back to its own source range. + /// + /// Markup removal makes block text non-contiguous in the source, so per-token byte spans are + /// derived from these fragments rather than from [`Block::byte_span`]. + pub fragments: Vec, +} + +/// A contiguous run of readable text mapped back to its exact source range. +#[derive(Debug, Clone, PartialEq)] +pub struct Fragment { + /// Readable text of this fragment. + pub text: String, + /// Byte range of this fragment inside [`Document::source`]. + pub byte_span: Range, + /// Semantic class inherited by every token produced from this fragment. + pub kind: TokenKind, +} + +/// An entry in the document outline (table of contents). +#[derive(Debug, Clone, PartialEq)] +pub struct Heading { + /// Heading level, 1 through 6. + pub level: u8, + /// Heading text with markup removed. + pub text: String, + /// Index of the block this heading corresponds to. + pub block_id: usize, +} + +/// Document-level metadata. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DocumentMeta { + /// Display title, usually the first H1 or the file name. + pub title: Option, + /// Which parser produced this document. + pub format: Option<&'static str>, +} + +/// A parsed document: normalized source text plus its structure. +#[derive(Debug, Clone, PartialEq)] +pub struct Document { + /// Canonical text: decoded, NFC-normalized, control characters stripped. + /// + /// Every [`Token::byte_span`] and [`Block::byte_span`] indexes into *this* string, not into + /// the raw bytes on disk. Normalizing once up front is what makes NFC and NFD inputs produce + /// byte-identical documents (spec §3.1.1). + pub source: String, + /// Structural blocks in reading order. + pub blocks: Vec, + /// Flat outline used for the table of contents. + pub headings: Vec, + /// Document-level metadata. + pub meta: DocumentMeta, +} + +impl Document { + /// Plain-text rendering of the document, used by `--dump` (OXD-026). + pub fn to_plain_text(&self) -> String { + let mut out = String::with_capacity(self.source.len()); + for block in &self.blocks { + match block.kind { + BlockKind::Heading(level) => { + out.push_str(&"#".repeat(level as usize)); + out.push(' '); + } + BlockKind::ListItem => out.push_str("- "), + BlockKind::Quote => out.push_str("> "), + _ => {} + } + out.push_str(&block.text); + out.push_str("\n\n"); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn token(weight: f32, pause_ms: u32) -> Token { + Token { + text: "x".into(), + orp_index: 0, + display_width: 1, + orp_offset: 0, + weight, + pause_ms, + kind: TokenKind::Word, + block_id: 0, + byte_span: 0..1, + } + } + + #[test] + fn duration_scales_inversely_with_wpm() { + let t = token(1.0, 0); + assert_eq!(t.duration_ms(300), 200); + assert_eq!(t.duration_ms(600), 100); + } + + #[test] + fn structural_pause_is_not_scaled_by_wpm() { + let t = token(1.0, 250); + assert_eq!(t.duration_ms(300), 450); + assert_eq!(t.duration_ms(600), 350); + } + + #[test] + fn duration_never_divides_by_zero() { + assert!(token(1.0, 0).duration_ms(0) > 0); + } + + #[test] + fn tables_are_excluded_from_the_rsvp_stream() { + assert!(!BlockKind::Table.is_readable()); + assert!(BlockKind::Paragraph.is_readable()); + } +} \ No newline at end of file diff --git a/crates/oxidiris/Cargo.toml b/crates/oxidiris/Cargo.toml new file mode 100644 index 0000000..7127e6a --- /dev/null +++ b/crates/oxidiris/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "oxidiris" +description = "A Rust TUI that leverages RSVP to stream docs to your focus point - zero eye movement required." +license = "GPL-3.0-or-later" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +oxidiris-core = { path = "../oxidiris-core", version = "0.1.0" } +ratatui.workspace = true +crossterm.workspace = true +clap.workspace = true +anyhow.workspace = true \ No newline at end of file diff --git a/oxidiris/src/main.rs b/crates/oxidiris/src/main.rs similarity index 100% rename from oxidiris/src/main.rs rename to crates/oxidiris/src/main.rs diff --git a/oxidiris/Cargo.toml b/oxidiris/Cargo.toml deleted file mode 100644 index b2bd77f..0000000 --- a/oxidiris/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "oxidiris" -version = "0.1.0" -edition = "2024" -description = "A Rust TUI that leverages RSVP to stream docs to your focus point—zero eye movement required." - -[dependencies] -ratatui = "0.30.2" \ No newline at end of file From 3fe542ff496665a4e951b65e05cfc0366deb883f Mon Sep 17 00:00:00 2001 From: Aleksandr Slokov Date: Tue, 1 Sep 2026 18:23:31 +0700 Subject: [PATCH 2/3] Make a POC --- BACKLOG.md | 68 ++++++--- README.md | 107 +++++++++++++- crates/oxidiris-core/src/encoding.rs | 6 +- crates/oxidiris-core/src/lib.rs | 4 +- crates/oxidiris-core/src/orp.rs | 2 +- crates/oxidiris-core/src/pacing.rs | 50 +++++-- crates/oxidiris-core/src/parser/markdown.rs | 41 +++++- crates/oxidiris-core/src/parser/mod.rs | 2 +- crates/oxidiris-core/src/parser/plaintext.rs | 2 +- crates/oxidiris-core/src/player.rs | 26 +++- crates/oxidiris-core/src/segment.rs | 18 ++- crates/oxidiris-core/src/sentence.rs | 2 +- crates/oxidiris-core/src/token.rs | 2 +- crates/oxidiris/src/main.rs | 145 ++++++++++++++++++- docs/informations/proposals.md | 14 +- 15 files changed, 429 insertions(+), 60 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 10dc010..7b5ad2e 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -70,31 +70,57 @@ Critical path: `OXD-001 → OXD-010 → OXD-011 → OXD-012 → OXD-018 → OXD- --- +## Current State + +**Milestone v0.1 (OXD-027) is complete.** Phases 0-2 are implemented, tested and verified against +this file: `oxidiris BACKLOG.md` reads it as 5 494 tokens across 81 headings. + +| | | +|---|---| +| Tests | 175 passing (81 engine, 81 terminal, 12 corpus, 1 doctest) | +| Gate | `make check` — fmt, clippy `-D warnings`, tests, wasm constraint | +| Verified | Anchor column fixed across the full Unicode corpus; deadline scheduler drift-free over 1 000 steps; live pty session confirms key dispatch, help popup and terminal restore | + +Deviations from the spec found during implementation are recorded in `docs/decisions/` and +back-annotated into the spec, per the note at the bottom of this file. So far: ADR 001 +(`token-timing.md`), which replaced `Token::duration_ms` with a WPM-independent weight. + +Known gaps carried forward, all deliberate and tracked: + +- Split view, outline and search are Phase 3-4 (OXD-031, OXD-032, OXD-043) +- CJK still splits on whitespace only — blocked on **DEC-02**, not papered over +- `--theme`, `--chunk`, `--start`, `--no-resume`, `--config` parse but report themselves as + unimplemented in the status bar rather than failing silently +- `-` (stdin) falls back to `--dump`: the TUI cannot read keys while stdin is a pipe. Proper + support needs OXD-047 + +--- + ## Overview Table | ID | Task | Phase | Size | Depends | Status | |---|---|---|---|---|---| -| OXD-001 | Split Cargo workspace | 0 | M | — | ⬜ Todo | -| OXD-002 | Metadata & MSRV | 0 | S | OXD-001 | ⬜ Todo | -| OXD-003 | CI baseline | 0 | M | OXD-001 | ⬜ Todo | -| OXD-004 | `testdata/` corpus | 0 | S | OXD-001 | ⬜ Todo | -| OXD-010 | Token & Document data types | 1 | M | OXD-001 | ⬜ Todo | -| OXD-011 | Unicode Segmenter | 1 | M | OXD-010 | ⬜ Todo | -| OXD-012 | ORP Algorithm | 1 | M | OXD-011 | ⬜ Todo | -| OXD-013 | Pacing Engine | 1 | M | OXD-010 | ⬜ Todo | -| OXD-014 | Sentence boundary heuristic | 1 | S | OXD-013 | ⬜ Todo | -| OXD-015 | Encoding Detection | 1 | S | OXD-001 | ⬜ Todo | -| OXD-016 | Plain Text Parser | 1 | M | OXD-010 | ⬜ Todo | -| OXD-017 | Markdown Parser | 1 | L | OXD-016 | ⬜ Todo | -| OXD-018 | Player state machine | 1 | L | OXD-012, OXD-013 | ⬜ Todo | -| OXD-020 | CLI Framework (clap) | 2 | M | OXD-001 | ⬜ Todo | -| OXD-021 | Event loop + deadline clock | 2 | L | OXD-018 | ⬜ Todo | -| OXD-022 | Terminal capability detection | 2 | M | OXD-020 | ⬜ Todo | -| OXD-023 | RSVP Widget (anchor column) | 2 | L | OXD-021, OXD-022 | ⬜ Todo | -| OXD-024 | Status bar & progress bar | 2 | S | OXD-023 | ⬜ Todo | -| OXD-025 | Help popup `?` | 2 | S | OXD-023 | ⬜ Todo | -| OXD-026 | `--dump` mode | 2 | S | OXD-017, OXD-020 | ⬜ Todo | -| OXD-027 | Milestone v0.1 | 2 | S | OXD-023…026 | ⬜ Todo | +| OXD-001 | Split Cargo workspace | 0 | M | — | ✅ Done | +| OXD-002 | Metadata & MSRV | 0 | S | OXD-001 | ✅ Done | +| OXD-003 | CI baseline | 0 | M | OXD-001 | ✅ Done | +| OXD-004 | `testdata/` corpus | 0 | S | OXD-001 | ✅ Done | +| OXD-010 | Token & Document data types | 1 | M | OXD-001 | ✅ Done | +| OXD-011 | Unicode Segmenter | 1 | M | OXD-010 | ✅ Done | +| OXD-012 | ORP Algorithm | 1 | M | OXD-011 | ✅ Done | +| OXD-013 | Pacing Engine | 1 | M | OXD-010 | ✅ Done | +| OXD-014 | Sentence boundary heuristic | 1 | S | OXD-013 | ✅ Done | +| OXD-015 | Encoding Detection | 1 | S | OXD-001 | ✅ Done | +| OXD-016 | Plain Text Parser | 1 | M | OXD-010 | ✅ Done | +| OXD-017 | Markdown Parser | 1 | L | OXD-016 | ✅ Done | +| OXD-018 | Player state machine | 1 | L | OXD-012, OXD-013 | ✅ Done | +| OXD-020 | CLI Framework (clap) | 2 | M | OXD-001 | ✅ Done | +| OXD-021 | Event loop + deadline clock | 2 | L | OXD-018 | ✅ Done | +| OXD-022 | Terminal capability detection | 2 | M | OXD-020 | ✅ Done | +| OXD-023 | RSVP Widget (anchor column) | 2 | L | OXD-021, OXD-022 | ✅ Done | +| OXD-024 | Status bar & progress bar | 2 | S | OXD-023 | ✅ Done | +| OXD-025 | Help popup `?` | 2 | S | OXD-023 | ✅ Done | +| OXD-026 | `--dump` mode | 2 | S | OXD-017, OXD-020 | ✅ Done | +| OXD-027 | Milestone v0.1 | 2 | S | OXD-023…026 | ✅ Done | | OXD-030 | Modal keymap system | 3 | M | OXD-021 | ⬜ Todo | | OXD-031 | Full-Text Panel + highlight | 3 | L | OXD-030 | ⬜ Todo | | OXD-032 | Outline / TOC Sidebar | 3 | M | OXD-031 | ⬜ Todo | diff --git a/README.md b/README.md index a643544..1d6776b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,109 @@ # oxidiris -A Rust TUI that leverages RSVP to stream docs to your focus point—zero eye movement required." +A Rust TUI that leverages RSVP to stream docs to your focus point — zero eye movement required. +``` +┌ BACKLOG.md ──────────────────────────────────────────────────────────────────┐ +│ │ +│ ▼ │ +│ Rules │ +│ ▲ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│speed: 400 WPM (eff. 295) word: 67/5494 (1%)│ +│[Space] play [K / Up] speed [H / Left] seek [?] help [q] quit ░░░░░░░│ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +The highlighted character is the **Optimal Recognition Point**, and it is drawn at the same +terminal column for every word in the document. That is the whole idea: your eye fixates once and +the text moves instead. + +## Status + +**v0.1** — reads `.md` and `.txt`. See [`BACKLOG.md`](BACKLOG.md) for what is built and what is next. + +## What it is not + +RSVP removes *regressions*, the small backward eye movements that make up 10-15% of natural reading +and are how the brain repairs a misparse. Removing them buys speed and costs comprehension, +especially on dense material. + +So Oxidiris is a **skim and triage** tool: work out whether a paper is worth your afternoon, get +through a changelog, re-read something you already know. It is not a replacement for reading a hard +paper carefully, and it does not advertise a WPM number as an achievement. + +## Install + +```sh +cargo install --path crates/oxidiris +``` + +Requires Rust 1.85 or newer. + +## Use + +```sh +oxidiris README.md # read at the default 300 WPM +oxidiris paper.md -w 450 # start faster +oxidiris notes.txt --pacing linear # even timing, no punctuation pauses +oxidiris BACKLOG.md --dump # clean plain text on stdout +oxidiris BACKLOG.md | less # piping produces text, not a broken TUI +``` + +### Keys + +| Key | Action | +|-------------------|---------------------------| +| `Space` | Play / pause | +| `K` `↑` / `J` `↓` | Faster / slower (25 WPM) | +| `+` / `-` | Faster / slower (5 WPM) | +| `H` `←` / `L` `→` | Back / forward 5 words | +| `[` / `]` | Previous / next paragraph | +| `g` / `G` | Start / end | +| `R` | Restart | +| `?` | Key reference | +| `q` | Quit | + +## Accessibility + +Oxidiris is assistive technology, so these are requirements rather than nice-to-haves: + +- **The anchor never relies on color alone.** It is bold, underlined, and framed by `▼ ▲`, so it + stays identifiable for color-blind readers and on monochrome terminals (WCAG SC 1.4.1). +- **The default speed is 300 WPM,** not a marketing number. Rapidly changing text is a + photosensitivity risk, and the reader warns once past 700 WPM (WCAG SC 2.3.1). +- **`NO_COLOR` is honored,** and color degrades from truecolor through 256 to 16 to none. +- **Everything is keyboard-driven.** There is no mouse-only action. +- **`--dump` is the screen-reader route.** A self-rewriting frame and a screen reader cannot + cooperate, so there is a plain-text path out. + +## Privacy + +Oxidiris reads your documents and sends nothing anywhere. No telemetry, no network access. Nothing +is written outside the file you opened. + +## Development + +```sh +make # list targets +make check # fmt, clippy, tests, wasm constraint — the gate CI runs +make frame # print one rendered frame without opening a terminal session +make demo # read this project's own backlog +``` + +The workspace is two crates: + +- **`oxidiris-core`** — the engine: Unicode segmentation, ORP, pacing, parsers, playback state. + It has no terminal dependency and builds for `wasm32-unknown-unknown`; CI enforces this so the + engine stays reusable for the web build and editor plugins. +- **`oxidiris`** — the terminal application. + +Design notes live in [`docs/informations/proposals.md`](docs/informations/proposals.md); decisions +that departed from it are in [`docs/decisions/token-timing.md`](docs/decisions/token-timing.md). + +## Licence + +GPL-3.0-or-later. See [DEC-01](BACKLOG.md#d-decisions-requiring-human-input) for an open question +about dual-licensing the engine crate. diff --git a/crates/oxidiris-core/src/encoding.rs b/crates/oxidiris-core/src/encoding.rs index 7324b9f..f9b5eb9 100644 --- a/crates/oxidiris-core/src/encoding.rs +++ b/crates/oxidiris-core/src/encoding.rs @@ -12,7 +12,9 @@ use thiserror::Error; #[derive(Debug, Error, PartialEq, Eq)] pub enum DecodeError { /// The input looks like a binary file rather than a document. - #[error("input appears to be binary (found {nul_bytes} NUL bytes in the first {scanned} bytes)")] + #[error( + "input appears to be binary (found {nul_bytes} NUL bytes in the first {scanned} bytes)" + )] Binary { /// How many NUL bytes were seen. nul_bytes: usize, @@ -148,4 +150,4 @@ mod tests { let (text, _) = decode(b"").unwrap(); assert!(text.is_empty()); } -} \ No newline at end of file +} diff --git a/crates/oxidiris-core/src/lib.rs b/crates/oxidiris-core/src/lib.rs index 77b5af2..b1c0639 100644 --- a/crates/oxidiris-core/src/lib.rs +++ b/crates/oxidiris-core/src/lib.rs @@ -82,7 +82,7 @@ mod tests { #[test] fn nfc_and_nfd_documents_are_byte_identical() { let nfc = "Ti\u{1EBF}ng Vi\u{1EC7}t r\u{1EA5}t hay.\n"; - let nfd = "Tie\u{302}\u{301}ng Vie\u{323}\u{302}t ra\u{301}\u{302}t hay.\n"; + let nfd = "Tie\u{302}\u{301}ng Vie\u{323}\u{302}t ra\u{302}\u{301}t hay.\n"; let a = load(nfc.as_bytes(), Some(parser::Format::PlainText)).unwrap(); let b = load(nfd.as_bytes(), Some(parser::Format::PlainText)).unwrap(); assert_eq!(a.source, b.source); @@ -102,4 +102,4 @@ mod tests { assert!(doc.source.get(t.byte_span.clone()).is_some(), "invalid span for {:?}", t.text); } } -} \ No newline at end of file +} diff --git a/crates/oxidiris-core/src/orp.rs b/crates/oxidiris-core/src/orp.rs index 6c2c91a..e741400 100644 --- a/crates/oxidiris-core/src/orp.rs +++ b/crates/oxidiris-core/src/orp.rs @@ -190,4 +190,4 @@ mod properties { prop_assert_eq!(l.total_width, display_width(&word)); } } -} \ No newline at end of file +} diff --git a/crates/oxidiris-core/src/pacing.rs b/crates/oxidiris-core/src/pacing.rs index fbf68db..fc87d28 100644 --- a/crates/oxidiris-core/src/pacing.rs +++ b/crates/oxidiris-core/src/pacing.rs @@ -69,7 +69,7 @@ pub fn tokenize(doc: &Document, mode: PacingMode) -> Vec { } let first_of_block = tokens.len(); for fragment in &block.fragments { - push_fragment(&mut tokens, block, fragment, mode); + push_fragment(&mut tokens, &doc.source, block, fragment); } if tokens.len() == first_of_block { continue; // Block contributed nothing readable. @@ -96,19 +96,44 @@ pub fn tokenize(doc: &Document, mode: PacingMode) -> Vec { tokens } +/// Byte range for one word, guaranteed to be a valid slice of `source`. +/// +/// Falls back to the whole fragment when the fragment's text is not a verbatim copy of its source +/// range, which happens for Markdown escapes and character entities. A slightly coarse highlight +/// is a fair price for never handing Review Mode a range that would panic on slicing. +fn word_span( + source: &str, + fragment: &Fragment, + word: &segment::Word<'_>, + exact: bool, +) -> core::ops::Range { + if exact { + let start = fragment.byte_span.start + word.start; + let end = fragment.byte_span.start + word.end; + if end <= source.len() && source.is_char_boundary(start) && source.is_char_boundary(end) { + return start..end; + } + } + let fb = fragment.byte_span.clone(); + if fb.end <= source.len() + && source.is_char_boundary(fb.start) + && source.is_char_boundary(fb.end) + { + return fb; + } + 0..0 +} + /// Split one fragment into tokens and append them. -fn push_fragment(tokens: &mut Vec, block: &Block, fragment: &Fragment, _mode: PacingMode) { +fn push_fragment(tokens: &mut Vec, source: &str, block: &Block, fragment: &Fragment) { + let exact = source.get(fragment.byte_span.clone()) == Some(fragment.text.as_str()); for word in segment::split_words(&fragment.text) { let graphemes = segment::grapheme_count(word.text); if graphemes == 0 { continue; } let layout = orp::layout(word.text); - // Clamp into the fragment: Markdown escapes and entities make a fragment's text shorter - // than the source range it came from, so a naive offset could point past the fragment. - // Review Mode slices `Document::source` with these spans, so they must always be valid. - let start = (fragment.byte_span.start + word.start).min(fragment.byte_span.end); - let end = (fragment.byte_span.start + word.end).min(fragment.byte_span.end); + let byte_span = word_span(source, fragment, &word, exact); tokens.push(Token { text: word.text.to_string(), @@ -119,7 +144,7 @@ fn push_fragment(tokens: &mut Vec, block: &Block, fragment: &Fragment, _m pause_ms: 0, kind: fragment.kind, block_id: block.id, - byte_span: start..end, + byte_span, }); } } @@ -198,7 +223,10 @@ mod tests { #[test] fn linear_mode_gives_every_token_the_same_duration() { - let tokens = tokens_of("# Title\n\nA short one and a considerably longer one.\n", PacingMode::Linear); + let tokens = tokens_of( + "# Title\n\nA short one and a considerably longer one.\n", + PacingMode::Linear, + ); assert!(tokens.len() > 3); let first = tokens[0].duration_ms(300); assert!(tokens.iter().all(|t| t.duration_ms(300) == first)); @@ -206,7 +234,7 @@ mod tests { #[test] fn sentence_end_costs_about_two_and_a_quarter_times_a_plain_word() { - let tokens = tokens_of("alpha bravo. charlie delta\n", PacingMode::Natural); + let tokens = tokens_of("alpha bravo. Charlie delta\n", PacingMode::Natural); let plain = tokens.iter().find(|t| t.text == "alpha").unwrap(); let stop = tokens.iter().find(|t| t.text == "bravo.").unwrap(); // Same length bucket, so the ratio isolates the punctuation factor. @@ -245,4 +273,4 @@ mod tests { let last_before = tokens.iter().find(|t| t.text == "here").unwrap(); assert!(last_before.pause_ms >= 400, "pause was {}", last_before.pause_ms); } -} \ No newline at end of file +} diff --git a/crates/oxidiris-core/src/parser/markdown.rs b/crates/oxidiris-core/src/parser/markdown.rs index e30b3ad..67df81d 100644 --- a/crates/oxidiris-core/src/parser/markdown.rs +++ b/crates/oxidiris-core/src/parser/markdown.rs @@ -17,8 +17,8 @@ struct OpenBlock { fragments: Vec, } -#[derive(Default)] -struct Builder { +struct Builder<'a> { + source: &'a str, blocks: Vec, headings: Vec, open: Option, @@ -28,7 +28,7 @@ struct Builder { link_depth: usize, } -impl Builder { +impl<'a> Builder<'a> { fn open_block(&mut self, kind: BlockKind, start: usize) { self.close_block(); self.open = Some(OpenBlock { kind, start, end: start, fragments: Vec::new() }); @@ -73,7 +73,24 @@ impl Builder { } let Some(open) = self.open.as_mut() else { return }; open.end = open.end.max(span.end); - open.fragments.push(Fragment { text: text.to_string(), byte_span: span, kind }); + let byte_span = tighten(self.source, span, text); + open.fragments.push(Fragment { text: text.to_string(), byte_span, kind }); + } +} + +/// Narrow an event's source range down to the exact bytes of its text. +/// +/// `pulldown-cmark` reports the range of the whole construct, so an inline code span carries the +/// surrounding backticks and a link carries its target. Leaving that slack in place would shift +/// every per-word offset derived from the fragment, and a shifted offset can land mid-UTF-8. +fn tighten(source: &str, span: core::ops::Range, text: &str) -> core::ops::Range { + let Some(slice) = source.get(span.clone()) else { return span }; + if slice == text { + return span; + } + match slice.find(text) { + Some(pos) => span.start + pos..span.start + pos + text.len(), + None => span, } } @@ -102,7 +119,16 @@ pub fn parse(text: &str) -> Document { options.insert(Options::ENABLE_FOOTNOTES); options.insert(Options::ENABLE_TASKLISTS); - let mut b = Builder::default(); + let mut b = Builder { + source: text, + blocks: Vec::new(), + headings: Vec::new(), + open: None, + quote_depth: 0, + in_code_block: false, + in_table: false, + link_depth: 0, + }; for (event, span) in Parser::new_ext(text, options).into_offset_iter() { match event { @@ -139,8 +165,7 @@ pub fn parse(text: &str) -> Document { // Inside a list item or table cell the surrounding block already exists; a loose list // would otherwise split each item into a separate paragraph block. Event::Start(Tag::Paragraph) if b.open.is_none() => { - let kind = - if b.quote_depth > 0 { BlockKind::Quote } else { BlockKind::Paragraph }; + let kind = if b.quote_depth > 0 { BlockKind::Quote } else { BlockKind::Paragraph }; b.open_block(kind, span.start); } Event::End(TagEnd::Paragraph) => { @@ -310,4 +335,4 @@ fn main() { println!(\"hi\"); } assert!(doc.headings.is_empty()); assert!(doc.meta.title.is_none()); } -} \ No newline at end of file +} diff --git a/crates/oxidiris-core/src/parser/mod.rs b/crates/oxidiris-core/src/parser/mod.rs index 49a1b1e..a6d734b 100644 --- a/crates/oxidiris-core/src/parser/mod.rs +++ b/crates/oxidiris-core/src/parser/mod.rs @@ -80,4 +80,4 @@ mod tests { assert_eq!(Format::sniff(md), Format::Markdown); assert_eq!(Format::sniff("Just a plain sentence.\n\nAnother one.\n"), Format::PlainText); } -} \ No newline at end of file +} diff --git a/crates/oxidiris-core/src/parser/plaintext.rs b/crates/oxidiris-core/src/parser/plaintext.rs index 90ac69a..92bf8d6 100644 --- a/crates/oxidiris-core/src/parser/plaintext.rs +++ b/crates/oxidiris-core/src/parser/plaintext.rs @@ -132,4 +132,4 @@ mod tests { assert_eq!(f.text, "indented line"); assert_eq!(&doc.source[f.byte_span.clone()], "indented line"); } -} \ No newline at end of file +} diff --git a/crates/oxidiris-core/src/player.rs b/crates/oxidiris-core/src/player.rs index 2fc1b4e..b469ad4 100644 --- a/crates/oxidiris-core/src/player.rs +++ b/crates/oxidiris-core/src/player.rs @@ -92,12 +92,15 @@ impl Player { (self.cursor.min(self.tokens.len().saturating_sub(1)) + 1, self.tokens.len()) } - /// Fraction of the document already consumed, in `0.0..=1.0`. + /// Position within the document, in `0.0..=1.0`. + /// + /// Defined as the exact inverse of [`Player::seek_ratio`]: 0.0 on the first token and 1.0 on + /// the last, so jumping to a percentage and reading the percentage back agree. pub fn progress_ratio(&self) -> f64 { - if self.tokens.is_empty() { - return 1.0; + match self.tokens.len() { + 0 | 1 => 0.0, + n => self.cursor as f64 / (n - 1) as f64, } - self.cursor as f64 / self.tokens.len() as f64 } /// Start advancing. Restarts from the top if the stream had finished. @@ -312,6 +315,19 @@ mod tests { assert_eq!(p.progress().0, 1); } + #[test] + fn progress_ratio_is_the_inverse_of_seek_ratio() { + let mut p = player_from(DOC); + p.goto_start(); + assert!((p.progress_ratio() - 0.0).abs() < 1e-9); + p.goto_end(); + assert!((p.progress_ratio() - 1.0).abs() < 1e-9); + for target in [0.25, 0.5, 0.75] { + p.seek_ratio(target); + assert!((p.progress_ratio() - target).abs() < 0.05, "round trip failed at {target}"); + } + } + #[test] fn seek_ratio_lands_where_expected() { let mut p = player_from(DOC); @@ -382,4 +398,4 @@ mod tests { } assert!(virtual_elapsed_ms > 0); } -} \ No newline at end of file +} diff --git a/crates/oxidiris-core/src/segment.rs b/crates/oxidiris-core/src/segment.rs index 34a5e46..f60ce2c 100644 --- a/crates/oxidiris-core/src/segment.rs +++ b/crates/oxidiris-core/src/segment.rs @@ -61,6 +61,14 @@ pub fn grapheme_count(text: &str) -> usize { text.graphemes(true).count() } +/// Grapheme clusters of `text`, in order. +/// +/// Exposed so that consumers (including the renderer) never have to reach for a Unicode crate of +/// their own and risk disagreeing with the engine about where a character begins. +pub fn graphemes(text: &str) -> Vec<&str> { + text.graphemes(true).collect() +} + /// Terminal column width of `text` per UAX #11. /// /// This is what the renderer must use for alignment: `"日本語"` is 3 graphemes but 6 columns. @@ -160,9 +168,17 @@ mod tests { } } + #[test] + fn graphemes_agree_with_the_grapheme_count() { + for text in ["", "hello", "日本語", "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"] { + assert_eq!(graphemes(text).len(), grapheme_count(text)); + assert_eq!(graphemes(text).concat(), text); + } + } + #[test] fn punctuation_stays_attached_to_its_word() { let words: Vec<_> = split_words("Hello, world.").iter().map(|w| w.text).collect(); assert_eq!(words, vec!["Hello,", "world."]); } -} \ No newline at end of file +} diff --git a/crates/oxidiris-core/src/sentence.rs b/crates/oxidiris-core/src/sentence.rs index ad648eb..7cfd2bd 100644 --- a/crates/oxidiris-core/src/sentence.rs +++ b/crates/oxidiris-core/src/sentence.rs @@ -154,4 +154,4 @@ mod tests { assert_eq!(classify("", None), Break::None); assert_eq!(classify(")", None), Break::None); } -} \ No newline at end of file +} diff --git a/crates/oxidiris-core/src/token.rs b/crates/oxidiris-core/src/token.rs index 535128f..c55c3ac 100644 --- a/crates/oxidiris-core/src/token.rs +++ b/crates/oxidiris-core/src/token.rs @@ -257,4 +257,4 @@ mod tests { assert!(!BlockKind::Table.is_readable()); assert!(BlockKind::Paragraph.is_readable()); } -} \ No newline at end of file +} diff --git a/crates/oxidiris/src/main.rs b/crates/oxidiris/src/main.rs index e7a11a9..58e15ce 100644 --- a/crates/oxidiris/src/main.rs +++ b/crates/oxidiris/src/main.rs @@ -1,3 +1,144 @@ -fn main() { - println!("Hello, world!"); +//! Oxidiris: read documents at speed without moving your eyes. + +#![forbid(unsafe_code)] + +mod app; +mod cli; +mod dump; +mod event; +mod keymap; +mod scheduler; +mod term; +mod ui; + +use std::io::{IsTerminal, Read}; +use std::path::Path; +use std::process::ExitCode; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use oxidiris_core::parser::Format; + +use crate::app::App; +use crate::cli::Cli; + +fn main() -> ExitCode { + let cli = Cli::parse(); + match run(cli) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("oxidiris: {err:#}"); + ExitCode::FAILURE + } + } +} + +fn run(cli: Cli) -> Result<()> { + let (bytes, title, format) = read_input(&cli)?; + + let doc = oxidiris_core::load(&bytes, format) + .with_context(|| format!("could not read {title} as text"))?; + + if doc.blocks.is_empty() { + bail!("{title} contains no readable text"); + } + + // Plain text when asked for it, and whenever stdout is not a terminal: piping into `less` + // should produce text, not a fight over the alternate screen. + let piped = !std::io::stdout().is_terminal(); + if cli.dump || piped || cli.reads_stdin() { + return dump::write_plain(&mut std::io::stdout().lock(), &doc); + } + + let message = build_notice(&cli, &doc); + let mut application = App::new(&doc, title, cli.wpm, cli.pacing.into()) + .with_detected_capabilities() + .with_message(message); + + install_panic_hook(); + let mut terminal = ratatui::init(); + let result = event::run(&mut terminal, &mut application); + ratatui::restore(); + result +} + +/// Read the document bytes plus a display title and a format hint. +fn read_input(cli: &Cli) -> Result<(Vec, String, Option)> { + if cli.reads_stdin() { + let mut buf = Vec::new(); + std::io::stdin().lock().read_to_end(&mut buf).context("reading standard input")?; + if buf.is_empty() { + bail!("standard input was empty"); + } + return Ok((buf, "stdin".to_string(), None)); + } + + let path = &cli.file; + let bytes = std::fs::read(path).with_context(|| format!("cannot open {}", path.display()))?; + let title = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + let format = Some(Format::from_extension(path.extension().and_then(|e| e.to_str()))); + Ok((bytes, title, format)) +} + +/// Compose the note shown in the status bar on the first frame. +/// +/// Flags that parse but do nothing are called out rather than silently ignored: a reader who +/// typed `--chunk 3` deserves to know it had no effect. +fn build_notice(cli: &Cli, doc: &oxidiris_core::Document) -> Option { + let pending = cli.unimplemented_flags(); + if !pending.is_empty() { + return Some(format!("not implemented yet: {}", pending.join(", "))); + } + let headings = doc.headings.len(); + if headings > 0 { + Some(format!("{headings} headings · [Space] to start · [?] for keys")) + } else { + Some("[Space] to start · [?] for keys".to_string()) + } +} + +/// Restore the terminal before a panic prints, so the backtrace is readable. +fn install_panic_hook() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + ratatui::restore(); + previous(info); + })); +} + +/// Helper kept for future path-based format overrides. +#[allow(dead_code)] +fn format_of(path: &Path) -> Format { + Format::from_extension(path.extension().and_then(|e| e.to_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn markdown_files_are_recognised_by_extension() { + assert_eq!(format_of(Path::new("a.md")), Format::Markdown); + assert_eq!(format_of(Path::new("a.txt")), Format::PlainText); + assert_eq!(format_of(Path::new("README")), Format::PlainText); + } + + #[test] + fn the_notice_names_unimplemented_flags() { + let cli = Cli::try_parse_from(["oxidiris", "a.md", "--chunk", "2"]).unwrap(); + let doc = oxidiris_core::parser::parse_markdown("# T\n\nbody\n"); + let notice = build_notice(&cli, &doc).unwrap(); + assert!(notice.contains("--chunk"), "got {notice:?}"); + } + + #[test] + fn the_notice_falls_back_to_a_hint() { + let cli = Cli::try_parse_from(["oxidiris", "a.md"]).unwrap(); + let doc = oxidiris_core::parser::parse_markdown("# T\n\nbody\n"); + let notice = build_notice(&cli, &doc).unwrap(); + assert!(notice.contains("[?]")); + } } diff --git a/docs/informations/proposals.md b/docs/informations/proposals.md index da37c21..7ea97a8 100644 --- a/docs/informations/proposals.md +++ b/docs/informations/proposals.md @@ -392,8 +392,8 @@ File / stdin / URL [ Segmenter ] ← unicode-segmentation + normalization │ ▼ -[ Vec ] { text, orp_index, display_width, - │ duration_ms, kind, block_id, byte_span } +[ Vec ] { text, orp_index, display_width, orp_offset, + │ weight, pause_ms, kind, block_id, byte_span } ▼ [ Player (state machine) ] con trỏ + đồng hồ + WPM │ @@ -404,6 +404,16 @@ File / stdin / URL `Token` giữ `byte_span` trỏ ngược về văn bản gốc — đây là thứ cho phép Bảng Toàn Văn highlight đúng từ đang đọc và cho phép Review Mode tái dựng nguyên đoạn. +> **Đính chính (ADR 001).** Bản đề xuất trước ghi `Token` có field `duration_ms`. Khi hiện thực +> OXD-010/OXD-018 mới thấy đây là thiết kế sai: WPM thay đổi *trong lúc đọc*, nên duration cố định +> buộc phải re-pace toàn bộ tài liệu mỗi lần bấm phím. Token giờ giữ `weight` (hệ số không phụ +> thuộc WPM) và `pause_ms` (khoảng nghỉ cấu trúc, không co giãn theo tốc độ); duration được suy ra +> lúc hiển thị qua `Token::duration_ms(wpm)`. Chi tiết và các phương án đã loại: +> [`docs/decisions/token-timing.md`](../decisions/token-timing.md). +> +> `Token` cũng có thêm `orp_offset` (độ rộng cột của phần trước ORP) để renderer không phải tính +> lại mỗi frame. + ### 4.2. Vòng lặp sự kiện và đồng hồ **Vấn đề:** cách hiện thực ngây thơ `sleep(60_000 / wpm)` sẽ tích lũy sai số — mỗi lần lặp cộng From 36ab6049d88411a78ea7334c0cb9860f97321477 Mon Sep 17 00:00:00 2001 From: Aleksandr Slokov Date: Tue, 1 Sep 2026 18:24:00 +0700 Subject: [PATCH 3/3] Implement v0.1 RSVP reader and CI gate Adds the oxidiris workspace, core engine, and terminal app for RSVP reading with Unicode-safe tokenization, ORP rendering, pacing, and markdown/plaintext parsing. This also introduces the corpus suite, CLI/TUI shell, scheduler and keymap, dump/help/status UI, and developer automation (Makefile, CI, rustfmt, deny config). The patch verifies the core engine stays terminal-free for wasm, updates docs/backlog for the v0.1 milestone, and locks in regression coverage for pacing, Unicode normalization, and accessibility behavior. --- .github/workflows/ci.yml | 77 ++++++ Makefile | 117 +++++++++ crates/oxidiris-core/tests/corpus.rs | 208 ++++++++++++++++ crates/oxidiris/src/app.rs | 226 +++++++++++++++++ crates/oxidiris/src/cli.rs | 206 ++++++++++++++++ crates/oxidiris/src/dump.rs | 70 ++++++ crates/oxidiris/src/event.rs | 145 +++++++++++ crates/oxidiris/src/keymap.rs | 282 +++++++++++++++++++++ crates/oxidiris/src/scheduler.rs | 143 +++++++++++ crates/oxidiris/src/term.rs | 185 ++++++++++++++ crates/oxidiris/src/ui/help.rs | 136 +++++++++++ crates/oxidiris/src/ui/mod.rs | 143 +++++++++++ crates/oxidiris/src/ui/rsvp.rs | 350 +++++++++++++++++++++++++++ crates/oxidiris/src/ui/status.rs | 228 +++++++++++++++++ deny.toml | 34 +++ docs/decisions/token-timing.md | 66 +++++ rustfmt.toml | 3 + testdata/README.md | 35 +++ testdata/abbreviations.txt | 5 + testdata/cjk.txt | 2 + testdata/emoji_zwj.txt | 1 + testdata/empty.txt | 0 testdata/latin1.txt | 1 + testdata/long_words.txt | 4 + testdata/mixed_width.txt | 1 + testdata/rfc_style.txt | 5 + testdata/sample.md | 28 +++ testdata/simple.txt | 4 + testdata/utf16_bom.txt | Bin 0 -> 36 bytes testdata/vietnamese_nfc.txt | 2 + testdata/vietnamese_nfd.txt | 2 + 31 files changed, 2709 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 Makefile create mode 100644 crates/oxidiris-core/tests/corpus.rs create mode 100644 crates/oxidiris/src/app.rs create mode 100644 crates/oxidiris/src/cli.rs create mode 100644 crates/oxidiris/src/dump.rs create mode 100644 crates/oxidiris/src/event.rs create mode 100644 crates/oxidiris/src/keymap.rs create mode 100644 crates/oxidiris/src/scheduler.rs create mode 100644 crates/oxidiris/src/term.rs create mode 100644 crates/oxidiris/src/ui/help.rs create mode 100644 crates/oxidiris/src/ui/mod.rs create mode 100644 crates/oxidiris/src/ui/rsvp.rs create mode 100644 crates/oxidiris/src/ui/status.rs create mode 100644 deny.toml create mode 100644 docs/decisions/token-timing.md create mode 100644 rustfmt.toml create mode 100644 testdata/README.md create mode 100644 testdata/abbreviations.txt create mode 100644 testdata/cjk.txt create mode 100644 testdata/emoji_zwj.txt create mode 100644 testdata/empty.txt create mode 100644 testdata/latin1.txt create mode 100644 testdata/long_words.txt create mode 100644 testdata/mixed_width.txt create mode 100644 testdata/rfc_style.txt create mode 100644 testdata/sample.md create mode 100644 testdata/simple.txt create mode 100644 testdata/utf16_bom.txt create mode 100644 testdata/vietnamese_nfc.txt create mode 100644 testdata/vietnamese_nfd.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a761c0c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +jobs: + fmt: + name: Formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all --check + + lint: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace --all-targets -- -D warnings + + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Windows is not optional: crossterm reports key *release* events there that other + # platforms do not, and the dispatcher has to filter them (see keymap::resolve). + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace + + wasm: + name: Architecture constraint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + # This job is the enforcement mechanism for spec 1.1: oxidiris-core must never gain a + # terminal dependency. Do not remove it, and do not "fix" a failure by adding a cfg gate — + # a failure here means terminal code leaked into the reusable engine. + - run: cargo build -p oxidiris-core --target wasm32-unknown-unknown + + msrv: + name: MSRV + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.85.0 + - uses: Swatinem/rust-cache@v2 + - run: cargo build --workspace + + deny: + name: Licences and advisories + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bc70fe7 --- /dev/null +++ b/Makefile @@ -0,0 +1,117 @@ +# Oxidiris developer commands. +# +# Run `make` with no target for the list. Every target here is also what CI runs, so a green +# `make check` locally means a green pipeline. + +FILE ?= BACKLOG.md +WPM ?= 300 +CORE := oxidiris-core +BIN := oxidiris + +.DEFAULT_GOAL := help +.PHONY: help build release run demo dump frame test test-core test-tui fmt fmt-check lint \ + check wasm doc doc-open bench audit clean install tree ci + +## help: list the available targets +help: + @echo "Oxidiris — make targets" + @echo + @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /' | column -t -s ':' + @echo + @echo "Variables: FILE= (default $(FILE)) WPM= (default $(WPM))" + +# --- build ------------------------------------------------------------------ + +## build: compile the whole workspace in debug mode +build: + cargo build --workspace + +## release: compile an optimised binary +release: + cargo build --workspace --release + +## install: install the oxidiris binary into ~/.cargo/bin +install: + cargo install --path crates/$(BIN) --locked + +# --- run -------------------------------------------------------------------- + +## run: open the reader on FILE at WPM +run: + cargo run -q -p $(BIN) -- $(FILE) --wpm $(WPM) + +## demo: open the reader on this project's own backlog +demo: + cargo run -q -p $(BIN) -- BACKLOG.md --wpm 400 + +## dump: print FILE as clean plain text (parser debugging, and the screen-reader path) +dump: + cargo run -q -p $(BIN) -- $(FILE) --dump + +# --- quality ---------------------------------------------------------------- + +## test: run every test in the workspace +test: + cargo test --workspace + +## test-core: run only the engine tests +test-core: + cargo test -p $(CORE) + +## test-tui: run only the terminal tests +test-tui: + cargo test -p $(BIN) + +## fmt: format the workspace +fmt: + cargo fmt --all + +## fmt-check: verify formatting without changing files +fmt-check: + cargo fmt --all --check + +## lint: clippy with warnings treated as errors +lint: + cargo clippy --workspace --all-targets -- -D warnings + +## wasm: verify oxidiris-core stays free of terminal dependencies (spec 1.1) +wasm: + cargo build -p $(CORE) --target wasm32-unknown-unknown + +## check: the Definition of Done gate — fmt, lint, test, wasm +check: fmt-check lint test wasm + @echo "OK — Definition of Done satisfied" + +## ci: everything check does, plus docs +ci: check doc + @echo "OK — full pipeline" + +# --- inspection ------------------------------------------------------------- + +## doc: build the API documentation +doc: + cargo doc -p $(CORE) --no-deps + +## doc-open: build the API documentation and open it +doc-open: + cargo doc -p $(CORE) --no-deps --open + +## frame: print one rendered frame to stdout (visual check, no terminal needed) +frame: + @cargo test -q -p $(BIN) --bin $(BIN) -- --ignored --nocapture render_frame_of_the_backlog + +## bench: run benchmarks (arrives with OXD-072) +bench: + @echo "no benchmarks yet — see OXD-072 in BACKLOG.md" + +## audit: check dependencies for advisories and licence problems +audit: + cargo deny check 2>/dev/null || echo "cargo-deny not installed: cargo install cargo-deny" + +## tree: show what oxidiris-core depends on, to catch accidental terminal deps +tree: + cargo tree -p $(CORE) + +## clean: remove build artefacts +clean: + cargo clean \ No newline at end of file diff --git a/crates/oxidiris-core/tests/corpus.rs b/crates/oxidiris-core/tests/corpus.rs new file mode 100644 index 0000000..c32da0e --- /dev/null +++ b/crates/oxidiris-core/tests/corpus.rs @@ -0,0 +1,208 @@ +//! Corpus-driven integration tests. +//! +//! Implements the verification half of OXD-004. Every fixture in `testdata/` is exercised here, +//! against the real public API rather than internal helpers. + +use std::path::{Path, PathBuf}; + +use oxidiris_core::pacing::{self, PacingMode}; +use oxidiris_core::parser::Format; +use oxidiris_core::player::Player; +use oxidiris_core::segment; + +fn testdata(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../testdata").join(name) +} + +fn bytes(name: &str) -> Vec { + std::fs::read(testdata(name)).unwrap_or_else(|e| panic!("cannot read {name}: {e}")) +} + +fn tokens(name: &str, format: Option) -> Vec { + let doc = oxidiris_core::load(&bytes(name), format).unwrap(); + pacing::tokenize(&doc, PacingMode::Natural) +} + +/// Every fixture must load without error and produce spans that are valid slices of the source. +#[test] +fn every_fixture_loads_and_produces_valid_spans() { + let fixtures = [ + "simple.txt", + "vietnamese_nfc.txt", + "vietnamese_nfd.txt", + "cjk.txt", + "emoji_zwj.txt", + "mixed_width.txt", + "abbreviations.txt", + "long_words.txt", + "rfc_style.txt", + "sample.md", + "utf16_bom.txt", + "latin1.txt", + "empty.txt", + ]; + + for name in fixtures { + let doc = oxidiris_core::load(&bytes(name), None) + .unwrap_or_else(|e| panic!("{name} failed to load: {e}")); + let tokens = pacing::tokenize(&doc, PacingMode::Natural); + for t in &tokens { + assert!( + doc.source.get(t.byte_span.clone()).is_some(), + "{name}: token {:?} has an invalid span {:?}", + t.text, + t.byte_span + ); + } + } +} + +/// The headline Unicode guarantee: normalization form must be invisible downstream. +#[test] +fn nfc_and_nfd_fixtures_produce_identical_token_streams() { + let nfc_bytes = bytes("vietnamese_nfc.txt"); + let nfd_bytes = bytes("vietnamese_nfd.txt"); + assert_ne!(nfc_bytes, nfd_bytes, "fixtures are identical, so this test proves nothing"); + + let nfc = tokens("vietnamese_nfc.txt", Some(Format::PlainText)); + let nfd = tokens("vietnamese_nfd.txt", Some(Format::PlainText)); + + assert!(!nfc.is_empty()); + assert_eq!(nfc.len(), nfd.len(), "different token counts for the same text"); + for (a, b) in nfc.iter().zip(nfd.iter()) { + assert_eq!(a.text, b.text); + assert_eq!(a.orp_index, b.orp_index); + assert_eq!(a.orp_offset, b.orp_offset); + assert_eq!(a.display_width, b.display_width); + } +} + +/// Column width, not character count, is what the renderer will align against. +#[test] +fn cjk_tokens_report_double_width() { + for t in tokens("cjk.txt", Some(Format::PlainText)) { + let graphemes = segment::grapheme_count(&t.text); + assert!( + t.display_width >= graphemes as u16, + "{:?}: width {} should be at least the grapheme count {graphemes}", + t.text, + t.display_width + ); + assert!(t.orp_offset <= t.display_width); + } +} + +#[test] +fn emoji_sequences_stay_whole() { + let tokens = tokens("emoji_zwj.txt", Some(Format::PlainText)); + let family = + tokens.iter().find(|t| t.text.contains('\u{1F468}')).expect("family emoji missing"); + assert_eq!(segment::grapheme_count(&family.text), 1, "ZWJ sequence was split"); + + let flag = tokens.iter().find(|t| t.text.contains('\u{1F1FB}')).expect("flag emoji missing"); + assert_eq!(segment::grapheme_count(&flag.text), 1, "flag was split"); +} + +/// The OXD-014 acceptance case, run against real prose. +#[test] +fn abbreviations_do_not_create_sentence_pauses() { + let tokens = tokens("abbreviations.txt", Some(Format::PlainText)); + let plain_weight = 1.0f32; + + for suspect in ["Fig.", "al.", "i.e.", "e.g.", "vs.", "No.", "approx.", "Dr."] { + let Some(t) = tokens.iter().find(|t| t.text == suspect) else { continue }; + assert!( + t.weight < plain_weight * 2.0, + "{suspect:?} was treated as a sentence end (weight {})", + t.weight + ); + } + + // The genuine full stop must still register. + let real_stop = tokens.iter().find(|t| t.text == "this.").expect("expected a real full stop"); + assert!( + real_stop.weight >= 2.0, + "a real full stop lost its pause (weight {})", + real_stop.weight + ); +} + +/// The OXD-016 acceptance case: hard-wrapped input must not become one block per line. +#[test] +fn hard_wrapped_text_rejoins_into_paragraphs() { + let doc = oxidiris_core::load(&bytes("rfc_style.txt"), Some(Format::PlainText)).unwrap(); + assert_eq!(doc.blocks.len(), 2, "expected 2 paragraphs, got {}", doc.blocks.len()); +} + +/// The OXD-017 acceptance case: no URL may reach the reader. +#[test] +fn markdown_keeps_link_labels_and_drops_targets() { + let tokens = tokens("sample.md", Some(Format::Markdown)); + for t in &tokens { + assert!(!t.text.contains("://"), "URL leaked into the stream: {:?}", t.text); + assert!(!t.text.starts_with("www."), "URL leaked into the stream: {:?}", t.text); + } + assert!(tokens.iter().any(|t| t.text == "link"), "link label was lost"); +} + +#[test] +fn markdown_headings_build_an_outline() { + let doc = oxidiris_core::load(&bytes("sample.md"), Some(Format::Markdown)).unwrap(); + let levels: Vec = doc.headings.iter().map(|h| h.level).collect(); + assert_eq!(levels, vec![1, 2, 3]); + assert_eq!(doc.meta.title.as_deref(), Some("Sample Document")); +} + +#[test] +fn table_contents_stay_out_of_the_reading_stream() { + let tokens = tokens("sample.md", Some(Format::Markdown)); + assert!(tokens.iter().all(|t| t.kind != oxidiris_core::TokenKind::Table)); +} + +#[test] +fn non_utf8_fixtures_decode_rather_than_failing() { + let utf16 = oxidiris_core::load(&bytes("utf16_bom.txt"), Some(Format::PlainText)).unwrap(); + assert!(utf16.source.contains("Xin chào"), "got {:?}", utf16.source); + + let latin = oxidiris_core::load(&bytes("latin1.txt"), Some(Format::PlainText)).unwrap(); + assert!(latin.source.contains("café"), "got {:?}", latin.source); +} + +#[test] +fn the_empty_fixture_produces_an_empty_document() { + let doc = oxidiris_core::load(&bytes("empty.txt"), None).unwrap(); + assert!(doc.blocks.is_empty()); + let player = Player::from_document(&doc, 300, PacingMode::Natural); + assert!(player.current().is_none()); +} + +/// The end-to-end case this proof of concept is judged on. +#[test] +fn the_projects_own_backlog_reads_end_to_end() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../BACKLOG.md"); + let doc = oxidiris_core::load(&std::fs::read(path).unwrap(), Some(Format::Markdown)).unwrap(); + + assert!(doc.headings.len() > 20, "expected a rich outline, got {}", doc.headings.len()); + + let mut player = Player::from_document(&doc, 400, PacingMode::Natural); + let total = player.tokens().len(); + assert!(total > 2_000, "expected a substantial stream, got {total}"); + + // Walk the entire document with a simulated clock and confirm it terminates. + player.play(); + let mut elapsed_ms: u64 = 0; + let mut steps = 0usize; + while player.current().is_some() { + elapsed_ms += u64::from(player.current_duration_ms()); + steps += 1; + assert!(steps <= total, "playback failed to terminate"); + if player.advance().is_none() { + break; + } + } + assert_eq!(steps, total); + + let effective = pacing::effective_wpm(player.tokens(), 400); + assert!(effective < 400, "effective speed {effective} should trail the configured 400"); + assert!(elapsed_ms > 0); +} diff --git a/crates/oxidiris/src/app.rs b/crates/oxidiris/src/app.rs new file mode 100644 index 0000000..41136af --- /dev/null +++ b/crates/oxidiris/src/app.rs @@ -0,0 +1,226 @@ +//! Application state and the key dispatcher. + +use oxidiris_core::player::{SEEK_WORDS, WPM_STEP_COARSE, WPM_STEP_FINE}; +use oxidiris_core::{Document, PacingMode, Player}; + +use crate::keymap::Action; +use crate::term::Capabilities; + +/// Everything the renderer needs to draw a frame. +pub struct App { + /// Playback cursor over the token stream. + pub player: Player, + /// Title shown on the reader frame, normally the file name. + pub title: String, + /// What the terminal can render. + pub caps: Capabilities, + /// Whether the key reference is open. + pub show_help: bool, + /// Scroll offset inside the key reference. + pub help_scroll: u16, + /// Transient note shown in place of the key hints. + pub message: Option, + /// Set once the reader has asked to leave. + pub should_quit: bool, +} + +impl App { + /// Build an application over a parsed document. + pub fn new(doc: &Document, title: String, wpm: u16, pacing: PacingMode) -> Self { + App { + player: Player::from_document(doc, wpm, pacing), + title, + caps: Capabilities::default(), + show_help: false, + help_scroll: 0, + message: None, + should_quit: false, + } + } + + /// Detect terminal capabilities from the environment. + pub fn with_detected_capabilities(mut self) -> Self { + self.caps = Capabilities::detect(); + self + } + + /// Attach a note to show in the status bar. + pub fn with_message(mut self, message: Option) -> Self { + self.message = message; + self + } + + /// The word currently on screen. + pub fn current_word(&self) -> Option<&str> { + self.player.current().map(|t| t.text.as_str()) + } + + /// Apply an action. + pub fn handle(&mut self, action: Action) { + // The message is a response to the last thing that happened, so any new input clears it. + if !matches!(action, Action::ToggleHelp | Action::Escape) { + self.message = None; + } + + match action { + Action::TogglePlay => { + self.show_help = false; + self.player.toggle(); + } + Action::Restart => self.player.restart(), + Action::Faster => self.player.adjust_wpm(i32::from(WPM_STEP_COARSE)), + Action::Slower => self.player.adjust_wpm(-i32::from(WPM_STEP_COARSE)), + Action::FasterFine => self.player.adjust_wpm(i32::from(WPM_STEP_FINE)), + Action::SlowerFine => self.player.adjust_wpm(-i32::from(WPM_STEP_FINE)), + Action::Back => self.player.seek_words(-(SEEK_WORDS as isize)), + Action::Forward => self.player.seek_words(SEEK_WORDS as isize), + Action::PrevBlock => self.player.seek_blocks(-1), + Action::NextBlock => self.player.seek_blocks(1), + Action::GotoStart => self.player.goto_start(), + Action::GotoEnd => self.player.goto_end(), + Action::ToggleHelp => { + self.show_help = !self.show_help; + self.help_scroll = 0; + // Opening the reference stops the stream; reading and reading about reading do not + // mix. + if self.show_help { + self.player.pause(); + } + } + Action::Escape => { + if self.show_help { + self.show_help = false; + } + } + Action::Quit => self.should_quit = true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oxidiris_core::{PlayState, parser, segment}; + + const DOC: &str = "\ +# Heading one + +First paragraph with a handful of words in it. + +Second paragraph, also with several words. +"; + + fn app() -> App { + let doc = parser::parse_markdown(&segment::sanitize(DOC)); + App::new(&doc, "test.md".into(), 300, PacingMode::Natural) + } + + #[test] + fn play_and_pause_toggle() { + let mut a = app(); + assert_eq!(a.player.state(), PlayState::Paused); + a.handle(Action::TogglePlay); + assert_eq!(a.player.state(), PlayState::Playing); + a.handle(Action::TogglePlay); + assert_eq!(a.player.state(), PlayState::Paused); + } + + #[test] + fn speed_actions_move_by_the_documented_steps() { + let mut a = app(); + a.handle(Action::Faster); + assert_eq!(a.player.wpm(), 300 + WPM_STEP_COARSE); + a.handle(Action::Slower); + assert_eq!(a.player.wpm(), 300); + a.handle(Action::FasterFine); + assert_eq!(a.player.wpm(), 300 + WPM_STEP_FINE); + } + + #[test] + fn navigation_actions_move_the_cursor() { + let mut a = app(); + a.handle(Action::Forward); + assert_eq!(a.player.progress().0, 1 + SEEK_WORDS); + a.handle(Action::Back); + assert_eq!(a.player.progress().0, 1); + a.handle(Action::GotoEnd); + assert_eq!(a.player.progress().0, a.player.tokens().len()); + } + + #[test] + fn paragraph_navigation_crosses_blocks() { + let mut a = app(); + let first = a.player.current().unwrap().block_id; + a.handle(Action::NextBlock); + assert!(a.player.current().unwrap().block_id > first); + } + + /// Reading and reading about reading do not mix. + #[test] + fn opening_help_pauses_playback() { + let mut a = app(); + a.handle(Action::TogglePlay); + assert!(a.player.is_playing()); + a.handle(Action::ToggleHelp); + assert!(a.show_help); + assert!(!a.player.is_playing()); + } + + /// Esc leaves the popup; only q leaves the program (spec §7.2). + #[test] + fn escape_closes_the_popup_without_quitting() { + let mut a = app(); + a.handle(Action::ToggleHelp); + a.handle(Action::Escape); + assert!(!a.show_help); + assert!(!a.should_quit); + } + + #[test] + fn escape_with_no_popup_open_does_nothing() { + let mut a = app(); + a.handle(Action::Escape); + assert!(!a.should_quit); + } + + #[test] + fn quit_sets_the_quit_flag() { + let mut a = app(); + a.handle(Action::Quit); + assert!(a.should_quit); + } + + #[test] + fn any_navigation_clears_a_pending_message() { + let mut a = app().with_message(Some("note".into())); + a.handle(Action::Forward); + assert!(a.message.is_none()); + } + + #[test] + fn opening_help_keeps_the_message_visible() { + let mut a = app().with_message(Some("note".into())); + a.handle(Action::ToggleHelp); + assert!(a.message.is_some()); + } + + #[test] + fn an_empty_document_survives_every_action() { + let doc = parser::parse_markdown(""); + let mut a = App::new(&doc, "empty.md".into(), 300, PacingMode::Natural); + for action in [ + Action::TogglePlay, + Action::Forward, + Action::Back, + Action::NextBlock, + Action::PrevBlock, + Action::GotoEnd, + Action::GotoStart, + Action::Restart, + Action::Faster, + ] { + a.handle(action); + } + assert!(a.current_word().is_none()); + } +} diff --git a/crates/oxidiris/src/cli.rs b/crates/oxidiris/src/cli.rs new file mode 100644 index 0000000..35c2e97 --- /dev/null +++ b/crates/oxidiris/src/cli.rs @@ -0,0 +1,206 @@ +//! Command line interface. +//! +//! Implements OXD-020. See spec §6. + +use std::path::PathBuf; + +use clap::{Parser, ValueEnum}; +use oxidiris_core::pacing::{DEFAULT_WPM, MAX_WPM, MIN_WPM}; + +/// Display mode for the reader. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Mode { + /// Split screen: RSVP frame plus the full text panel. Arrives in v0.2 (OXD-031). + Tui, + /// Minimal: only the RSVP frame, centred. + Focus, +} + +/// Pacing strategy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Pacing { + /// Vary timing by word length, punctuation and structure. + Natural, + /// Absolutely even timing. + Linear, +} + +impl From for oxidiris_core::PacingMode { + fn from(value: Pacing) -> Self { + match value { + Pacing::Natural => oxidiris_core::PacingMode::Natural, + Pacing::Linear => oxidiris_core::PacingMode::Linear, + } + } +} + +/// Colour theme. Full theme support arrives in v0.3 (OXD-040). +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Theme { + Dark, + Light, + Solarized, +} + +/// Read documents at speed without moving your eyes. +#[derive(Debug, Parser)] +#[command( + name = "oxidiris", + version, + about, + long_about = None, + after_help = "\ +EXAMPLES: + oxidiris README.md Read a file at the default 300 WPM + oxidiris paper.md -w 450 Start at 450 words per minute + oxidiris notes.txt --pacing linear Disable punctuation-aware pauses + oxidiris BACKLOG.md --dump Print clean plain text and exit + oxidiris BACKLOG.md | less Piping also produces plain text + +KEYS: + Space play/pause J/K speed H/L seek [/] paragraph ? help q quit" +)] +pub struct Cli { + /// Document to read. Use `-` to read from standard input. + pub file: PathBuf, + + /// Starting reading speed in words per minute. + #[arg(short = 'w', long, default_value_t = DEFAULT_WPM, value_parser = wpm_in_range)] + pub wpm: u16, + + /// Display mode. + #[arg(short = 'm', long, value_enum, default_value_t = Mode::Focus)] + pub mode: Mode, + + /// Pacing strategy. + #[arg(long, value_enum, default_value_t = Pacing::Natural)] + pub pacing: Pacing, + + /// Colour theme. + #[arg(long, value_enum)] + pub theme: Option, + + /// Print clean plain text to stdout instead of opening the reader. + #[arg(long)] + pub dump: bool, + + /// Words shown per step. Not implemented yet (OXD-046). + #[arg(long, value_name = "N")] + pub chunk: Option, + + /// Start position, e.g. `50%`. Not implemented yet (OXD-033). + #[arg(long, value_name = "POS")] + pub start: Option, + + /// Ignore any saved position. Not implemented yet (OXD-045). + #[arg(long)] + pub no_resume: bool, + + /// Alternate config file. Not implemented yet (OXD-041). + #[arg(long, value_name = "PATH")] + pub config: Option, +} + +/// Reject speeds outside the supported band with a message that names the band. +fn wpm_in_range(s: &str) -> Result { + let value: u16 = s.parse().map_err(|_| format!("`{s}` is not a number"))?; + if (MIN_WPM..=MAX_WPM).contains(&value) { + Ok(value) + } else { + Err(format!("speed must be between {MIN_WPM} and {MAX_WPM} WPM (got {value})")) + } +} + +impl Cli { + /// Whether the input is standard input rather than a file on disk. + pub fn reads_stdin(&self) -> bool { + self.file.as_os_str() == "-" + } + + /// Flags that parse but are not wired up yet, so the reader can say so instead of pretending. + pub fn unimplemented_flags(&self) -> Vec<&'static str> { + let mut v = Vec::new(); + if self.mode == Mode::Tui { + v.push("--mode tui (split view lands in v0.2, OXD-031)"); + } + if self.theme.is_some() { + v.push("--theme (OXD-040)"); + } + if self.chunk.is_some() { + v.push("--chunk (OXD-046)"); + } + if self.start.is_some() { + v.push("--start (OXD-033)"); + } + if self.no_resume { + v.push("--no-resume (OXD-045)"); + } + if self.config.is_some() { + v.push("--config (OXD-041)"); + } + v + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn cli_definition_is_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn defaults_are_the_safe_ones() { + let cli = Cli::try_parse_from(["oxidiris", "a.md"]).unwrap(); + assert_eq!(cli.wpm, DEFAULT_WPM); + assert_eq!(cli.mode, Mode::Focus); + assert_eq!(cli.pacing, Pacing::Natural); + assert!(!cli.dump); + } + + #[test] + fn wpm_outside_the_supported_band_is_rejected() { + for bad in ["10", "5000", "abc"] { + let err = Cli::try_parse_from(["oxidiris", "a.md", "-w", bad]).unwrap_err(); + assert!( + err.to_string().contains("between") || err.to_string().contains("not a number"), + "unhelpful error for {bad}: {err}" + ); + } + } + + #[test] + fn wpm_inside_the_band_is_accepted() { + let cli = Cli::try_parse_from(["oxidiris", "a.md", "--wpm", "450"]).unwrap(); + assert_eq!(cli.wpm, 450); + } + + #[test] + fn dash_means_standard_input() { + assert!(Cli::try_parse_from(["oxidiris", "-"]).unwrap().reads_stdin()); + assert!(!Cli::try_parse_from(["oxidiris", "a.md"]).unwrap().reads_stdin()); + } + + #[test] + fn unimplemented_flags_are_reported_not_silently_ignored() { + let cli = Cli::try_parse_from(["oxidiris", "a.md", "--chunk", "3", "--no-resume"]).unwrap(); + let flags = cli.unimplemented_flags(); + assert_eq!(flags.len(), 2); + assert!(flags.iter().any(|f| f.contains("--chunk"))); + } + + #[test] + fn a_fully_default_invocation_reports_nothing_unimplemented() { + let cli = Cli::try_parse_from(["oxidiris", "a.md"]).unwrap(); + assert!(cli.unimplemented_flags().is_empty()); + } + + #[test] + fn help_mentions_a_real_example() { + let help = Cli::command().render_help().to_string(); + assert!(help.contains("oxidiris README.md")); + } +} diff --git a/crates/oxidiris/src/dump.rs b/crates/oxidiris/src/dump.rs new file mode 100644 index 0000000..4dda412 --- /dev/null +++ b/crates/oxidiris/src/dump.rs @@ -0,0 +1,70 @@ +//! Plain-text output. +//! +//! Implements OXD-026. See spec §3.4.3 and §4.4. +//! +//! This is both the parser debugging tool and a genuine accessibility route: a screen reader +//! cannot follow text that rewrites itself several times a second, so there has to be a way to +//! get the cleaned document out as ordinary text. + +use std::io::Write; + +use anyhow::Result; +use oxidiris_core::Document; + +/// Write the document as clean plain text. +pub fn write_plain(out: &mut impl Write, doc: &Document) -> Result<()> { + write!(out, "{}", doc.to_plain_text())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use oxidiris_core::{parser, segment}; + + fn dump(md: &str) -> String { + let doc = parser::parse_markdown(&segment::sanitize(md)); + let mut buf = Vec::new(); + write_plain(&mut buf, &doc).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn output_is_readable_text_with_no_escape_sequences() { + let text = dump("# Title\n\nSome *emphasised* words and [a link](https://example.com).\n"); + assert!(!text.contains('\u{1b}'), "escape sequence leaked into plain output"); + assert!(text.contains("Title")); + assert!(text.contains("emphasised")); + } + + #[test] + fn urls_do_not_survive_into_plain_output() { + let text = dump("See [the docs](https://example.com/x) and .\n"); + assert!(!text.contains("://"), "got {text:?}"); + assert!(text.contains("the docs")); + } + + #[test] + fn structure_is_preserved_as_simple_markers() { + let text = dump("# H1\n\n## H2\n\n- item one\n- item two\n\n> quoted\n"); + assert!(text.contains("# H1")); + assert!(text.contains("## H2")); + assert!(text.contains("- item one")); + assert!(text.contains("> quoted")); + } + + #[test] + fn an_empty_document_produces_empty_output() { + assert_eq!(dump(""), ""); + } + + #[test] + fn the_projects_own_backlog_dumps_cleanly() { + let doc = oxidiris_core::load(include_bytes!("../../../BACKLOG.md"), None).unwrap(); + let mut buf = Vec::new(); + write_plain(&mut buf, &doc).unwrap(); + let text = String::from_utf8(buf).unwrap(); + assert!(text.len() > 5_000); + assert!(!text.contains('\u{1b}')); + } +} diff --git a/crates/oxidiris/src/event.rs b/crates/oxidiris/src/event.rs new file mode 100644 index 0000000..6422532 --- /dev/null +++ b/crates/oxidiris/src/event.rs @@ -0,0 +1,145 @@ +//! Input thread and the main loop. +//! +//! Implements the event half of OXD-021. See spec §4.2. +//! +//! Input is read on its own thread and delivered through a channel, so the main loop can block on +//! `recv_timeout(until_next_token)`. That keeps keystrokes responsive to the millisecond while the +//! loop is otherwise asleep, without the complexity of an async runtime. + +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use crossterm::event::{self, Event as CtEvent}; +use ratatui::DefaultTerminal; + +use crate::app::App; +use crate::keymap; +use crate::scheduler::Deadline; +use crate::ui; + +/// How long the input thread waits between polls. +const POLL_INTERVAL: Duration = Duration::from_millis(100); +/// Idle timeout while paused. Long enough that a paused reader costs no measurable CPU. +const IDLE_TIMEOUT: Duration = Duration::from_millis(250); + +/// Something the main loop reacts to. +#[derive(Debug)] +pub enum AppEvent { + /// A key was pressed. + Key(crossterm::event::KeyEvent), + /// The terminal was resized. + Resize, + /// The input thread failed. + Error(String), +} + +/// Start the input thread and return the receiving end of its channel. +pub fn spawn_input() -> Receiver { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + loop { + match event::poll(POLL_INTERVAL) { + Ok(false) => continue, + Ok(true) => match event::read() { + Ok(CtEvent::Key(key)) => { + if tx.send(AppEvent::Key(key)).is_err() { + break; + } + } + Ok(CtEvent::Resize(_, _)) => { + if tx.send(AppEvent::Resize).is_err() { + break; + } + } + Ok(_) => {} + Err(e) => { + let _ = tx.send(AppEvent::Error(e.to_string())); + break; + } + }, + Err(e) => { + let _ = tx.send(AppEvent::Error(e.to_string())); + break; + } + } + } + }); + rx +} + +/// Run the reader until the reader quits. +pub fn run(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> { + let rx = spawn_input(); + let mut deadline = Deadline::start(Instant::now()); + let mut dirty = true; + let mut was_playing = false; + + loop { + if dirty { + terminal.draw(|frame| ui::render(frame, app))?; + dirty = false; + } + if app.should_quit { + return Ok(()); + } + + // Starting playback resets the schedule, so the first word is not cut short by whatever + // time was left on the previous deadline. + let playing = app.player.is_playing(); + if playing && !was_playing { + deadline.resync(Instant::now()); + deadline.advance( + Instant::now(), + Duration::from_millis(u64::from(app.player.current_duration_ms())), + ); + } + was_playing = playing; + + let now = Instant::now(); + let timeout = if playing { deadline.timeout(now) } else { IDLE_TIMEOUT }; + + match rx.recv_timeout(timeout) { + Ok(AppEvent::Key(key)) => { + if let Some(action) = keymap::resolve(key) { + app.handle(action); + dirty = true; + } + } + Ok(AppEvent::Resize) => dirty = true, + Ok(AppEvent::Error(msg)) => { + app.message = Some(format!("input error: {msg}")); + dirty = true; + } + Err(RecvTimeoutError::Timeout) => { + if playing && deadline.is_due(Instant::now()) { + let step = Duration::from_millis(u64::from(app.player.current_duration_ms())); + if app.player.advance().is_none() { + app.player.pause(); + } + deadline.advance(Instant::now(), step); + dirty = true; + } + } + Err(RecvTimeoutError::Disconnected) => return Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_paused_reader_waits_a_long_time_between_wakeups() { + // A busy loop while paused would burn a laptop battery for nothing. + assert!(IDLE_TIMEOUT >= Duration::from_millis(100)); + } + + #[test] + fn the_input_poll_interval_keeps_keys_responsive() { + // The channel delivers immediately; this only bounds how long a shutdown takes. + assert!(POLL_INTERVAL <= Duration::from_millis(100)); + } +} diff --git a/crates/oxidiris/src/keymap.rs b/crates/oxidiris/src/keymap.rs new file mode 100644 index 0000000..06a4b57 --- /dev/null +++ b/crates/oxidiris/src/keymap.rs @@ -0,0 +1,282 @@ +//! Key bindings, declared once as data. +//! +//! Supports OXD-025. See spec §7. +//! +//! The help popup and the key dispatcher both read [`BINDINGS`], so the documented keys and the +//! working keys cannot drift apart. Phase 3 (OXD-030) turns this table into a per-mode map; the +//! shape is chosen so that change is additive. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Something the reader can ask the application to do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// Start or stop the stream. + TogglePlay, + /// Return to the first token. + Restart, + /// Increase speed by a coarse step. + Faster, + /// Decrease speed by a coarse step. + Slower, + /// Increase speed by a fine step. + FasterFine, + /// Decrease speed by a fine step. + SlowerFine, + /// Step back through the stream. + Back, + /// Step forward through the stream. + Forward, + /// Jump to the previous paragraph. + PrevBlock, + /// Jump to the next paragraph. + NextBlock, + /// Jump to the first token. + GotoStart, + /// Jump to the last token. + GotoEnd, + /// Show or hide the key reference. + ToggleHelp, + /// Leave a popup, or do nothing. + Escape, + /// Exit the program. + Quit, +} + +/// One documented binding. +#[derive(Debug, Clone, Copy)] +pub struct Binding { + /// Keys that trigger it, as shown to the reader. + pub keys: &'static str, + /// What it does. + pub description: &'static str, + /// Section heading in the help popup. + pub group: &'static str, + /// The action it maps to. + pub action: Action, +} + +/// Every binding the reader can use, in the order the help popup lists them. +pub const BINDINGS: &[Binding] = &[ + Binding { + keys: "Space", + description: "Play / pause", + group: "Playback", + action: Action::TogglePlay, + }, + Binding { + keys: "R", + description: "Restart from the beginning", + group: "Playback", + action: Action::Restart, + }, + Binding { + keys: "K / Up", + description: "Faster (+25 WPM)", + group: "Speed", + action: Action::Faster, + }, + Binding { + keys: "J / Down", + description: "Slower (-25 WPM)", + group: "Speed", + action: Action::Slower, + }, + Binding { + keys: "+", + description: "Faster, fine (+5 WPM)", + group: "Speed", + action: Action::FasterFine, + }, + Binding { + keys: "-", + description: "Slower, fine (-5 WPM)", + group: "Speed", + action: Action::SlowerFine, + }, + Binding { + keys: "H / Left", + description: "Back 5 words", + group: "Navigation", + action: Action::Back, + }, + Binding { + keys: "L / Right", + description: "Forward 5 words", + group: "Navigation", + action: Action::Forward, + }, + Binding { + keys: "[", + description: "Previous paragraph", + group: "Navigation", + action: Action::PrevBlock, + }, + Binding { + keys: "]", + description: "Next paragraph", + group: "Navigation", + action: Action::NextBlock, + }, + Binding { + keys: "g", + description: "Go to start", + group: "Navigation", + action: Action::GotoStart, + }, + Binding { keys: "G", description: "Go to end", group: "Navigation", action: Action::GotoEnd }, + Binding { + keys: "?", + description: "Toggle this help", + group: "System", + action: Action::ToggleHelp, + }, + Binding { keys: "Esc", description: "Close popup", group: "System", action: Action::Escape }, + Binding { keys: "q", description: "Quit", group: "System", action: Action::Quit }, +]; + +/// Map a key event to an action. +/// +/// Returns `None` for keys with no binding, and for key *release* events, which Windows terminals +/// emit alongside presses. +pub fn resolve(key: KeyEvent) -> Option { + use crossterm::event::KeyEventKind; + if key.kind == KeyEventKind::Release { + return None; + } + if key.modifiers.contains(KeyModifiers::CONTROL) { + return match key.code { + KeyCode::Char('c') => Some(Action::Quit), + _ => None, + }; + } + Some(match key.code { + KeyCode::Char(' ') => Action::TogglePlay, + KeyCode::Char('r') | KeyCode::Char('R') => Action::Restart, + KeyCode::Char('k') | KeyCode::Char('K') | KeyCode::Up => Action::Faster, + KeyCode::Char('j') | KeyCode::Char('J') | KeyCode::Down => Action::Slower, + KeyCode::Char('+') | KeyCode::Char('=') => Action::FasterFine, + KeyCode::Char('-') | KeyCode::Char('_') => Action::SlowerFine, + KeyCode::Char('h') | KeyCode::Char('H') | KeyCode::Left => Action::Back, + KeyCode::Char('l') | KeyCode::Char('L') | KeyCode::Right => Action::Forward, + KeyCode::Char('[') => Action::PrevBlock, + KeyCode::Char(']') => Action::NextBlock, + KeyCode::Char('g') => Action::GotoStart, + KeyCode::Char('G') => Action::GotoEnd, + KeyCode::Char('?') => Action::ToggleHelp, + KeyCode::Esc => Action::Escape, + KeyCode::Char('q') | KeyCode::Char('Q') => Action::Quit, + _ => return None, + }) +} + +/// Keys bound to `action`, formatted for display. +/// +/// The status bar hints go through here rather than hard-coding key names, so the table stays the +/// only place a binding is described. +pub fn keys_for(action: Action) -> &'static str { + BINDINGS.iter().find(|b| b.action == action).map_or("", |b| b.keys) +} + +/// Group names in display order. +pub fn groups() -> Vec<&'static str> { + let mut seen: Vec<&'static str> = Vec::new(); + for b in BINDINGS { + if !seen.contains(&b.group) { + seen.push(b.group); + } + } + seen +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyEventKind; + + fn press(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn every_documented_binding_is_reachable_from_a_key() { + // The help popup must not promise an action the dispatcher cannot produce. + let reachable: Vec = [ + KeyCode::Char(' '), + KeyCode::Char('r'), + KeyCode::Char('k'), + KeyCode::Char('j'), + KeyCode::Char('+'), + KeyCode::Char('-'), + KeyCode::Char('h'), + KeyCode::Char('l'), + KeyCode::Char('['), + KeyCode::Char(']'), + KeyCode::Char('g'), + KeyCode::Char('G'), + KeyCode::Char('?'), + KeyCode::Esc, + KeyCode::Char('q'), + ] + .into_iter() + .filter_map(|c| resolve(press(c))) + .collect(); + + for binding in BINDINGS { + assert!( + reachable.contains(&binding.action), + "binding {:?} is documented but unreachable", + binding.keys + ); + } + } + + #[test] + fn arrow_keys_mirror_the_vim_keys() { + assert_eq!(resolve(press(KeyCode::Up)), resolve(press(KeyCode::Char('k')))); + assert_eq!(resolve(press(KeyCode::Down)), resolve(press(KeyCode::Char('j')))); + assert_eq!(resolve(press(KeyCode::Left)), resolve(press(KeyCode::Char('h')))); + assert_eq!(resolve(press(KeyCode::Right)), resolve(press(KeyCode::Char('l')))); + } + + /// Windows terminals report presses and releases; acting on both would double every keystroke. + #[test] + fn key_releases_are_ignored() { + let mut ev = press(KeyCode::Char(' ')); + ev.kind = KeyEventKind::Release; + assert_eq!(resolve(ev), None); + } + + #[test] + fn ctrl_c_quits_and_other_control_chords_do_nothing() { + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(resolve(ctrl_c), Some(Action::Quit)); + let ctrl_x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL); + assert_eq!(resolve(ctrl_x), None); + } + + #[test] + fn escape_and_quit_are_separate_actions() { + // Esc closes a popup; only q leaves the program (spec §7.2). + assert_eq!(resolve(press(KeyCode::Esc)), Some(Action::Escape)); + assert_eq!(resolve(press(KeyCode::Char('q'))), Some(Action::Quit)); + } + + #[test] + fn unbound_keys_resolve_to_nothing() { + assert_eq!(resolve(press(KeyCode::Char('z'))), None); + assert_eq!(resolve(press(KeyCode::F(5))), None); + } + + #[test] + fn keys_for_returns_the_documented_key_names() { + assert_eq!(keys_for(Action::TogglePlay), "Space"); + assert_eq!(keys_for(Action::ToggleHelp), "?"); + assert_eq!(keys_for(Action::Quit), "q"); + } + + #[test] + fn groups_are_listed_once_each_in_order() { + assert_eq!(groups(), vec!["Playback", "Speed", "Navigation", "System"]); + } +} diff --git a/crates/oxidiris/src/scheduler.rs b/crates/oxidiris/src/scheduler.rs new file mode 100644 index 0000000..3bc17ba --- /dev/null +++ b/crates/oxidiris/src/scheduler.rs @@ -0,0 +1,143 @@ +//! Drift-free deadline scheduling. +//! +//! Implements the timing half of OXD-021. See spec §4.2. +//! +//! # Why not `sleep(60_000 / wpm)` +//! +//! The obvious loop adds the render time and the scheduler's own lateness to every single step. +//! At 300 WPM that is a few milliseconds per word, which compounds into tens of seconds over a +//! twenty-minute read. Worse than the total error, the rhythm becomes audibly uneven, and an +//! uneven rhythm is exactly what RSVP cannot tolerate. +//! +//! The fix is to anchor each deadline to the previous *deadline* rather than to the current time, +//! so lateness never accumulates. + +use std::time::{Duration, Instant}; + +/// If the clock is this far behind, assume the process was suspended and resynchronise instead of +/// firing a burst of catch-up steps in the reader's face. +const RESYNC_THRESHOLD: Duration = Duration::from_secs(2); + +/// Tracks when the next token is due. +#[derive(Debug, Clone, Copy)] +pub struct Deadline { + next: Instant, +} + +impl Deadline { + /// Start a schedule at `now`. + pub fn start(now: Instant) -> Self { + Deadline { next: now } + } + + /// How long to wait before the next token, given the current time. + /// + /// Zero when the deadline has already passed. + pub fn timeout(&self, now: Instant) -> Duration { + self.next.saturating_duration_since(now) + } + + /// Whether the next token is due. + pub fn is_due(&self, now: Instant) -> bool { + now >= self.next + } + + /// Schedule the following token `step` after the *previous deadline*. + /// + /// Anchoring to `self.next` rather than to `now` is the entire point: rendering jitter is + /// absorbed instead of accumulated. + pub fn advance(&mut self, now: Instant, step: Duration) { + self.next += step; + if now.saturating_duration_since(self.next) > RESYNC_THRESHOLD { + self.next = now + step; + } + } + + /// Restart the schedule from `now`, used when playback resumes after a pause. + pub fn resync(&mut self, now: Instant) { + self.next = now; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const STEP: Duration = Duration::from_millis(200); // 300 WPM + + /// The OXD-021 acceptance case, run against a simulated clock so it finishes instantly. + /// + /// Every iteration pretends the render arrived late by a varying amount. A naive + /// `now + step` scheduler would accumulate all of that lateness; this one must not. + #[test] + fn a_thousand_steps_accumulate_no_drift() { + let base = Instant::now(); + let mut deadline = Deadline::start(base); + let mut jitter = Duration::ZERO; + + for i in 0..1000u32 { + // Simulated wall clock: the ideal time plus a render delay of 0-7 ms. + jitter = Duration::from_millis(u64::from(i % 8)); + let now = base + STEP * i + jitter; + deadline.advance(now, STEP); + } + + assert_eq!(deadline.timeout(base), STEP * 1000, "deadline drifted despite per-step jitter"); + + // Sanity: the jitter really was present, so the test is not vacuous. + assert!(jitter > Duration::ZERO); + } + + #[test] + fn total_error_stays_far_below_one_percent() { + let base = Instant::now(); + let mut deadline = Deadline::start(base); + for i in 0..1000u32 { + deadline.advance(base + STEP * i + Duration::from_millis(3), STEP); + } + let ideal = STEP * 1000; + let actual = deadline.timeout(base); + let error = actual.abs_diff(ideal); + assert!( + error.as_secs_f64() / ideal.as_secs_f64() < 0.01, + "error {error:?} exceeds 1% of {ideal:?}" + ); + } + + #[test] + fn timeout_is_zero_once_the_deadline_has_passed() { + let base = Instant::now(); + let d = Deadline::start(base); + assert_eq!(d.timeout(base + Duration::from_secs(1)), Duration::ZERO); + assert!(d.is_due(base)); + } + + #[test] + fn timeout_counts_down_towards_the_deadline() { + let base = Instant::now(); + let mut d = Deadline::start(base); + d.advance(base, STEP); + assert_eq!(d.timeout(base), STEP); + assert_eq!(d.timeout(base + Duration::from_millis(50)), Duration::from_millis(150)); + } + + /// After a laptop suspend the deadline is hours behind. Catching up would fire thousands of + /// tokens instantly; resynchronising is the only sane behaviour. + #[test] + fn a_long_stall_resynchronises_instead_of_replaying() { + let base = Instant::now(); + let mut d = Deadline::start(base); + let woke_up = base + Duration::from_secs(3600); + d.advance(woke_up, STEP); + assert_eq!(d.timeout(woke_up), STEP); + } + + #[test] + fn a_short_stall_is_absorbed_not_resynchronised() { + let base = Instant::now(); + let mut d = Deadline::start(base); + let slightly_late = base + Duration::from_millis(500); + d.advance(slightly_late, STEP); + assert_eq!(d.timeout(base), STEP); + } +} diff --git a/crates/oxidiris/src/term.rs b/crates/oxidiris/src/term.rs new file mode 100644 index 0000000..ed64281 --- /dev/null +++ b/crates/oxidiris/src/term.rs @@ -0,0 +1,185 @@ +//! Terminal capability detection. +//! +//! Implements OXD-022. See spec §3.4.4. +//! +//! No two terminals are alike, and a reading tool that assumes truecolor and full Unicode will +//! render as garbage on the terminals people actually have in front of them. + +/// How much colour the terminal can show. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ColorLevel { + /// Monochrome: `NO_COLOR` is set, or `TERM=dumb`. + None, + /// The 16 ANSI colours. + Ansi16, + /// The 256-colour cube. + Ansi256, + /// 24-bit colour. + TrueColor, +} + +/// Whether the window is big enough for the layout we want. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SizeClass { + /// Comfortable: split view is possible. + Full, + /// Cramped: fall back to the single-panel focus mode. + Narrow, + /// Unusable: ask the reader to enlarge the window rather than drawing a broken layout. + TooSmall, +} + +/// Minimum width for the full layout. +pub const MIN_WIDTH: u16 = 80; +/// Minimum height for the full layout. +pub const MIN_HEIGHT: u16 = 24; +/// Absolute minimum width below which nothing is drawn. +pub const ABS_MIN_WIDTH: u16 = 30; +/// Absolute minimum height below which nothing is drawn. +/// +/// Ten rows is what the reader frame plus the status block need before they start clipping. +pub const ABS_MIN_HEIGHT: u16 = 10; + +/// Classify a terminal size. +pub const fn size_class(width: u16, height: u16) -> SizeClass { + if width < ABS_MIN_WIDTH || height < ABS_MIN_HEIGHT { + SizeClass::TooSmall + } else if width < MIN_WIDTH || height < MIN_HEIGHT { + SizeClass::Narrow + } else { + SizeClass::Full + } +} + +/// What the current terminal can render. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Capabilities { + /// Colour depth available. + pub color: ColorLevel, + /// Whether box-drawing and arrow glyphs are safe to emit. + pub unicode: bool, +} + +impl Capabilities { + /// Detect capabilities from the process environment. + pub fn detect() -> Self { + Self::from_env(|key| std::env::var(key).ok()) + } + + /// Detect capabilities from an arbitrary environment lookup, so this is testable. + pub fn from_env(get: impl Fn(&str) -> Option) -> Self { + let term = get("TERM").unwrap_or_default(); + let colorterm = get("COLORTERM").unwrap_or_default().to_ascii_lowercase(); + + // NO_COLOR is honoured whenever it is present and not empty. + // https://no-color.org + let no_color = get("NO_COLOR").is_some_and(|v| !v.is_empty()); + + let color = if no_color || term == "dumb" { + ColorLevel::None + } else if colorterm == "truecolor" || colorterm == "24bit" { + ColorLevel::TrueColor + } else if term.contains("256color") { + ColorLevel::Ansi256 + } else if term.is_empty() { + ColorLevel::None + } else { + ColorLevel::Ansi16 + }; + + let locale = get("LC_ALL") + .or_else(|| get("LC_CTYPE")) + .or_else(|| get("LANG")) + .unwrap_or_default() + .to_ascii_uppercase(); + let unicode = term != "dumb" && (locale.contains("UTF-8") || locale.contains("UTF8")); + + Capabilities { color, unicode } + } + + /// Marker glyphs framing the ORP character, above and below. + /// + /// These are not decoration. WCAG SC 1.4.1 forbids colour as the only carrier of information, + /// so the anchor must remain identifiable when colour is unavailable or the reader is + /// colour-blind (spec §3.4.1). + pub const fn markers(self) -> (&'static str, &'static str) { + if self.unicode { ("▼", "▲") } else { ("v", "^") } + } +} + +impl Default for Capabilities { + fn default() -> Self { + Capabilities { color: ColorLevel::Ansi16, unicode: true } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option + use<> { + let map: HashMap = + pairs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(); + move |key: &str| map.get(key).cloned() + } + + #[test] + fn no_color_disables_colour_entirely() { + let caps = Capabilities::from_env(env(&[ + ("NO_COLOR", "1"), + ("COLORTERM", "truecolor"), + ("TERM", "xterm-256color"), + ])); + assert_eq!(caps.color, ColorLevel::None); + } + + #[test] + fn empty_no_color_is_ignored() { + let caps = Capabilities::from_env(env(&[("NO_COLOR", ""), ("COLORTERM", "truecolor")])); + assert_eq!(caps.color, ColorLevel::TrueColor); + } + + #[test] + fn colour_depth_is_detected_in_order_of_preference() { + assert_eq!( + Capabilities::from_env(env(&[("COLORTERM", "truecolor"), ("TERM", "xterm")])).color, + ColorLevel::TrueColor + ); + assert_eq!( + Capabilities::from_env(env(&[("TERM", "xterm-256color")])).color, + ColorLevel::Ansi256 + ); + assert_eq!(Capabilities::from_env(env(&[("TERM", "xterm")])).color, ColorLevel::Ansi16); + } + + #[test] + fn dumb_terminal_gets_no_colour_and_no_unicode() { + let caps = Capabilities::from_env(env(&[("TERM", "dumb"), ("LANG", "en_US.UTF-8")])); + assert_eq!(caps.color, ColorLevel::None); + assert!(!caps.unicode); + assert_eq!(caps.markers(), ("v", "^")); + } + + #[test] + fn unicode_markers_need_a_utf8_locale() { + let utf8 = Capabilities::from_env(env(&[("TERM", "xterm"), ("LANG", "en_US.UTF-8")])); + assert!(utf8.unicode); + assert_eq!(utf8.markers(), ("▼", "▲")); + + let latin = Capabilities::from_env(env(&[("TERM", "xterm"), ("LANG", "en_US.ISO-8859-1")])); + assert!(!latin.unicode); + } + + #[test] + fn size_classes_match_the_documented_thresholds() { + assert_eq!(size_class(120, 40), SizeClass::Full); + assert_eq!(size_class(80, 24), SizeClass::Full); + assert_eq!(size_class(79, 24), SizeClass::Narrow); + assert_eq!(size_class(80, 23), SizeClass::Narrow); + assert_eq!(size_class(40, 10), SizeClass::Narrow); + assert_eq!(size_class(29, 10), SizeClass::TooSmall); + assert_eq!(size_class(40, 9), SizeClass::TooSmall); + assert_eq!(size_class(0, 0), SizeClass::TooSmall); + } +} diff --git a/crates/oxidiris/src/ui/help.rs b/crates/oxidiris/src/ui/help.rs new file mode 100644 index 0000000..54326ab --- /dev/null +++ b/crates/oxidiris/src/ui/help.rs @@ -0,0 +1,136 @@ +//! Key reference popup. +//! +//! Implements OXD-025. See spec §7. +//! +//! Every line is generated from [`crate::keymap::BINDINGS`], so the popup cannot promise a key +//! that the dispatcher does not honour. + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Paragraph}; + +use crate::keymap::{BINDINGS, groups}; + +/// Build the popup body from the binding table. +pub fn lines() -> Vec> { + let mut out = Vec::new(); + for (i, group) in groups().into_iter().enumerate() { + if i > 0 { + out.push(Line::raw("")); + } + out.push(Line::from(Span::styled( + group, + Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ))); + for binding in BINDINGS.iter().filter(|b| b.group == group) { + out.push(Line::from(vec![ + Span::raw(format!(" {:<10}", binding.keys)), + Span::raw(binding.description), + ])); + } + } + out +} + +/// Centre a `width` x `height` rectangle inside `area`, shrinking to fit if needed. +fn centered(area: Rect, width: u16, height: u16) -> Rect { + let w = width.min(area.width); + let h = height.min(area.height); + Rect { + x: area.x + (area.width.saturating_sub(w)) / 2, + y: area.y + (area.height.saturating_sub(h)) / 2, + width: w, + height: h, + } +} + +/// Render the popup over `area`. +/// +/// `scroll` lets a short terminal reach the lower entries. +pub fn render(frame: &mut Frame, area: Rect, scroll: u16) { + let body = lines(); + let popup = centered(area, 46, body.len() as u16 + 2); + + frame.render_widget(Clear, popup); + let block = Block::bordered().title(Line::from(" Keys · [?] or [Esc] to close ")); + frame.render_widget(Paragraph::new(body).block(block).scroll((scroll, 0)), popup); +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + /// The popup is generated, not hand-written: every binding must appear in it. + #[test] + fn every_binding_appears_in_the_popup() { + let rendered: String = lines() + .iter() + .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::()) + .collect::>() + .join("\n"); + + for binding in BINDINGS { + assert!(rendered.contains(binding.keys), "key {:?} missing from help", binding.keys); + assert!( + rendered.contains(binding.description), + "description {:?} missing from help", + binding.description + ); + } + } + + #[test] + fn every_group_gets_a_heading() { + let rendered: String = lines() + .iter() + .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::()) + .collect::>() + .join("\n"); + for group in groups() { + assert!(rendered.contains(group), "group {group:?} missing from help"); + } + } + + #[test] + fn the_popup_is_centred_and_stays_inside_the_frame() { + let area = Rect { x: 0, y: 0, width: 100, height: 40 }; + let popup = centered(area, 46, 20); + assert_eq!(popup.x, 27); + assert_eq!(popup.y, 10); + assert!(popup.right() <= area.right()); + assert!(popup.bottom() <= area.bottom()); + } + + #[test] + fn the_popup_shrinks_rather_than_overflowing_a_small_frame() { + let area = Rect { x: 0, y: 0, width: 30, height: 10 }; + let popup = centered(area, 46, 30); + assert_eq!(popup.width, 30); + assert_eq!(popup.height, 10); + } + + #[test] + fn rendering_the_popup_never_panics() { + for (w, h) in [(30u16, 10u16), (80, 24), (200, 60)] { + let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); + terminal.draw(|frame| render(frame, frame.area(), 0)).unwrap(); + } + } + + #[test] + fn scrolling_reaches_the_lower_entries_on_a_short_terminal() { + let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap(); + terminal.draw(|frame| render(frame, frame.area(), 12)).unwrap(); + let buffer = terminal.backend().buffer(); + let text: String = (0..12) + .flat_map(|y| (0..60).map(move |x| (x, y))) + .filter_map(|(x, y)| buffer.cell((x, y))) + .map(|c| c.symbol()) + .collect(); + assert!(text.contains("Quit"), "scrolled view should reach the System group"); + } +} diff --git a/crates/oxidiris/src/ui/mod.rs b/crates/oxidiris/src/ui/mod.rs new file mode 100644 index 0000000..af0ef1f --- /dev/null +++ b/crates/oxidiris/src/ui/mod.rs @@ -0,0 +1,143 @@ +//! Screen composition. + +pub mod help; +pub mod rsvp; +pub mod status; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +use crate::app::App; +use crate::term::{ABS_MIN_HEIGHT, ABS_MIN_WIDTH, SizeClass, size_class}; + +/// Rows reserved for the status block, including its border. +const STATUS_HEIGHT: u16 = 4; + +/// Draw the whole screen. +pub fn render(frame: &mut Frame, app: &App) { + let area = frame.area(); + + if size_class(area.width, area.height) == SizeClass::TooSmall { + render_too_small(frame, area); + return; + } + + let [reader_area, status_area] = + Layout::vertical([Constraint::Min(5), Constraint::Length(STATUS_HEIGHT)]).areas(area); + + rsvp::render(frame, reader_area, app.current_word(), &app.title, app.caps); + status::render(frame, status_area, &app.player, app.caps, app.message.as_deref()); + + if app.show_help { + help::render(frame, area, app.help_scroll); + } +} + +/// Ask for a bigger window instead of drawing a broken layout (spec §3.4.4). +fn render_too_small(frame: &mut Frame, area: Rect) { + let text = vec![ + Line::from("Terminal too small").centered(), + Line::from(format!("need at least {ABS_MIN_WIDTH}x{ABS_MIN_HEIGHT}")).centered(), + Line::from(format!("current: {}x{}", area.width, area.height)).centered(), + Line::from("").centered(), + Line::from("[q] quit").centered(), + ]; + let top = area.height.saturating_sub(text.len() as u16) / 2; + let mut lines = vec![Line::raw(""); top as usize]; + lines.extend(text); + frame.render_widget( + Paragraph::new(lines).style(Style::default().add_modifier(Modifier::BOLD)), + area, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use oxidiris_core::{PacingMode, parser, segment}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + fn app() -> App { + let text = segment::sanitize("# Title\n\nSome words to read here quickly.\n"); + let doc = parser::parse_markdown(&text); + App::new(&doc, "test.md".into(), 300, PacingMode::Natural) + } + + #[test] + fn the_full_screen_renders_at_every_supported_size() { + for (w, h) in [(30u16, 10u16), (40, 12), (80, 24), (120, 40), (200, 60)] { + let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); + terminal.draw(|frame| render(frame, &app())).unwrap(); + } + } + + #[test] + fn a_window_below_the_absolute_minimum_asks_to_be_enlarged() { + let mut terminal = Terminal::new(TestBackend::new(20, 6)).unwrap(); + terminal.draw(|frame| render(frame, &app())).unwrap(); + let buffer = terminal.backend().buffer(); + let text: String = (0..6u16) + .flat_map(|y| (0..20u16).map(move |x| (x, y))) + .filter_map(|(x, y)| buffer.cell((x, y))) + .map(|c| c.symbol()) + .collect(); + assert!(text.contains("too small"), "expected a size prompt, got {text:?}"); + } + + #[test] + fn the_help_overlay_draws_on_top_of_the_reader() { + let mut a = app(); + a.show_help = true; + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|frame| render(frame, &a)).unwrap(); + let buffer = terminal.backend().buffer(); + let text: String = (0..24u16) + .flat_map(|y| (0..80u16).map(move |x| (x, y))) + .filter_map(|(x, y)| buffer.cell((x, y))) + .map(|c| c.symbol()) + .collect(); + assert!(text.contains("Play / pause")); + } + + /// Render one real frame of this project's own backlog and print it. + /// + /// Ignored by default because its value is visual, not assertive. Run it with + /// `make frame` to eyeball the layout without opening a terminal session. + #[test] + #[ignore = "visual check, run via `make frame`"] + fn render_frame_of_the_backlog() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../BACKLOG.md"); + let doc = oxidiris_core::load(&std::fs::read(path).unwrap(), None).unwrap(); + let mut a = App::new(&doc, "BACKLOG.md".into(), 400, PacingMode::Natural); + a.player.seek_ratio(0.012); + + let mut terminal = Terminal::new(TestBackend::new(80, 16)).unwrap(); + terminal.draw(|frame| render(frame, &a)).unwrap(); + let buffer = terminal.backend().buffer(); + + println!(); + for y in 0..16u16 { + let line: String = + (0..80u16).filter_map(|x| buffer.cell((x, y))).map(|c| c.symbol()).collect(); + println!("{}", line.trim_end()); + } + println!("\ncurrent word: {:?}", a.current_word()); + println!("tokens: {}", a.player.tokens().len()); + println!("headings: {}", doc.headings.len()); + } + + #[test] + fn resizing_repeatedly_keeps_the_layout_intact() { + let a = app(); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + for (w, h) in [(80u16, 24u16), (31, 10), (200, 50), (40, 12), (80, 24)] { + terminal.backend_mut().resize(w, h); + terminal.draw(|frame| render(frame, &a)).unwrap(); + } + } +} diff --git a/crates/oxidiris/src/ui/rsvp.rs b/crates/oxidiris/src/ui/rsvp.rs new file mode 100644 index 0000000..a40a096 --- /dev/null +++ b/crates/oxidiris/src/ui/rsvp.rs @@ -0,0 +1,350 @@ +//! The RSVP frame: one word, anchored so the eye never moves. +//! +//! Implements OXD-023. See spec §3.1.3 and §3.4.1. +//! +//! # The one invariant +//! +//! The ORP grapheme is drawn at the *same absolute column* for every word in the document. Not +//! approximately, not usually: exactly, or the tool has no reason to exist. + +use oxidiris_core::orp; +use oxidiris_core::segment::{display_width, graphemes}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; + +use crate::term::{Capabilities, ColorLevel}; + +/// A word broken into the three pieces the renderer draws, plus its left padding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fitted { + /// Spaces to the left of the word. + pub pad: u16, + /// Graphemes before the anchor. + pub prefix: String, + /// The anchored grapheme. + pub focus: String, + /// Graphemes after the anchor. + pub suffix: String, +} + +impl Fitted { + /// Column at which the focus grapheme starts. + pub fn focus_column(&self) -> u16 { + self.pad + display_width(&self.prefix) + } +} + +/// Keep the leftmost graphemes that fit in `max` columns, marking the cut with an ellipsis. +fn truncate_right(text: &str, max: u16) -> String { + if display_width(text) <= max { + return text.to_string(); + } + if max == 0 { + return String::new(); + } + if max == 1 { + return "\u{2026}".to_string(); + } + let budget = max - 1; // room for the ellipsis + let mut out = String::new(); + let mut used = 0u16; + for g in graphemes(text) { + let w = display_width(g); + if used + w > budget { + break; + } + out.push_str(g); + used += w; + } + out.push('\u{2026}'); + out +} + +/// Keep the rightmost graphemes that fit in `max` columns. +fn truncate_left(text: &str, max: u16) -> String { + if display_width(text) <= max { + return text.to_string(); + } + if max == 0 { + return String::new(); + } + let budget = max - 1; + let mut kept: Vec<&str> = Vec::new(); + let mut used = 0u16; + for g in graphemes(text).into_iter().rev() { + let w = display_width(g); + if used + w > budget { + break; + } + kept.push(g); + used += w; + } + kept.reverse(); + format!("\u{2026}{}", kept.concat()) +} + +/// Lay out `word` so its ORP grapheme lands on column `anchor` inside a frame `width` wide. +/// +/// The padding is computed from the *column width* of the prefix, never from its character count, +/// which is what keeps CJK and emoji from shifting the anchor (spec §3.1.1). +pub fn fit(word: &str, anchor: u16, width: u16) -> Fitted { + let layout = orp::layout(word); + let focus_width = display_width(&layout.focus); + + // A word whose prefix is wider than the anchor cannot be placed as-is; trimming from the left + // keeps the anchor honest at the cost of the word's head, which is the lesser evil. + let prefix = if layout.orp_offset > anchor { + truncate_left(&layout.prefix, anchor) + } else { + layout.prefix + }; + let prefix_width = display_width(&prefix); + let pad = anchor.saturating_sub(prefix_width); + + let used = pad.saturating_add(prefix_width).saturating_add(focus_width); + let right_budget = width.saturating_sub(used); + let suffix = truncate_right(&layout.suffix, right_budget); + + Fitted { pad, prefix, focus: layout.focus, suffix } +} + +/// Style for the anchored grapheme. +/// +/// Bold and underline are applied unconditionally, so the anchor survives a monochrome terminal +/// and remains visible to a colour-blind reader. Colour is an *addition*, never the only signal +/// (WCAG SC 1.4.1, spec §3.4.1). +fn focus_style(caps: Capabilities) -> Style { + let base = Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + match caps.color { + ColorLevel::None => base, + ColorLevel::Ansi16 => base.fg(Color::LightRed), + ColorLevel::Ansi256 | ColorLevel::TrueColor => base.fg(Color::Rgb(0xFF, 0x5F, 0x5F)), + } +} + +/// Render the reader frame into `area`. +pub fn render(frame: &mut Frame, area: Rect, word: Option<&str>, title: &str, caps: Capabilities) { + let block = Block::bordered().title(Line::from(format!(" {title} "))); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.width == 0 || inner.height == 0 { + return; + } + + let anchor = inner.width / 2; + let (marker_top, marker_bottom) = caps.markers(); + let marker_line = |glyph: &str| { + Line::from(vec![Span::raw(" ".repeat(anchor as usize)), Span::raw(glyph.to_string())]) + }; + + let word_line = match word { + Some(w) => { + let f = fit(w, anchor, inner.width); + // The reason this program exists, checked on every debug frame. + debug_assert_eq!(f.focus_column(), anchor, "ORP left the anchor column"); + Line::from(vec![ + Span::raw(" ".repeat(f.pad as usize)), + Span::raw(f.prefix), + Span::styled(f.focus, focus_style(caps)), + Span::raw(f.suffix), + ]) + } + None => Line::from(vec![ + Span::raw(" ".repeat(anchor.saturating_sub(3) as usize)), + Span::styled("[end]", Style::default().add_modifier(Modifier::DIM)), + ]), + }; + + // Centre the three-line group vertically, but never above the frame. + let top_offset = inner.height.saturating_sub(3) / 2; + let lines = vec![Line::raw(""); top_offset as usize] + .into_iter() + .chain([marker_line(marker_top), word_line, marker_line(marker_bottom)]) + .collect::>(); + + frame.render_widget(Paragraph::new(lines), inner); +} + +#[cfg(test)] +mod tests { + use super::*; + use oxidiris_core::segment::sanitize; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + /// The corpus that every alignment claim is checked against. + const CORPUS: &[&str] = &[ + "a", + "I", + "at", + "the", + "hello", + "reading", + "javascript", + "internationalization", + "supercalifragilisticexpialidocious", + "Oxidiris", + "tiếng", + "Việt", + "nghiên", + "日本語", + "日本語文書", + "3.14159", + "std::fmt", + "v1.2.3", + "e.g.", + "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", + "\u{1F1FB}\u{1F1F3}", + "café", + ]; + + #[test] + fn focus_lands_on_the_anchor_for_every_word_in_the_corpus() { + for width in [40u16, 80, 120, 200] { + let anchor = width / 2; + for word in CORPUS { + let f = fit(&sanitize(word), anchor, width); + assert_eq!( + f.focus_column(), + anchor, + "word {word:?} at width {width} put the anchor on column {}", + f.focus_column() + ); + } + } + } + + #[test] + fn fitted_pieces_reassemble_the_word_when_nothing_is_truncated() { + for word in CORPUS { + let w = sanitize(word); + let f = fit(&w, 60, 120); + assert_eq!(format!("{}{}{}", f.prefix, f.focus, f.suffix), w); + } + } + + #[test] + fn an_over_long_word_is_truncated_rather_than_overflowing() { + let word = "a".repeat(200); + let f = fit(&word, 20, 40); + let drawn = + f.pad + display_width(&f.prefix) + display_width(&f.focus) + display_width(&f.suffix); + assert!(drawn <= 40, "drew {drawn} columns into a 40 column frame"); + assert!(f.suffix.ends_with('\u{2026}'), "truncation must be visible"); + } + + #[test] + fn a_word_wider_than_the_anchor_keeps_the_anchor_and_trims_its_head() { + // ORP index 4 on a wide-character word: the prefix alone is 8 columns. + let word = "日本語文書館庫室"; + let f = fit(word, 3, 20); + assert_eq!(f.focus_column(), 3); + assert!(f.prefix.starts_with('\u{2026}')); + } + + #[test] + fn a_zero_width_frame_does_not_panic() { + let f = fit("hello", 0, 0); + assert_eq!(f.pad, 0); + } + + /// End-to-end through a real ratatui buffer: the anchor cell must hold the focus grapheme. + #[test] + fn the_rendered_buffer_puts_the_focus_grapheme_on_the_anchor_column() { + let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap(); + let caps = Capabilities::default(); + + for word in CORPUS { + let w = sanitize(word); + terminal.draw(|frame| render(frame, frame.area(), Some(&w), "test", caps)).unwrap(); + + let buffer = terminal.backend().buffer(); + // Frame is 80 wide with a 1-column border, so inner width is 78 and the anchor is 39. + let anchor_x = 1 + 78 / 2; + let expected = orp::layout(&w).focus; + + let found = (0..12) + .filter_map(|y| buffer.cell((anchor_x, y))) + .any(|cell| cell.symbol() == expected); + assert!(found, "focus grapheme {expected:?} of {word:?} was not on column {anchor_x}"); + } + } + + #[test] + fn the_anchor_column_is_identical_across_words_of_wildly_different_lengths() { + let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap(); + let caps = Capabilities::default(); + let mut columns = Vec::new(); + + for word in ["a", "hello", "internationalization", "日本語文書"] { + terminal.draw(|frame| render(frame, frame.area(), Some(word), "t", caps)).unwrap(); + let buffer = terminal.backend().buffer(); + let expected = orp::layout(word).focus; + let col = (0..80u16) + .find(|x| { + (0..12u16) + .filter_map(|y| buffer.cell((*x, y))) + .any(|c| c.symbol() == expected && *x > 0) + }) + .unwrap(); + columns.push(col); + } + assert!( + columns.windows(2).all(|w| w[0] == w[1]), + "anchor column moved between words: {columns:?}" + ); + } + + /// WCAG SC 1.4.1: with colour unavailable the anchor must still be identifiable. + #[test] + fn the_anchor_survives_a_monochrome_terminal() { + let caps = Capabilities { color: ColorLevel::None, unicode: true }; + let style = focus_style(caps); + assert!(style.add_modifier.contains(Modifier::UNDERLINED)); + assert!(style.add_modifier.contains(Modifier::BOLD)); + assert_eq!(style.fg, None, "monochrome terminals must not be sent colour"); + } + + #[test] + fn colour_terminals_get_colour_on_top_of_the_non_colour_signal() { + let style = focus_style(Capabilities { color: ColorLevel::TrueColor, unicode: true }); + assert!(style.fg.is_some()); + assert!(style.add_modifier.contains(Modifier::UNDERLINED)); + } + + #[test] + fn markers_are_drawn_on_the_anchor_column() { + let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap(); + let caps = Capabilities::default(); + terminal.draw(|frame| render(frame, frame.area(), Some("hello"), "t", caps)).unwrap(); + let buffer = terminal.backend().buffer(); + let anchor_x = 1 + 78 / 2; + let symbols: Vec = + (0..12).filter_map(|y| buffer.cell((anchor_x, y))).map(|c| c.symbol().into()).collect(); + assert!(symbols.iter().any(|s| s == "▼")); + assert!(symbols.iter().any(|s| s == "▲")); + } + + #[test] + fn rendering_at_many_sizes_never_panics() { + let caps = Capabilities::default(); + for (w, h) in [(30u16, 10u16), (40, 12), (80, 24), (200, 50), (31, 10)] { + let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); + terminal + .draw(|frame| render(frame, frame.area(), Some("internationalization"), "t", caps)) + .unwrap(); + } + } + + #[test] + fn the_end_of_stream_marker_renders_without_a_word() { + let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap(); + terminal + .draw(|frame| render(frame, frame.area(), None, "t", Capabilities::default())) + .unwrap(); + } +} diff --git a/crates/oxidiris/src/ui/status.rs b/crates/oxidiris/src/ui/status.rs new file mode 100644 index 0000000..a9f6799 --- /dev/null +++ b/crates/oxidiris/src/ui/status.rs @@ -0,0 +1,228 @@ +//! Status bar: speed, position, progress. +//! +//! Implements OXD-024. See spec §3.3 and §5. + +use oxidiris_core::Player; +use oxidiris_core::pacing::FLASH_WARNING_WPM; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; + +use crate::keymap::{Action, keys_for}; +use crate::term::{Capabilities, ColorLevel}; + +/// Left half of the status line: configured speed and the speed actually achieved. +/// +/// Both numbers are shown because pacing multipliers make them differ by 15-25%, and a reader who +/// only sees the configured number will believe a speed they are not reading at (spec §3.2.4). +pub fn speed_text(player: &Player) -> String { + format!("speed: {} WPM (eff. {})", player.wpm(), player.effective_wpm()) +} + +/// Right half: position measured in words, which is the unit the cursor actually moves in. +pub fn position_text(player: &Player) -> String { + let (current, total) = player.progress(); + let percent = (player.progress_ratio() * 100.0).round() as u32; + format!("word: {current}/{total} ({percent}%)") +} + +/// Draw a progress bar from plain characters, so it survives terminals without box drawing. +pub fn progress_bar(ratio: f64, width: u16, unicode: bool) -> String { + if width == 0 { + return String::new(); + } + let (full, empty) = if unicode { ('\u{2588}', '\u{2591}') } else { ('#', '-') }; + let filled = (ratio.clamp(0.0, 1.0) * f64::from(width)).round() as u16; + let mut bar = String::with_capacity(width as usize); + for i in 0..width { + bar.push(if i < filled { full } else { empty }); + } + bar +} + +/// Render the status block into `area`. +pub fn render( + frame: &mut Frame, + area: Rect, + player: &Player, + caps: Capabilities, + message: Option<&str>, +) { + let block = Block::bordered(); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.width == 0 || inner.height == 0 { + return; + } + + let speed = speed_text(player); + let position = position_text(player); + let gap = (inner.width as usize) + .saturating_sub(speed.chars().count() + position.chars().count()) + .max(1); + + let warn = player.wpm() >= FLASH_WARNING_WPM; + let speed_style = if warn && caps.color != ColorLevel::None { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else if warn { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let first = Line::from(vec![ + Span::styled(speed, speed_style), + Span::raw(" ".repeat(gap)), + Span::raw(position), + ]); + + let hint = match message { + Some(msg) => msg.to_string(), + None if warn => { + format!("high speed (>{FLASH_WARNING_WPM} WPM) may be uncomfortable · [?] help") + } + None => { + let state = if player.is_playing() { "pause" } else { "play" }; + format!( + "[{}] {state} [{}] speed [{}] seek [{}] help [{}] quit", + keys_for(Action::TogglePlay), + keys_for(Action::Faster), + keys_for(Action::Back), + keys_for(Action::ToggleHelp), + keys_for(Action::Quit), + ) + } + }; + + let bar_width = inner.width.saturating_sub(hint.chars().count() as u16 + 2); + let second = Line::from(vec![ + Span::styled(hint, Style::default().add_modifier(Modifier::DIM)), + Span::raw(" "), + Span::raw(progress_bar(player.progress_ratio(), bar_width, caps.unicode)), + ]); + + frame.render_widget(Paragraph::new(vec![first, second]), inner); +} + +#[cfg(test)] +mod tests { + use super::*; + use oxidiris_core::{PacingMode, parser, segment}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + fn player() -> Player { + let text = segment::sanitize( + "The quick brown fox jumps over the lazy dog. Pack my box with five dozen jugs.\n", + ); + let doc = parser::parse_markdown(&text); + Player::from_document(&doc, 450, PacingMode::Natural) + } + + #[test] + fn speed_shows_both_the_configured_and_the_effective_number() { + let p = player(); + let text = speed_text(&p); + assert!(text.contains("450 WPM")); + assert!(text.contains("eff.")); + assert!(p.effective_wpm() < p.wpm(), "effective speed should trail the configured one"); + } + + #[test] + fn effective_speed_follows_a_speed_change() { + let mut p = player(); + let before = p.effective_wpm(); + p.set_wpm(900); + assert!(p.effective_wpm() > before); + } + + #[test] + fn position_is_measured_in_words_not_lines() { + let p = player(); + let text = position_text(&p); + assert!(text.starts_with("word: "), "got {text:?}"); + assert!(text.contains('%')); + } + + #[test] + fn percentage_matches_the_players_progress() { + let mut p = player(); + p.goto_end(); + assert!(position_text(&p).contains("100%")); + p.goto_start(); + assert!(position_text(&p).contains("(0%)")); + } + + #[test] + fn progress_bar_respects_its_width_and_the_unicode_setting() { + assert_eq!(progress_bar(0.5, 10, false).chars().count(), 10); + assert_eq!(progress_bar(0.0, 4, false), "----"); + assert_eq!(progress_bar(1.0, 4, false), "####"); + assert!(progress_bar(0.5, 10, true).contains('\u{2588}')); + assert_eq!(progress_bar(0.5, 0, true), ""); + } + + #[test] + fn progress_bar_clamps_out_of_range_ratios() { + assert_eq!(progress_bar(-1.0, 4, false), "----"); + assert_eq!(progress_bar(5.0, 4, false), "####"); + } + + #[test] + fn the_status_line_fits_an_eighty_column_terminal() { + let mut terminal = Terminal::new(TestBackend::new(80, 6)).unwrap(); + let p = player(); + terminal + .draw(|frame| render(frame, frame.area(), &p, Capabilities::default(), None)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + // Nothing may be written outside the frame, and the border must survive on both edges. + for y in 0..6u16 { + assert!(buffer.cell((79u16, y)).is_some()); + } + let rendered: String = + (0..80).filter_map(|x| buffer.cell((x, 1u16))).map(|c| c.symbol()).collect(); + assert!(rendered.contains("450 WPM"), "status line was {rendered:?}"); + } + + #[test] + fn a_high_speed_earns_a_warning_line() { + let mut p = player(); + p.set_wpm(900); + let mut terminal = Terminal::new(TestBackend::new(100, 6)).unwrap(); + terminal + .draw(|frame| render(frame, frame.area(), &p, Capabilities::default(), None)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let line: String = + (0..100).filter_map(|x| buffer.cell((x, 2u16))).map(|c| c.symbol()).collect(); + assert!(line.contains("high speed"), "expected a warning, got {line:?}"); + } + + /// The hints must be generated, so renaming a key in the table updates the status bar too. + #[test] + fn the_hint_line_is_built_from_the_binding_table() { + let mut terminal = Terminal::new(TestBackend::new(100, 6)).unwrap(); + let p = player(); + terminal + .draw(|frame| render(frame, frame.area(), &p, Capabilities::default(), None)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let line: String = + (0..100).filter_map(|x| buffer.cell((x, 2u16))).map(|c| c.symbol()).collect(); + assert!(line.contains(keys_for(Action::TogglePlay))); + assert!(line.contains(keys_for(Action::Quit))); + } + + #[test] + fn rendering_into_a_tiny_area_does_not_panic() { + let mut terminal = Terminal::new(TestBackend::new(12, 3)).unwrap(); + let p = player(); + terminal + .draw(|frame| render(frame, frame.area(), &p, Capabilities::default(), None)) + .unwrap(); + } +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..938bf10 --- /dev/null +++ b/deny.toml @@ -0,0 +1,34 @@ +[graph] +targets = [ + { triple = "x86_64-unknown-linux-gnu" }, + { triple = "aarch64-apple-darwin" }, + { triple = "x86_64-pc-windows-msvc" }, + { triple = "wasm32-unknown-unknown" }, +] + +[advisories] +yanked = "deny" + +[licenses] +# Permissive licences only for dependencies. Oxidiris itself is GPL-3.0-or-later, which can +# consume all of these; see DEC-01 in BACKLOG.md for the pending dual-licence decision. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Zlib", + "MPL-2.0", +] +confidence-threshold = 0.9 + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" \ No newline at end of file diff --git a/docs/decisions/token-timing.md b/docs/decisions/token-timing.md new file mode 100644 index 0000000..88a134b --- /dev/null +++ b/docs/decisions/token-timing.md @@ -0,0 +1,66 @@ +# ADR 001 — Tokens carry a weight, not a duration + +**Status:** Accepted +**Date:** 2026-09-01 +**Task:** OXD-010, OXD-018 +**Amends:** `docs/informations/proposals.md` §4.1 + +## Context + +The spec sketched `Token` with a `duration_ms: u32` field: the display time baked in at parse +time. That is the obvious shape, and it is wrong for one specific reason. + +Reading speed is adjustable *during* playback (`J`/`K`, spec §7.2). If duration is a stored field, +every keypress forces one of two bad options: + +1. **Re-pace the whole document.** A 5 500-token file gets rewritten on every 25 WPM nudge, and + the reader can hold the key down. +2. **Leave old durations stale.** The tokens already read keep their timing, which is correct, but + so do the tokens *ahead* of the cursor, which is not — the speed change would not take effect + until the document was reloaded. + +There is a second, quieter problem: a stored duration makes `Token` untestable without deciding a +speed first, and it pushes a playback concern into the parser. + +## Decision + +`Token` stores the WPM-independent parts of the timing model: + +```rust +pub struct Token { + pub weight: f32, // length x punctuation x kind + pub pause_ms: u32, // structural pause, not scaled by speed + // ... +} + +impl Token { + pub fn duration_ms(&self, wpm: u16) -> u32 { + let base = 60_000.0 / f32::from(wpm.max(1)); + (base * self.weight) as u32 + self.pause_ms + } +} +``` + +Duration is derived at the moment the token is shown, from the speed in force at that moment. + +## Consequences + +**Good.** A speed change is `self.wpm = new` and nothing else: naturally non-retroactive, O(1), +and correct while the key is held down. Structural pauses stay absolute, which is what we want — +the beat after a paragraph should not shrink just because the reader sped up. `Player` needs no +clock to be tested, which is what lets OXD-018 be verified with simulated time and reused as-is +under WebAssembly (OXD-080). + +**Cost.** Callers that want a duration must supply a speed. In practice only two places do: +`Player::current_duration_ms` and `pacing::effective_wpm`. + +**Watch out.** `weight` is an `f32` multiplier chain. Do not accumulate it across tokens, and do +not compare weights for equality across pacing modes. + +## Alternatives rejected + +- **Store duration and re-pace on change.** Rejected on cost: O(n) per keypress, and the parser + would need to know the speed. +- **Store duration at a reference WPM and scale on read.** Equivalent to this decision but loses + precision twice and hides the structural pause inside a scalable number, so a fast reader would + get shortened paragraph breaks. \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..7b79ad6 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +max_width = 100 +use_small_heuristics = "Max" +edition = "2024" \ No newline at end of file diff --git a/testdata/README.md b/testdata/README.md new file mode 100644 index 0000000..c1f3eb5 --- /dev/null +++ b/testdata/README.md @@ -0,0 +1,35 @@ +# Test corpus + +Fixtures shared by the engine tests (OXD-004). Each file exists to pin down one specific +behaviour, so please do not "tidy" the contents — the odd characters are the point. + +| File | What it pins down | +|---|---| +| `simple.txt` | Baseline ASCII: paragraph splitting on blank lines | +| `vietnamese_nfc.txt` | Vietnamese in composed form (NFC) | +| `vietnamese_nfd.txt` | **Byte-different, semantically identical** to the NFC file. The two must produce identical token streams | +| `cjk.txt` | Full-width characters: 1 grapheme occupies 2 terminal columns | +| `emoji_zwj.txt` | ZWJ family sequence and a regional-indicator flag; each must count as one grapheme | +| `mixed_width.txt` | ASCII, CJK and emoji on a single line | +| `abbreviations.txt` | `Fig.`, `et al.`, `i.e.`, `e.g.`, `vs.`, `No.`, `v1.2.3`, `std::fmt`, `Dr.` — none may trigger a sentence-end pause, and the final real full stop must still be detected | +| `long_words.txt` | URLs, a git hash, and words longer than the reader frame | +| `rfc_style.txt` | Hard wrapping at column ~64. Must rejoin into 2 paragraphs, not 5 | +| `sample.md` | Markdown covering headings, lists, code fences, tables, quotes, links and autolinks | +| `utf16_bom.txt` | UTF-16LE with a byte order mark | +| `latin1.txt` | Windows-1252 bytes that are invalid UTF-8 | +| `empty.txt` | Zero bytes: must not panic anywhere | + +## Regenerating the Unicode fixtures + +`vietnamese_nfd.txt` is derived from `vietnamese_nfc.txt`: + +```sh +python3 -c " +import unicodedata, pathlib +nfc = pathlib.Path('testdata/vietnamese_nfc.txt').read_text() +pathlib.Path('testdata/vietnamese_nfd.txt').write_text(unicodedata.normalize('NFD', nfc)) +" +``` + +The `tests/corpus.rs` suite asserts that the two files really are byte-different before comparing +their token streams, so a regeneration mistake fails loudly rather than making the test vacuous. \ No newline at end of file diff --git a/testdata/abbreviations.txt b/testdata/abbreviations.txt new file mode 100644 index 0000000..56ac8a3 --- /dev/null +++ b/testdata/abbreviations.txt @@ -0,0 +1,5 @@ +See Fig. 3 for the layout used by Rayner et al. in their study. +That is, i.e. the regression rate, e.g. around 15 percent. +Compare vs. the control group, No. 5 in the table. +Release v1.2.3 changed std::fmt behaviour, approx. 20 percent faster. +Dr. Rayner published this. The next sentence really does start here. diff --git a/testdata/cjk.txt b/testdata/cjk.txt new file mode 100644 index 0000000..76222fd --- /dev/null +++ b/testdata/cjk.txt @@ -0,0 +1,2 @@ +日本語のテキスト +中文测试 diff --git a/testdata/emoji_zwj.txt b/testdata/emoji_zwj.txt new file mode 100644 index 0000000..4a9d037 --- /dev/null +++ b/testdata/emoji_zwj.txt @@ -0,0 +1 @@ +👨‍👩‍👧 family 🇻🇳 flag diff --git a/testdata/empty.txt b/testdata/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/testdata/latin1.txt b/testdata/latin1.txt new file mode 100644 index 0000000..5c618bd --- /dev/null +++ b/testdata/latin1.txt @@ -0,0 +1 @@ +café résumé naïve diff --git a/testdata/long_words.txt b/testdata/long_words.txt new file mode 100644 index 0000000..bae50a8 --- /dev/null +++ b/testdata/long_words.txt @@ -0,0 +1,4 @@ +Visit https://example.com/a/very/long/path/that/goes/on for details. +Commit 6821254aa1b2c3d4e5f60718293a4b5c6d7e8f90 is the one. +The function reconstruct_document_from_token_stream is long. +Pneumonoultramicroscopicsilicovolcanoconiosis is longer still. diff --git a/testdata/mixed_width.txt b/testdata/mixed_width.txt new file mode 100644 index 0000000..c51b843 --- /dev/null +++ b/testdata/mixed_width.txt @@ -0,0 +1 @@ +ascii 日本語 👍 mixed widths on one line diff --git a/testdata/rfc_style.txt b/testdata/rfc_style.txt new file mode 100644 index 0000000..b2e2a97 --- /dev/null +++ b/testdata/rfc_style.txt @@ -0,0 +1,5 @@ +This document specifies an Internet standards track protocol for +the Internet community, and requests discussion and suggestions +for improvements. + +Distribution of this memo is unlimited. diff --git a/testdata/sample.md b/testdata/sample.md new file mode 100644 index 0000000..7c8c9ef --- /dev/null +++ b/testdata/sample.md @@ -0,0 +1,28 @@ +# Sample Document + +A paragraph with [a link](https://example.com/docs), some `inline code`, and **bold** text. +It also mentions an autolink: . + +## Features + +- Blazing fast +- No eye movement required +- Handles Markdown + +### Nested heading + +> A block quote that spans +> two source lines. + +```rust +fn main() { + println!("hello"); +} +``` + +| Column | Value | +|---|---| +| alpha | 1 | +| beta | 2 | + +Final paragraph. It ends the document, e.g. after Fig. 3 and Dr. Rayner's work. \ No newline at end of file diff --git a/testdata/simple.txt b/testdata/simple.txt new file mode 100644 index 0000000..c0e87e8 --- /dev/null +++ b/testdata/simple.txt @@ -0,0 +1,4 @@ +Hello world. +This is a simple ASCII document. + +Second paragraph here. diff --git a/testdata/utf16_bom.txt b/testdata/utf16_bom.txt new file mode 100644 index 0000000000000000000000000000000000000000..1d30e99e406ffceb1c25079643f05c298327e19d GIT binary patch literal 36 qcmezWFM=VHA&)_UA(QFkQK@h!r;cB%V5Z0#-PW*#Q*@batA^H literal 0 HcmV?d00001 diff --git a/testdata/vietnamese_nfc.txt b/testdata/vietnamese_nfc.txt new file mode 100644 index 0000000..0806da8 --- /dev/null +++ b/testdata/vietnamese_nfc.txt @@ -0,0 +1,2 @@ +Tiếng Việt có dấu thanh đầy đủ. +Nghiên cứu khoa học. diff --git a/testdata/vietnamese_nfd.txt b/testdata/vietnamese_nfd.txt new file mode 100644 index 0000000..c8c829a --- /dev/null +++ b/testdata/vietnamese_nfd.txt @@ -0,0 +1,2 @@ +Tiếng Việt có dấu thanh đầy đủ. +Nghiên cứu khoa học.