diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..940856d39 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Preserve the verified upstream license bytes on Windows checkouts. +docs/rust-engine/third-party/*.LICENSE text eol=lf diff --git a/.github/workflows/oxytype-ci.yml b/.github/workflows/oxytype-ci.yml index 75ee28b8f..f903bc03b 100644 --- a/.github/workflows/oxytype-ci.yml +++ b/.github/workflows/oxytype-ci.yml @@ -74,7 +74,7 @@ jobs: # Workspace packages bundled into the backend and frontend. runtime-packages: - 'packages/{challenges,contracts,funbox,schemas,typing-engine,typing-fixtures,util,web-shared}/**' - - 'crates/**' + - 'crates/{typing-engine,typing-engine-wasm}/**' - 'Cargo.{toml,lock}' - 'rust-toolchain.toml' - 'scripts/{engine,prepare-engine}.ts' @@ -84,7 +84,7 @@ jobs: - 'packages/**' - '!**/*.md' engine: - - 'crates/**' + - 'crates/{typing-engine,typing-engine-wasm}/**' - 'Cargo.{toml,lock}' - 'rust-toolchain.toml' - 'scripts/{engine,prepare-engine}.ts' diff --git a/.github/workflows/terminal-release.yml b/.github/workflows/terminal-release.yml new file mode 100644 index 000000000..a92336c0a --- /dev/null +++ b/.github/workflows/terminal-release.yml @@ -0,0 +1,180 @@ +name: Terminal binaries + +on: + pull_request: + branches: [main, "stack/**", "t3/rust-engine-migration"] + paths: + - "crates/typing-engine/**" + - "crates/oxytype-tui/**" + - "Cargo.*" + - "rust-toolchain.toml" + - ".cargo/**" + - ".node-version" + - "package.json" + - "pnpm-workspace.yaml" + - "pnpm-lock.yaml" + - "scripts/terminal*" + - "docs/rust-engine/terminal*" + - "docs/rust-engine/third-party/**" + - ".github/workflows/terminal-release.yml" + push: + tags: ["terminal-v*"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: terminal-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + +env: + OXYTYPE_BUILD_REV: ${{ github.sha }} + CARGO_TERM_COLOR: always + +jobs: + binaries: + name: native (${{ matrix.target }}) + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-24.04, target: x86_64-unknown-linux-gnu } + - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu } + - { os: macos-15-intel, target: x86_64-apple-darwin } + - { os: macos-15, target: aarch64-apple-darwin } + env: + MACOSX_DEPLOYMENT_TARGET: "15.0" + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: voidzero-dev/setup-vp@v1.21.2 + with: + node-version-file: .node-version + run-install: false + - uses: actions/cache@v5.0.3 + with: + path: | + ~/.cargo/registry + target + key: terminal-${{ matrix.target }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} + - name: Native formatting + run: vp run engine:fmt + - name: Archive portability regressions + run: vp run terminal:test:release-tools + - name: Native lint + run: vp exec cargo clippy --locked -p typing-engine -p oxytype-tui --all-targets -- -D warnings + - name: Shared engine fixtures + run: vp exec cargo test --locked -p typing-engine + - name: Native terminal input and durable history + run: vp run terminal:test + - name: POSIX terminal lifecycle and persistence + run: vp run terminal:test:pty + - name: Build licensed versioned archive + run: vp run terminal:package -- --target ${{ matrix.target }} + - name: Verify extracted binary without runtime tools or checkout files + run: vp run terminal:archive-smoke -- --target ${{ matrix.target }} + - name: Upload candidate archives and checksums + uses: actions/upload-artifact@v7.0.2 + with: + name: terminal-${{ matrix.target }} + path: | + target/terminal-release/*.tar.gz + target/terminal-release/*.sha256 + retention-days: 14 + if-no-files-found: error + + emulators: + name: Linux emulator (${{ matrix.emulator }}) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + emulator: [kitty, alacritty] + env: + LIBGL_ALWAYS_SOFTWARE: "true" + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: voidzero-dev/setup-vp@v1.21.2 + with: + node-version-file: .node-version + run-install: false + - uses: actions/cache@v5.0.3 + with: + path: | + ~/.cargo/registry + target + key: terminal-emulator-${{ matrix.emulator }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} + - name: Install distribution emulator and isolated display tools + run: | + vp exec sudo apt-get update + vp exec sudo apt-get install --no-install-recommends -y ${{ matrix.emulator }} xvfb xdotool libxkbcommon-x11-0 fonts-dejavu-core fonts-noto-cjk fonts-noto-color-emoji + - name: Real key encoding, Unicode, resize, history and cleanup + run: vp run terminal:test:emulator -- --emulator ${{ matrix.emulator }} --command /usr/bin/${{ matrix.emulator }} --evidence target/terminal-emulator-evidence + - name: Retain exact emulator versions and test traces + if: always() + uses: actions/upload-artifact@v7.0.2 + with: + name: emulator-${{ matrix.emulator }} + path: target/terminal-emulator-evidence + retention-days: 14 + if-no-files-found: warn + + source: + name: corresponding source + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: voidzero-dev/setup-vp@v1.21.2 + with: + node-version-file: .node-version + run-install: false + - name: Vendor and prove an offline source build + run: vp run terminal:source + - uses: actions/upload-artifact@v7.0.2 + with: + name: terminal-source + path: | + target/terminal-release/*-source.tar.gz + target/terminal-release/*-source.tar.gz.sha256 + retention-days: 14 + if-no-files-found: error + + publish: + name: authorized tagged release + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'voltcrash/oxytype' + needs: [binaries, emulators, source] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: voidzero-dev/setup-vp@v1.21.2 + with: + node-version-file: .node-version + run-install: false + - name: Require recorded real terminal verification + run: vp run terminal:release-gate + - uses: actions/download-artifact@v8.0.2 + with: + pattern: terminal-* + path: release + merge-multiple: true + - name: Verify all platform/source checksums + run: vp exec python3 scripts/terminal_release.py checksums --archive release + - name: Publish the explicitly pushed terminal tag + env: + GH_TOKEN: ${{ github.token }} + run: vp exec gh release create "$GITHUB_REF_NAME" release/*.tar.gz release/SHA256SUMS --verify-tag --title "Oxytype terminal ${GITHUB_REF_NAME#terminal-v}" --notes-file docs/rust-engine/terminal-release-notes.md diff --git a/.gitignore b/.gitignore index 2f6a763df..4e10f7e00 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,6 @@ frontend/playwright-report/ # Rust / generated Wasm and type bindings /target/ /packages/typing-engine/generated/ + +# Python terminal verification/release tooling +__pycache__/ diff --git a/Cargo.lock b/Cargo.lock index 2e6b37e02..b44df931f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,80 +2,1058 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook 0.3.18", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" +dependencies = [ + "hashbrown 0.17.1", + "portable-atomic", + "thiserror 2.0.21", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +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 = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "oxytype-tui" +version = "0.1.0" +dependencies = [ + "clap", + "crossterm", + "directories", + "nix 0.30.1", + "portable-pty", + "ratatui", + "rusqlite", + "serde", + "serde_json", + "sha2", + "signal-hook 0.4.5", + "tempfile", + "typing-engine", + "unicode-segmentation", + "unicode-width", + "uuid", + "vt100", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] [[package]] -name = "cfg-if" -version = "1.0.5" +name = "powerfmt" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] -name = "equivalent" -version = "1.0.3" +name = "proc-macro2" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] [[package]] -name = "foldhash" -version = "0.2.0" +name = "quote" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] [[package]] -name = "hashbrown" -version = "0.17.1" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "indexmap" -version = "2.14.2" +name = "ratatui" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ - "equivalent", - "hashbrown", + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "ratatui-core" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] [[package]] -name = "memchr" -version = "2.8.3" +name = "ratatui-crossterm" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] [[package]] -name = "once_cell" -version = "1.21.4" +name = "ratatui-widgets" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "unicode-ident", + "bitflags 2.13.2", ] [[package]] -name = "quote" -version = "1.0.47" +name = "redox_users" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "proc-macro2", + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.21", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", ] [[package]] @@ -84,6 +1062,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -129,6 +1125,148 @@ dependencies = [ "zmij", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook 0.3.18", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "syn" version = "2.0.119" @@ -151,6 +1289,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", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -160,13 +1311,33 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -180,6 +1351,27 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "ts-rs" version = "12.0.1" @@ -187,7 +1379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" dependencies = [ "serde_json", - "thiserror", + "thiserror 2.0.21", "ts-rs-macros", ] @@ -203,6 +1395,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "typing-engine" version = "0.1.0" @@ -228,6 +1426,85 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -273,6 +1550,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -282,6 +1575,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -297,6 +1596,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index c1fd99039..0d649a9c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,9 @@ [workspace] -members = ["crates/typing-engine", "crates/typing-engine-wasm"] +members = [ + "crates/typing-engine", + "crates/typing-engine-wasm", + "crates/oxytype-tui", +] resolver = "3" [workspace.package] diff --git a/README.md b/README.md index f7bb79d4d..9bb98f778 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,20 @@ behavior against immutable original references. `vp run engine:lint` and `vp run engine:fmt` check Rust. Reference provenance and measured budgets are in [migration progress](./docs/rust-engine/progress.md). +## Offline terminal + +The native [Ratatui app](./docs/rust-engine/terminal.md) uses the same engine +on Linux/macOS without Wasm. It includes English words/quotes, five typing modes, settings, +durable local history and JSON export. No account, sync or first-run download. +`vp run terminal:dev` runs it; `vp run terminal:test` and +`vp run terminal:test:pty` check native behavior and terminal lifecycle. +`vp run terminal:test:emulator -- --emulator kitty --command /usr/bin/kitty` +checks actual Kitty keyboard input in a private Linux display; Ghostty and +Alacritty are also supported by the harness. See the terminal docs for tools. +`vp run terminal:package` prepares a licensed binary archive. Installed users +need no development runtime. See [release verification/status](./docs/rust-engine/terminal-release.md) +for tested platforms and pending real-terminal checks; no release published yet. + ## Contributing See [CONTRIBUTING.md](./docs/CONTRIBUTING.md) and the [code of conduct](./docs/CODE_OF_CONDUCT.md). Report bugs in the [Oxytype repository](https://github.com/voltcrash/oxytype/issues). diff --git a/crates/oxytype-tui/Cargo.toml b/crates/oxytype-tui/Cargo.toml new file mode 100644 index 000000000..624a8a2af --- /dev/null +++ b/crates/oxytype-tui/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "oxytype-tui" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Offline Oxytype terminal client using the shared Rust typing engine" + +[[bin]] +name = "oxytype" +path = "src/main.rs" + +[dependencies] +typing-engine = { path = "../typing-engine" } +serde.workspace = true +serde_json.workspace = true +ratatui = { version = "=0.30.2", default-features = false, features = [ + "crossterm", + "layout-cache", +] } +crossterm = "=0.29.0" +clap = { version = "4.5", features = ["derive", "env", "string"] } +signal-hook = "=0.4.5" +unicode-segmentation = "=1.13.3" +unicode-width = "=0.2.2" +directories = "=6.0.0" +rusqlite = { version = "=0.40.2", features = ["bundled"] } +sha2 = "0.10.9" +uuid = { version = "1", features = ["v4"] } +tempfile = "3" + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.30", features = ["signal", "process"] } + +[dev-dependencies] +portable-pty = "=0.9.0" +vt100 = "=0.16.2" + +[lints] +workspace = true diff --git a/crates/oxytype-tui/assets/ATTRIBUTION.md b/crates/oxytype-tui/assets/ATTRIBUTION.md new file mode 100644 index 000000000..f5eb555d4 --- /dev/null +++ b/crates/oxytype-tui/assets/ATTRIBUTION.md @@ -0,0 +1,12 @@ +# Bundled offline content + +English words: exact copy of `frontend/static/languages/english.json` in the +Oxytype repository, derived from Monkeytype. Distributed under this repository's +GPL-3.0-only license; see the root LICENSE and upstream attribution. + +Original Oxytype practice prose in quotes.json was written for this terminal app, +licensed GPL-3.0-only. It is not attributed to historical authors. + +English source SHA-256: `9eff3c4563d4409f4c18d6bd35963654570c72d688b4e01453eac916d614be1e`. +Both assets are compiled into the executable; no download or source checkout +is needed at runtime. diff --git a/crates/oxytype-tui/assets/english.json b/crates/oxytype-tui/assets/english.json new file mode 100644 index 000000000..b5b43a667 --- /dev/null +++ b/crates/oxytype-tui/assets/english.json @@ -0,0 +1,207 @@ +{ + "name": "english", + "noLazyMode": true, + "orderedByFrequency": true, + "words": [ + "the", + "be", + "of", + "and", + "a", + "to", + "in", + "he", + "have", + "it", + "that", + "for", + "they", + "I", + "with", + "as", + "not", + "on", + "she", + "at", + "by", + "this", + "we", + "you", + "do", + "but", + "from", + "or", + "which", + "one", + "would", + "all", + "will", + "there", + "say", + "who", + "make", + "when", + "can", + "more", + "if", + "no", + "man", + "out", + "other", + "so", + "what", + "time", + "up", + "go", + "about", + "than", + "into", + "could", + "state", + "only", + "new", + "year", + "some", + "take", + "come", + "these", + "know", + "see", + "use", + "get", + "like", + "then", + "first", + "any", + "work", + "now", + "may", + "such", + "give", + "over", + "think", + "most", + "even", + "find", + "day", + "also", + "after", + "way", + "many", + "must", + "look", + "before", + "great", + "back", + "through", + "long", + "where", + "much", + "should", + "well", + "people", + "down", + "own", + "just", + "because", + "good", + "each", + "those", + "feel", + "seem", + "how", + "high", + "too", + "place", + "little", + "world", + "very", + "still", + "nation", + "hand", + "old", + "life", + "tell", + "write", + "become", + "here", + "show", + "house", + "both", + "between", + "need", + "mean", + "call", + "develop", + "under", + "last", + "right", + "move", + "thing", + "general", + "school", + "never", + "same", + "another", + "begin", + "while", + "number", + "part", + "turn", + "real", + "leave", + "might", + "want", + "point", + "form", + "off", + "child", + "few", + "small", + "since", + "against", + "ask", + "late", + "home", + "interest", + "large", + "person", + "end", + "open", + "public", + "follow", + "during", + "present", + "without", + "again", + "hold", + "govern", + "around", + "possible", + "head", + "consider", + "word", + "program", + "problem", + "however", + "lead", + "system", + "set", + "order", + "eye", + "plan", + "run", + "keep", + "face", + "fact", + "group", + "play", + "stand", + "increase", + "early", + "course", + "change", + "help", + "line" + ] +} diff --git a/crates/oxytype-tui/assets/quotes.json b/crates/oxytype-tui/assets/quotes.json new file mode 100644 index 000000000..8ab35b24f --- /dev/null +++ b/crates/oxytype-tui/assets/quotes.json @@ -0,0 +1,42 @@ +[ + { + "id": 100001, + "text": "A quiet room leaves enough space to hear the rhythm of the keys.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100002, + "text": "The last light crossed the desk as another page began to fill.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100003, + "text": "Every morning, the station clock met the same patient crowd. Some carried letters, others carried lunch, and one carried a bright blue umbrella beneath a clear sky.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100004, + "text": "A small garden can teach a useful kind of patience. Water the soil, watch the changing light, and leave room for the roots to find their own way through the dark.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100005, + "text": "The map on the table showed a narrow road beside a river. We followed it slowly, stopping whenever the trees opened onto a view of the water. By evening, the road had become a path, and the path had brought us to a bridge we could hear long before we could see it.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100006, + "text": "When the workshop opened, each tool already had a place. The new apprentice spent a week learning those places before making anything at all. Years later, she would remember that week whenever a difficult job became easier simply because everything she needed was within reach.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100007, + "text": "On the first cool evening of autumn, the neighbors brought their chairs into the courtyard. Someone carried a pot of tea, someone else brought a bowl of apples, and a child arranged fallen leaves along the edge of the fountain. For a while, the conversation moved from the weather to the old shops on the corner and then to the names of people who had lived there before. Nothing remarkable happened. Yet when the lights came on in the windows above them, nobody was in a hurry to go inside. The courtyard had become a room that belonged to everyone.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100008, + "text": "The library kept a box of unfinished drawings beside the window. Visitors could add a line, a color, or a whole new corner of the scene before returning it to the box. Over the years, a sketch of a single house grew into a street, then a town, and finally a landscape with fields beyond the last row of roofs. No one knew who had drawn the first house. No one needed to know. Each new visitor found a little space that had been left open, made a choice, and passed the drawing along to someone who would see a different possibility.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + } +] diff --git a/crates/oxytype-tui/build.rs b/crates/oxytype-tui/build.rs new file mode 100644 index 000000000..7e5a6577e --- /dev/null +++ b/crates/oxytype-tui/build.rs @@ -0,0 +1,31 @@ +use std::process::Command; +fn git(args: &[&str]) -> Option { + let result = Command::new("git").args(args).output().ok()?; + result + .status + .success() + .then(|| String::from_utf8_lossy(&result.stdout).trim().to_string()) +} +fn main() { + println!("cargo:rerun-if-env-changed=OXYTYPE_BUILD_REV"); + if let Some(path) = git(&["rev-parse", "--git-path", "HEAD"]) { + println!("cargo:rerun-if-changed={path}"); + } + if let Some(branch) = git(&["symbolic-ref", "-q", "HEAD"]) + && let Some(path) = git(&["rev-parse", "--git-path", &branch]) + { + println!("cargo:rerun-if-changed={path}"); + } + let revision = std::env::var("OXYTYPE_BUILD_REV") + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| git(&["rev-parse", "HEAD"])) + .unwrap_or_else(|| "unknown-source-revision".into()); + assert!( + revision + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-'), + "Invalid OXYTYPE_BUILD_REV" + ); + println!("cargo:rustc-env=OXYTYPE_BUILD_REV={revision}"); +} diff --git a/crates/oxytype-tui/src/content.rs b/crates/oxytype-tui/src/content.rs new file mode 100644 index 000000000..247370ec2 --- /dev/null +++ b/crates/oxytype-tui/src/content.rs @@ -0,0 +1,179 @@ +//! Local UTF-8 asset loading only. Generation and normalization belong to core. +use crate::Result; +use serde::Deserialize; +use std::{fs::File, io::Read, path::Path}; +use typing_engine::{ + Text, + generation::Language, + quotes::{Quote, QuoteAsset}, +}; + +pub const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; +/// Decode text-file/clipboard line endings as logical terminal Enter characters. +pub fn line_endings(value: &str) -> String { + value.replace("\r\n", "\n").replace('\r', "\n") +} +pub fn read_utf8(path: &Path) -> Result { + let mut bytes = vec![]; + File::open(path)? + .take(MAX_FILE_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_FILE_BYTES { + return Err("Local file exceeds 8 MiB".into()); + } + Ok(String::from_utf8(bytes).map_err(|_| "Local file must be valid UTF-8")?) +} +pub fn safe_text(value: &str) -> Result<()> { + if value.chars().any(|c| { + (c.is_control() && c != '\t' && c != '\n') + || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') + }) { + return Err( + "Content contains terminal controls or directional overrides (only Tab/LF allowed)" + .into(), + ); + } + Ok(()) +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalLanguage { + name: String, + words: Vec, + #[serde(default)] + bcp47: Option, + #[serde(default)] + right_to_left: Option, + #[serde(default)] + joining_script: bool, + #[serde(default)] + original_punctuation: bool, + #[serde(default)] + no_lazy_mode: bool, + #[serde(default)] + additional_accents: Vec<(String, String)>, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LocalQuote { + id: u32, + text: String, + source: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Pack { + format_version: u32, + license: String, + attribution: String, + language: LocalLanguage, + #[serde(default)] + quotes: Vec, +} +#[derive(Clone)] +pub struct OfflineContent { + pub language: Language, + pub quotes: QuoteAsset, + pub attribution: String, +} +impl OfflineContent { + pub fn bundled() -> Result { + Self::convert( + serde_json::from_str(include_str!("../assets/english.json"))?, + serde_json::from_str(include_str!("../assets/quotes.json"))?, + "Oxytype / Monkeytype English words; original Oxytype prose; GPL-3.0-only".into(), + ) + } + pub fn load(path: &Path) -> Result { + Self::parse(&read_utf8(path)?) + } + pub fn parse(json: &str) -> Result { + if json.len() as u64 > MAX_FILE_BYTES { + return Err("Local pack exceeds 8 MiB".into()); + } + let pack: Pack = serde_json::from_str(json)?; + if pack.format_version != 1 { + return Err("Unsupported local content pack version (expected 1)".into()); + } + if pack.license.trim().is_empty() || pack.attribution.trim().is_empty() { + return Err("Content pack requires license and attribution".into()); + } + safe_text(&pack.license)?; + safe_text(&pack.attribution)?; + Self::convert( + pack.language, + pack.quotes, + format!("{}; {}", pack.attribution, pack.license), + ) + } + fn convert(raw: LocalLanguage, quotes: Vec, attribution: String) -> Result { + if raw.words.is_empty() || raw.words.iter().any(|w| w.trim().is_empty()) { + return Err("Language requires nonempty words".into()); + } + for word in &raw.words { + safe_text(word)?; + } + for (a, b) in &raw.additional_accents { + safe_text(a)?; + safe_text(b)?; + } + if raw.name.is_empty() + || raw.name.len() > 128 + || !raw + .name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'_') + { + return Err("Invalid content language identifier".into()); + } + if let Some(bcp47) = &raw.bcp47 { + safe_text(bcp47)?; + } + let language = Language { + name: raw.name.clone(), + bcp47: raw.bcp47, + words: raw.words.iter().map(|w| Text::from(w.as_str())).collect(), + right_to_left: raw.right_to_left, + joining_script: raw.joining_script, + original_punctuation: raw.original_punctuation, + no_lazy_mode: raw.no_lazy_mode, + additional_accents: raw + .additional_accents + .into_iter() + .map(|(a, b)| (Text::from(a.as_str()), Text::from(b.as_str()))) + .collect(), + }; + typing_engine::validation::language(&language)?; + let mut ids = std::collections::BTreeSet::new(); + let mut converted = vec![]; + for quote in quotes { + safe_text("e.text)?; + safe_text("e.source)?; + if quote.text.trim().is_empty() || !ids.insert(quote.id) { + return Err("Quotes require nonempty text and unique IDs".into()); + } + let text = Text::from(quote.text.as_str()); + converted.push(Quote { + length: text.len(), + text, + british_text: None, + source: quote.source, + id: quote.id, + group: 0, + language: raw.name.clone(), + text_split: vec![], + }); + } + let quotes = QuoteAsset { + language: raw.name, + groups: vec![(0, 124), (125, 249), (250, 499), (500, 1_000_000)], + quotes: converted, + }; + typing_engine::validation::quote_asset("es)?; + Ok(Self { + language, + quotes, + attribution, + }) + } +} diff --git a/crates/oxytype-tui/src/engine.rs b/crates/oxytype-tui/src/engine.rs new file mode 100644 index 000000000..e340d48ec --- /dev/null +++ b/crates/oxytype-tui/src/engine.rs @@ -0,0 +1,285 @@ +//! Terminal observations and display projection. All typing rules stay in core. +use crate::Result; +use typing_engine::{ + Command, Config, Session, Text, + generation::{Content, CustomText, Language, Prepare}, + random::Random, + session::{LiveMetrics, State}, + stats::{self, Log, Summary}, +}; + +pub struct EngineHost { + pub config: Config, + pub words: Vec, + pub inputs: Vec, + pub visual_input: Option, + pub result: Option, + pub result_error: Option, + pub seed: u32, + pub content_identity: String, + pub repeated: bool, + content: Content, + session: Option, + language: Language, + custom: Option, + quotes: Option, +} +impl EngineHost { + pub fn new( + config: Config, + language: Language, + custom: Option, + seed: u32, + ) -> Result { + Self::with_assets(config, language, custom, seed, None) + } + pub fn offline( + config: Config, + content: crate::content::OfflineContent, + custom: Option, + seed: u32, + ) -> Result { + crate::options::validate(&config, &content, custom.as_ref())?; + Self::with_assets(config, content.language, custom, seed, Some(content.quotes)) + } + fn with_assets( + config: Config, + language: Language, + custom: Option, + seed: u32, + quotes: Option, + ) -> Result { + let mut host = Self { + config, + language, + custom, + quotes, + seed, + content_identity: String::new(), + repeated: false, + content: Content::default(), + session: None, + words: vec![], + inputs: vec![], + visual_input: None, + result: None, + result_error: None, + }; + host.content_identity = crate::storage::hash(&( + &host.language.words, + &host.quotes.as_ref().map(|q| &q.quotes), + &host.custom.as_ref().map(|c| &c.text), + ))?; + host.restart(false, 0.0, 0.0)?; + Ok(host) + } + pub fn session(&self) -> &Session { + self.session.as_ref().expect("Prepared terminal session") + } + pub fn state(&self) -> State { + self.session().state() + } + pub fn live(&self, now: f64) -> Result { + Ok(self.session().live_stats(now)?) + } + pub fn log(&self) -> Result { + Ok(serde_json::from_value(self.session().log())?) + } + pub fn custom_limit(&self) -> Option { + self.custom.as_ref().map(|c| typing_engine::results::Limit { + mode: c.limit.mode.clone(), + value: c.limit.value, + }) + } + pub fn restart(&mut self, repeat: bool, now: f64, date: f64) -> Result<()> { + let mut same = repeat; + if let Some(mut session) = self.session.take() { + let policy = session.restart_policy(typing_engine::restart::RestartOptions { + with_same_wordset: repeat, + ..Default::default() + }); + if !policy.allowed { + self.session = Some(session); + return Err("Restart unavailable during this test".into()); + } + same = policy.with_same_wordset; + if session.state().active { + session.apply(Command::Abandon { now, date })?; + } + self.content.recycle_session(session); + if !same { + self.seed = self.seed.wrapping_add(1); + } + } + self.content.prepare(Prepare { + config: self.config.clone(), + language: self.language.clone(), + custom: self.custom.clone(), + random: Random::seeded(self.seed), + quotes: self.quotes.clone(), + polyglot: vec![], + selected_quote_id: 0, + favorite_quote_ids: vec![], + sections: vec![], + })?; + let session = self.content.start_session(same)?; + self.repeated = same; + self.words = session.snapshot().words; + for word in &self.words { + terminal_text(word)?; + } + self.inputs.clear(); + self.visual_input = None; + self.result = None; + self.result_error = None; + self.session = Some(session); + Ok(()) + } + pub fn command(&mut self, command: Command) -> Result<()> { + let update = self + .session + .as_mut() + .expect("Prepared terminal session") + .apply(command)?; + self.visual_input = update.visual_input_override; + for change in update.words { + terminal_text(&change.word)?; + self.words + .resize(self.words.len().max(change.index + 1), Text::default()); + self.words[change.index] = change.word; + } + // Mechanical transport projection; no input reconstruction or score formulas. + for event in update.events.iter().filter(|event| event.kind == "input") { + let index = event.data["wordIndex"] + .as_u64() + .expect("Native input index") as usize; + self.inputs + .resize(self.inputs.len().max(index + 1), Text::default()); + self.inputs[index] = stats::text(&event.data["inputValue"]); + if event.data["clearedNextWord"] == true + && let Some(next) = self.inputs.get_mut(index + 1) + { + *next = Text::default(); + } + } + if update.state.ended && self.result.is_none() && self.result_error.is_none() { + match stats::typed_summary(&self.log()?) { + Ok(summary) => self.result = Some(summary), + Err(error) => self.result_error = Some(error.to_string()), + } + } + Ok(()) + } + pub fn insert(&mut self, text: &str, now: f64, date: f64) -> Result<()> { + self.command(Command::Insert { + data: Text::from(text), + now, + date, + correct_shift_used: None, + automatic: None, + is_composition_ending: None, + layout: None, + }) + } +} + +/// Explicit synthetic smoke path: never records invented observations in history. +pub fn smoke() -> Result<()> { + let mut host = EngineHost::new( + Config { + mode: "words".into(), + words: 2, + ..Default::default() + }, + Language { + name: "english".into(), + words: ["one", "two", "three", "four"] + .into_iter() + .map(Text::from) + .collect(), + ..Default::default() + }, + None, + 42, + )?; + for index in 0..2 { + let word = host.words[index].display_lossy(); + host.insert( + &word, + 100.0 + index as f64 * 1000.0, + 1_000_100.0 + index as f64 * 1000.0, + )?; + } + if !host.state().ended || host.result.is_none() { + return Err("Native smoke session did not complete".into()); + } + let log = host.log()?; + if log + .events + .iter() + .any(|event| event.kind == "keydown" || event.kind == "keyup") + { + return Err("Smoke inserted unobserved keyboard telemetry".into()); + } + let modes = ["time", "words", "quote", "custom", "zen"]; + for mode in modes { + let custom = if mode == "custom" { + Some(crate::options::custom_text_auto( + "alpha beta", + "repeat", + "word", + None, + )?) + } else { + None + }; + let mut host = EngineHost::offline( + Config { + mode: mode.into(), + words: 2, + time: 1.0, + ..Default::default() + }, + crate::content::OfflineContent::bundled()?, + custom, + 42, + )?; + if mode == "zen" { + host.insert("alpha ", 100.0, 1000.0)?; + host.command(Command::Bailout { + now: 1100.0, + date: 2000.0, + })?; + } else if mode == "time" { + let word = host.words[0].display_lossy(); + host.insert(&word, 100.0, 1000.0)?; + host.command(Command::Advance { + now: 1200.0, + date: 2100.0, + })?; + } else { + for i in 0..1000 { + if host.state().ended { + break; + } + let word = host.words[i].display_lossy(); + host.insert(&word, 100.0 + i as f64 * 1000.0, 1000.0 + i as f64 * 1000.0)?; + } + } + if !host.state().ended || host.result.is_none() { + return Err(format!("Bundled offline {mode} smoke did not complete").into()); + } + } + println!( + "{}", + serde_json::json!({"syntheticSmoke":true,"completed":true,"bundledModes":modes,"engineVersion":typing_engine::BEHAVIOR_VERSION}) + ); + Ok(()) +} + +fn terminal_text(text: &Text) -> Result<()> { + let text = String::from_utf16(&text.0).map_err( + |_| "Generated content contains isolated UTF-16 surrogates; unavailable in terminal input", + )?; + crate::content::safe_text(&text) +} diff --git a/crates/oxytype-tui/src/history.rs b/crates/oxytype-tui/src/history.rs new file mode 100644 index 000000000..966b7dfd8 --- /dev/null +++ b/crates/oxytype-tui/src/history.rs @@ -0,0 +1,119 @@ +//! Historical summaries are displayed/exported as stored, never recalculated. +use crate::{Result, storage::Store}; +use ratatui::text::Line; +use serde_json::Value; + +const MODES: &[Option<&str>] = &[ + None, + Some("time"), + Some("words"), + Some("quote"), + Some("custom"), + Some("zen"), +]; +#[derive(Default)] +pub struct History { + pub rows: Vec, + pub selected: usize, + pub details: bool, + pub scroll: usize, + filter: usize, + page: usize, +} +impl History { + pub fn mode(&self) -> Option<&'static str> { + MODES[self.filter] + } + pub fn refresh(&mut self, store: &Store) -> Result<()> { + self.rows = store.history_page(self.mode(), 100, self.page * 100)?; + self.selected = self.selected.min(self.rows.len().saturating_sub(1)); + Ok(()) + } + pub fn filter(&mut self, store: &Store) -> Result<()> { + self.filter = (self.filter + 1) % MODES.len(); + self.page = 0; + self.selected = 0; + self.details = false; + self.refresh(store) + } + pub fn page(&mut self, direction: i32, store: &Store) -> Result<()> { + let page = self.page; + self.page = (self.page as i64 + i64::from(direction)).max(0) as usize; + self.refresh(store)?; + if self.rows.is_empty() && self.page > 0 { + self.page = page; + self.refresh(store)?; + } + self.selected = 0; + self.details = false; + self.scroll = 0; + Ok(()) + } + pub fn select(&mut self, direction: i32) { + if self.details { + self.scroll = (self.scroll as i64 + i64::from(direction)).max(0) as usize; + } else { + self.selected = (self.selected as i64 + i64::from(direction)) + .clamp(0, self.rows.len().saturating_sub(1) as i64) + as usize; + } + } + pub fn current(&self) -> Option<&Value> { + self.rows.get(self.selected) + } + pub fn lines(&self, store: &Store, height: usize) -> Vec> { + let mut lines = vec![ + Line::from(format!( + "Local history · {} · page {}", + self.mode().unwrap_or("all modes"), + self.page + 1 + )), + Line::from( + "↑↓ select/scroll · Enter details · m filter · PgUp/PgDn · F7 export · F8 replay", + ), + ]; + if self.details { + if let Some(row) = self.current() { + let details = serde_json::to_string_pretty(row).unwrap_or_default(); + lines.extend( + details + .lines() + .skip(self.scroll) + .take(height.saturating_sub(2)) + .map(|s| Line::from(s.to_string())), + ); + } + } else { + let visible = height.saturating_sub(2).max(1); + let skip = self.selected.saturating_sub(visible - 1); + for (index, row) in self.rows.iter().enumerate().skip(skip).take(visible) { + let summary = &row["summary"]; + let validity = if row["formatVersion"] == 1 { + row["invalidReason"].as_str().unwrap_or("valid") + } else { + "unknown format; details/export" + }; + let best = row["formatVersion"] == 1 + && row["personalBestEligible"] == true + && row["personalBestGroup"] + .as_str() + .and_then(|g| store.best(g).ok().flatten()) + == summary["wpm"].as_f64(); + lines.push(Line::from(format!( + "{} {} {} · {} WPM / {}% · {}{}", + if index == self.selected { ">" } else { " " }, + row["createdMs"], + row["config"]["mode"].as_str().unwrap_or("unknown"), + summary["wpm"], + summary["acc"], + validity, + if best { " · PB" } else { "" } + ))); + } + if self.rows.is_empty() { + lines.push(Line::from("No saved results for this filter.")); + } + } + lines + } +} diff --git a/crates/oxytype-tui/src/input.rs b/crates/oxytype-tui/src/input.rs new file mode 100644 index 000000000..03a2d0b33 --- /dev/null +++ b/crates/oxytype-tui/src/input.rs @@ -0,0 +1,70 @@ +//! Terminal delivery observations, never physical keyboard telemetry. +use crate::{Result, engine::EngineHost}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use serde::{Deserialize, Serialize}; +use typing_engine::{Command, Text}; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Capabilities { + pub enhanced_keyboard_negotiated: bool, + pub release_observed: bool, + pub repeat_observed: bool, + pub physical_key_identity: bool, + pub composition_lifecycle: bool, + pub physical_key_timing: bool, +} +impl Capabilities { + pub fn observe(&mut self, key: KeyEvent) { + self.release_observed |= key.kind == KeyEventKind::Release; + self.repeat_observed |= key.kind == KeyEventKind::Repeat; + } +} +pub fn pop_grapheme(value: &mut String) { + if let Some((offset, _)) = value.grapheme_indices(true).next_back() { + value.truncate(offset); + } +} +/// Mechanical text-control edit, expressed losslessly in UTF-16 for the core. +/// Core still decides deletion eligibility, regression and event accounting. +pub fn delete_observation(input: &Text) -> Result { + // Legacy per-unit error policies can leave a partial surrogate. The lossy + // view preserves UTF-16 length (one replacement per isolated unit); use its + // grapheme boundary only, then slice original units without round-tripping. + let mut value = input.display_lossy(); + pop_grapheme(&mut value); + Ok(Text(input.0[..value.encode_utf16().count()].to_vec())) +} +pub fn apply(host: &mut EngineHost, key: KeyEvent, now: f64, date: f64) -> Result<()> { + if key.kind == KeyEventKind::Release || host.state().ended { + return Ok(()); + } + let control = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + match key.code { + KeyCode::Backspace => host.command(Command::Delete { + kind: if control || alt { + "deleteWordBackward" + } else { + "deleteContentBackward" + } + .into(), + now, + date, + observed_input: if control || alt { + None + } else { + Some(delete_observation(&host.state().input_value)?) + }, + sentinel_removed: false, + })?, + KeyCode::Char(c) if !control && !alt && !c.is_control() => { + host.insert(&c.to_string(), now, date)? + } + KeyCode::Enter if !control && !alt => host.insert("\n", now, date)?, + KeyCode::Tab if !control && !alt => host.insert("\t", now, date)?, + _ => {} + } + Ok(()) +} diff --git a/crates/oxytype-tui/src/lib.rs b/crates/oxytype-tui/src/lib.rs new file mode 100644 index 000000000..2b63d7be7 --- /dev/null +++ b/crates/oxytype-tui/src/lib.rs @@ -0,0 +1,24 @@ +pub mod content; +pub mod engine; +pub mod history; +pub mod input; +pub mod local; +pub mod options; +pub mod render; +pub mod replay; +pub mod setup; +pub mod storage; +pub mod terminal; + +pub type Error = Box; +pub type Result = std::result::Result; + +pub fn version() -> String { + format!( + "{} (engine {}; log {}; build {})", + env!("CARGO_PKG_VERSION"), + typing_engine::BEHAVIOR_VERSION, + typing_engine::EVENT_LOG_VERSION, + env!("OXYTYPE_BUILD_REV") + ) +} diff --git a/crates/oxytype-tui/src/local.rs b/crates/oxytype-tui/src/local.rs new file mode 100644 index 000000000..1d8661a02 --- /dev/null +++ b/crates/oxytype-tui/src/local.rs @@ -0,0 +1,77 @@ +//! App-owned persistence state and recoverable write queue. +use crate::{ + Result, + engine::EngineHost, + history::History, + input::Capabilities, + setup::Setup, + storage::{self, Settings, Store}, +}; +use serde_json::Value; + +pub struct Local { + pub store: Store, + pub preferences: Settings, + pub history: History, + pub pending: Vec, + pub latest_result: Option, + pub replay: Option, + captured: bool, +} +impl Local { + pub fn new(store: Store, preferences: Settings) -> Self { + Self { + store, + preferences, + history: History::default(), + pending: vec![], + latest_result: None, + replay: None, + captured: false, + } + } + pub fn capture( + &mut self, + host: &EngineHost, + capabilities: &Capabilities, + date: f64, + ) -> Result { + if !host.state().ended || self.captured { + return Ok(false); + } + let record = storage::record(host, capabilities, self.preferences.record_replay, date)?; + self.captured = true; + self.latest_result = Some(record.clone()); + self.pending.push(record); + self.retry()?; + Ok(true) + } + pub fn retry(&mut self) -> Result<()> { + while let Some(record) = self.pending.first() { + self.store.save(record)?; + self.pending.remove(0); + } + Ok(()) + } + pub fn restarted(&mut self) { + self.captured = false; + self.latest_result = None; + } + pub fn configure_setup(&self, setup: &mut Setup) { + setup.theme = self.preferences.theme.clone(); + setup.reduced_animation = self.preferences.reduced_animation; + setup.record_replay = self.preferences.record_replay; + } + pub fn apply(&mut self, setup: &Setup) -> Result<()> { + let preferences = Settings { + config: setup.config.clone(), + theme: setup.theme.clone(), + reduced_animation: setup.reduced_animation, + record_replay: setup.record_replay, + format_version: 1, + }; + self.store.save_settings(&preferences)?; + self.preferences = preferences; + Ok(()) + } +} diff --git a/crates/oxytype-tui/src/main.rs b/crates/oxytype-tui/src/main.rs new file mode 100644 index 000000000..6e8b5d85b --- /dev/null +++ b/crates/oxytype-tui/src/main.rs @@ -0,0 +1,607 @@ +use clap::Parser; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use oxytype_tui::{Result, engine::EngineHost, terminal::TerminalGuard}; +use oxytype_tui::{ + local::Local, + storage::{Paths, Store}, +}; +use oxytype_tui::{options::Cli, setup::Setup}; +use ratatui::{ + Frame, + layout::{Constraint, Layout}, + text::Line, + widgets::{Block, Paragraph, Wrap}, +}; +use std::{ + sync::atomic::Ordering, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use typing_engine::Command; + +#[derive(Clone, Copy, PartialEq)] +enum Screen { + Test, + Help, + Settings, + History, + Editor, + Replay, +} +fn date() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as f64 +} +fn main() { + if let Err(error) = run() { + eprintln!("oxytype: {error}"); + std::process::exit(1); + } +} +fn run() -> Result<()> { + let cli = Cli::parse(); + if cli.smoke_test { + return oxytype_tui::engine::smoke(); + } + if cli.capabilities { + println!("{}", oxytype_tui::options::capabilities()); + return Ok(()); + } + if cli.diagnostics { + println!( + "{}", + serde_json::json!({"version":oxytype_tui::version(),"os":std::env::consts::OS,"architecture":std::env::consts::ARCH,"bindingVersion":typing_engine::BINDING_VERSION,"offlinePackVersion":1,"keyboard":"Inspect F1 after terminal negotiation","physicalKeyTiming":null}) + ); + return Ok(()); + } + let paths = Paths::resolve(cli.config_dir.as_deref(), cli.data_dir.as_deref())?; + if cli.paths { + println!( + "Config: {}\nData: {}", + paths.config.display(), + paths.data.display() + ); + return Ok(()); + } + let mut store = Store::open(&paths)?; + if let Some(output) = &cli.export { + store.export(output, cli.history_mode.as_deref())?; + println!("Exported to {}", output.display()); + return Ok(()); + } + let mut preferences = store.settings()?; + preferences.record_replay |= cli.record_replay; + if let Some(theme) = &cli.theme { + preferences.theme = theme.clone(); + } + let (config, content, custom) = cli.prepare(preferences.config.clone())?; + let mut local = Local::new(store, preferences); + let mut setup = Setup::new(config.clone(), custom.as_ref()); + local.configure_setup(&mut setup); + let mut host = EngineHost::offline( + config, + content.clone(), + custom, + cli.seed.unwrap_or(date() as u32), + )?; + let (guard, mut terminal) = TerminalGuard::enter(cli.keyboard != "basic")?; + #[cfg(debug_assertions)] + match std::env::var("OXYTYPE_TERMINAL_TEST_FAILURE").as_deref() { + Ok("panic") => panic!("Injected terminal cleanup test panic"), + Ok("error") => return Err("Injected terminal cleanup test error".into()), + _ => {} + } + let mut capabilities = oxytype_tui::input::Capabilities { + enhanced_keyboard_negotiated: guard.enhanced.load(Ordering::SeqCst), + ..Default::default() + }; + let origin = Instant::now(); + let mut screen = Screen::Test; + let mut notice = String::new(); + let mut last_draw = Instant::now(); + let mut dirty = true; + loop { + if guard.resume_if_requested(&mut terminal)? { + capabilities.enhanced_keyboard_negotiated = guard.enhanced.load(Ordering::SeqCst); + dirty = true; + } + let now = origin.elapsed().as_secs_f64() * 1000.0; + if host.state().active { + host.command(Command::Advance { now, date: date() })?; + } + match local.capture(&host, &capabilities, date()) { + Ok(true) => { + notice = "Result saved locally.".into(); + dirty = true; + } + Ok(false) => {} + Err(error) => { + notice = + format!("UNSAVED result: {error}. F9 retries; quitting loses pending data."); + dirty = true; + } + } + if guard.quit.load(Ordering::SeqCst) { + break; + } + if dirty + || (host.state().active + && last_draw.elapsed() + >= Duration::from_millis(if local.preferences.reduced_animation { + 250 + } else { + 100 + })) + { + terminal.draw(|frame| { + draw( + frame, + &host, + now, + View { + screen, + notice: ¬ice, + local: &local, + setup: &setup, + capabilities: &capabilities, + }, + ) + })?; + dirty = false; + last_draw = Instant::now(); + } + match event::poll(Duration::from_millis(50)) { + Ok(false) => continue, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error.into()), + Ok(true) => {} + } + dirty = true; + let event = event::read()?; + // Capture delivery time after poll/read, never the earlier polling time. + let now = origin.elapsed().as_secs_f64() * 1000.0; + if let Event::Key(key) = event { + capabilities.observe(key); + } + match event { + // Fullscreen draw autoresizes and invalidates its viewport. An extra + // clear queries cursor position, which basic terminals need not report. + Event::Resize(_, _) => {} + Event::Paste(text) if screen == Screen::Editor => { + let text = oxytype_tui::content::line_endings(&text); + match oxytype_tui::content::safe_text(&text) { + Ok(()) if setup.text.len() + text.len() <= 8 * 1024 * 1024 => { + setup.text.push_str(&text) + } + Ok(()) => notice = "Custom text exceeds 8 MiB".into(), + Err(error) => notice = error.to_string(), + } + } + Event::Paste(_) => notice = "Paste blocked during typing.".into(), + Event::Key(key) if key.kind != KeyEventKind::Release => { + let control = key.modifiers.contains(KeyModifiers::CONTROL); + if (control && matches!(key.code, KeyCode::Char('q' | 'c'))) + || key.code == KeyCode::F(10) + { + break; + } + if control && key.code == KeyCode::Char('z') { + if cfg!(unix) { + guard.suspend.store(true, Ordering::SeqCst); + } else { + notice = "Suspend is unavailable on this platform.".into(); + } + continue; + } + match key.code { + KeyCode::F(1) => screen = Screen::Help, + KeyCode::F(2) if !host.state().active => { + setup.config = host.config.clone(); + local.configure_setup(&mut setup); + screen = Screen::Settings; + } + KeyCode::F(4) if !host.state().active => screen = Screen::Editor, + KeyCode::F(6) if screen == Screen::Editor => setup.cycle_order(), + KeyCode::F(7) if screen == Screen::Editor => setup.cycle_limit(), + KeyCode::F(9) if screen == Screen::Editor => setup.increase_length(), + KeyCode::F(8) if screen == Screen::Editor => { + if !local.pending.is_empty() { + notice = + "Save the pending result with F9 before starting another test." + .into(); + continue; + } + setup.config.mode = "custom".into(); + match setup.build(&content, host.seed.wrapping_add(1)) { + Ok(next) => { + host = next; + local.restarted(); + screen = Screen::Test; + notice = local + .apply(&setup) + .err() + .map(|e| format!("Settings not saved: {e}")) + .unwrap_or_default(); + } + Err(error) => notice = error.to_string(), + } + } + KeyCode::Char(c) + if screen == Screen::Editor + && !control + && !key.modifiers.contains(KeyModifiers::ALT) => + { + if !c.is_control() && setup.text.len() < 8 * 1024 * 1024 { + setup.text.push(c); + } + } + KeyCode::Backspace if screen == Screen::Editor => { + oxytype_tui::input::pop_grapheme(&mut setup.text); + } + KeyCode::Enter if screen == Screen::Editor => setup.text.push('\n'), + KeyCode::Tab if screen == Screen::Editor => setup.text.push('\t'), + KeyCode::Up if screen == Screen::Settings => setup.select(-1), + KeyCode::Down if screen == Screen::Settings => setup.select(1), + KeyCode::Left if screen == Screen::Settings => setup.adjust(-1), + KeyCode::Right if screen == Screen::Settings => setup.adjust(1), + KeyCode::F(3) if !host.state().active => { + local.history.refresh(&local.store)?; + screen = Screen::History; + } + KeyCode::Up if screen == Screen::History => local.history.select(-1), + KeyCode::Down if screen == Screen::History => local.history.select(1), + KeyCode::PageUp if screen == Screen::History => { + local.history.page(-1, &local.store)? + } + KeyCode::PageDown if screen == Screen::History => { + local.history.page(1, &local.store)? + } + KeyCode::Enter if screen == Screen::History => { + local.history.details = !local.history.details; + local.history.scroll = 0; + } + KeyCode::Char('m') if screen == Screen::History => { + local.history.filter(&local.store)? + } + KeyCode::F(7) if screen == Screen::History => { + let output = paths + .data + .join(format!("export-{}.json", uuid::Uuid::new_v4())); + notice = match local.store.export(&output, local.history.mode()) { + Ok(()) => format!("Exported {}", output.display()), + Err(e) => format!("Export failed: {e}"), + }; + } + KeyCode::F(8) if screen == Screen::History => { + if let Some(record) = local.history.current() { + match oxytype_tui::replay::Replay::new(record) { + Ok(replay) => { + local.replay = Some(replay); + screen = Screen::Replay; + } + Err(e) => notice = e.to_string(), + } + } + } + KeyCode::Right | KeyCode::Char(' ') if screen == Screen::Replay => { + if let Some(replay) = &mut local.replay { + replay.step(); + } + } + KeyCode::Home if screen == Screen::Replay => { + if let Some(replay) = &mut local.replay { + replay.seek(typing_engine::replay::Cursor::default()); + } + } + KeyCode::PageUp | KeyCode::PageDown if screen == Screen::Replay => { + if let Some(replay) = &mut local.replay { + let direction = if key.code == KeyCode::PageUp { -1 } else { 1 }; + replay.seek(typing_engine::replay::Cursor { + word_index: (replay.frame.cursor.word_index + direction).max(0), + letter_index: 0, + }); + } + } + KeyCode::Esc if screen == Screen::Replay => { + local.replay = None; + screen = Screen::History; + } + KeyCode::F(9) => { + notice = match local.retry() { + Ok(()) => "Pending results saved.".into(), + Err(e) => format!("Still unsaved: {e}"), + } + } + KeyCode::Esc if screen != Screen::Test => screen = Screen::Test, + KeyCode::F(5) | KeyCode::F(6) => { + if !local.pending.is_empty() { + notice = + "Save the pending result with F9 before starting another test." + .into(); + continue; + } + host.restart(key.code == KeyCode::F(6), now, date())?; + local.restarted(); + screen = Screen::Test; + notice.clear(); + } + KeyCode::Enter if screen == Screen::Settings => { + if !local.pending.is_empty() { + notice = + "Save the pending result with F9 before starting another test." + .into(); + continue; + } + if setup.config.mode == "custom" && setup.text.trim().is_empty() { + screen = Screen::Editor; + } else { + match setup.build(&content, host.seed.wrapping_add(1)) { + Ok(next) => { + host = next; + local.restarted(); + screen = Screen::Test; + notice = local + .apply(&setup) + .err() + .map(|e| format!("Settings not saved: {e}")) + .unwrap_or_default(); + } + Err(error) => notice = error.to_string(), + } + } + } + KeyCode::F(8) if screen == Screen::Test => { + host.command(Command::Bailout { now, date: date() })?; + } + KeyCode::Enter if control && screen == Screen::Test => { + host.command(Command::Bailout { now, date: date() })?; + } + _ if screen == Screen::Test && !host.state().ended => { + let (columns, rows) = crossterm::terminal::size()?; + if columns >= 60 && rows >= 18 { + oxytype_tui::input::apply(&mut host, key, now, date())?; + } + } + _ => {} + } + } + _ => {} + } + } + if !local.pending.is_empty() { + return Err(format!( + "{} result(s) could not be saved; prior history is intact", + local.pending.len() + ) + .into()); + } + Ok(()) +} +struct View<'a> { + screen: Screen, + notice: &'a str, + local: &'a Local, + setup: &'a Setup, + capabilities: &'a oxytype_tui::input::Capabilities, +} +fn draw(frame: &mut Frame, host: &EngineHost, now: f64, view: View<'_>) { + let View { + screen, + notice, + local, + setup, + capabilities, + } = view; + let area = frame.area(); + let theme = match local.preferences.theme.as_str() { + "high-contrast" => ratatui::style::Style::new() + .fg(ratatui::style::Color::White) + .bg(ratatui::style::Color::Black), + "light" => ratatui::style::Style::new() + .fg(ratatui::style::Color::Black) + .bg(ratatui::style::Color::White), + _ => ratatui::style::Style::default(), + }; + frame.render_widget(Block::default().style(theme), area); + if area.width < 60 || area.height < 18 { + frame.render_widget( + Paragraph::new("Resize to at least 60×18. Active test clock continues. Ctrl+Q quits."), + area, + ); + return; + } + let rows = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(3), + ]) + .split(area); + frame.render_widget( + Paragraph::new(format!( + "Oxytype {} | {}{}{}", + env!("CARGO_PKG_VERSION"), + host.config.mode, + if local.preferences.record_replay { + " | RECORDING PRIVATE TEXT" + } else { + "" + }, + if local.pending.is_empty() { + String::new() + } else { + format!(" | {} UNSAVED", local.pending.len()) + } + )) + .block(Block::bordered()), + rows[0], + ); + let state = host.state(); + let body = match screen { + Screen::Help => vec![ + Line::from("F5 new · F6 repeat · F2 settings · F3 history · F4 custom · Esc return"), + Line::from("F8 finish · Backspace delete · Ctrl/Alt+Backspace delete word"), + Line::from("Ctrl+Q / Ctrl+C quit · Ctrl+Z suspend (Unix)"), + Line::from("Physical-key timing is unavailable; no key releases are invented."), + Line::from(format!( + "Keyboard: {} · release observed: {} · repeat observed: {}", + if capabilities.enhanced_keyboard_negotiated { + "enhanced" + } else { + "basic" + }, + capabilities.release_observed, + capabilities.repeat_observed + )), + ], + Screen::Settings => { + let lines = setup.lines(); + let visible = rows[1].height.saturating_sub(3) as usize; + let skip = setup.selected.saturating_sub(visible.saturating_sub(1)); + std::iter::once(lines[0].clone()) + .chain(lines.into_iter().skip(skip + 1).take(visible)) + .collect() + } + Screen::Editor => { + let mut lines = setup.editor_lines(); + for line in &mut lines { + *line = Line::from(oxytype_tui::render::clip_cells( + &line.to_string(), + rows[1].width.saturating_sub(2) as usize, + )); + } + let input = typing_engine::Text::from(setup.text.as_str()); + let mut view = oxytype_tui::render::text_view( + &typing_engine::Text::default(), + &input, + &host.config.language, + rows[1].width.saturating_sub(2), + rows[1].height.saturating_sub(4), + ); + frame.set_cursor_position(( + rows[1].x + 1 + view.caret.0, + rows[1].y + 1 + lines.len() as u16 + view.caret.1, + )); + for line in &mut view.lines { + for span in &mut line.spans { + span.style = ratatui::style::Style::default(); + } + } + lines.extend(view.lines); + lines + } + Screen::Replay => local.replay.as_ref().map(|r| r.lines()).unwrap_or_default(), + Screen::History => local + .history + .lines(&local.store, rows[1].height.saturating_sub(2) as usize), + Screen::Test if state.ended => vec![ + Line::from(host.result.as_ref().map_or_else( + || { + host.result_error + .clone() + .unwrap_or_else(|| "Test ended".into()) + }, + |score| { + format!( + "{:.1} WPM · {:.1}% accuracy · {:.2}s", + score.wpm, score.acc, score.test_duration + ) + }, + )), + Line::from(state.reason.unwrap_or_else(|| { + if host.log().is_ok_and(|log| log.context.bailed_out) { + "Finished manually".into() + } else { + "Completed".into() + } + })), + Line::from( + local + .latest_result + .as_ref() + .map(|r| { + format!( + "Local validity: {}", + r["invalidReason"].as_str().unwrap_or("valid") + ) + }) + .unwrap_or_else(|| "Preparing local result…".into()), + ), + Line::from("F5 new test · F6 repeat"), + ], + Screen::Test => { + let mut lines = vec![]; + if let Ok(live) = host.live(now) { + lines.push(Line::from(format!( + "{:.1} WPM · {:.1}% · {:.0}s", + live.wpm, live.acc, live.seconds + ))); + } + let input = host.visual_input.clone().unwrap_or(state.input_value); + let mut target = typing_engine::Text::default(); + if host.config.mode != "zen" { + target = host + .words + .get(state.word_index) + .cloned() + .unwrap_or_default(); + } + let view = oxytype_tui::render::text_view( + &target, + &input, + &host.config.language, + rows[1].width.saturating_sub(2), + rows[1].height.saturating_sub(6), + ); + if view.errors { + lines.push(Line::from("! Incorrect text is underlined")); + } + let caret_y = rows[1].y + 1 + lines.len() as u16 + view.caret.1; + frame.set_cursor_position((rows[1].x + 1 + view.caret.0, caret_y)); + lines.extend(view.lines.into_iter().map(|line| { + line.style( + ratatui::style::Style::new().add_modifier(ratatui::style::Modifier::BOLD), + ) + })); + if host.config.mode != "zen" { + let next = host + .words + .iter() + .skip(state.word_index + 1) + .take(5) + .map(|w| w.display_lossy().replace('\n', "↵").replace('\t', "→")) + .collect::(); + lines.push(Line::from(format!( + "Next: {}", + oxytype_tui::render::clip_cells( + &next, + rows[1].width.saturating_sub(8) as usize + ) + ))); + } + lines.push(Line::from(format!( + "Input: {}", + oxytype_tui::render::clip_cells( + &input + .display_lossy() + .replace('\t', "→ ") + .replace('\n', "↵"), + rows[1].width.saturating_sub(9) as usize + ) + ))); + lines + } + }; + frame.render_widget( + Paragraph::new(body) + .wrap(Wrap { trim: false }) + .block(Block::bordered()), + rows[1], + ); + frame.render_widget( + Paragraph::new(format!( + "F1 help · F5 new · F6 repeat · Ctrl+Q quit · F9 retry saves\n{notice}" + )), + rows[2], + ); +} diff --git a/crates/oxytype-tui/src/options.rs b/crates/oxytype-tui/src/options.rs new file mode 100644 index 000000000..68ed11e13 --- /dev/null +++ b/crates/oxytype-tui/src/options.rs @@ -0,0 +1,251 @@ +//! CLI overrides apply to the very same core Config edited by the settings UI. +use crate::{ + Result, + content::{OfflineContent, read_utf8, safe_text}, +}; +use clap::Parser; +use std::path::PathBuf; +use typing_engine::{ + Config, Text, + generation::{CustomLimit, CustomText}, +}; + +pub const FUNBOXES: &[&str] = &["capitals", "rAnDoMcAsE", "backwards", "IPv4", "IPv6"]; +#[derive(Parser, Default)] +#[command(name="oxytype", version=crate::version(), about="Offline typing with the shared Oxytype Rust engine")] +pub struct Cli { + /// Deterministic synthetic smoke; no terminal or saved data. + #[arg(long)] + pub smoke_test: bool, + /// time, words, quote, custom, or zen. Defaults come from the shared engine. + #[arg(long)] + pub mode: Option, + #[arg(long)] + pub words: Option, + #[arg(long)] + pub time: Option, + /// Must match bundled English or the explicitly supplied pack. + #[arg(long)] + pub language: Option, + #[arg(long)] + pub seed: Option, + /// Version-1 local JSON language/quote pack; never downloaded. + #[arg(long)] + pub pack: Option, + /// Private custom text; never saved in settings/history by default. + #[arg(long, conflicts_with = "file")] + pub text: Option, + #[arg(long, conflicts_with = "text")] + pub file: Option, + #[arg(long, default_value = "repeat")] + pub custom_order: String, + #[arg(long, default_value = "word")] + pub custom_limit: String, + #[arg(long)] + pub custom_length: Option, + /// Comma-separated quote groups: 0 short, 1 medium, 2 long, 3 very long. + #[arg(long, value_delimiter = ',')] + pub quote_length: Option>, + #[arg(long)] + pub punctuation: bool, + #[arg(long)] + pub numbers: bool, + #[arg(long)] + pub difficulty: Option, + #[arg(long)] + pub stop_on_error: Option, + #[arg(long)] + pub delete_on_error: Option, + #[arg(long)] + pub confidence: Option, + #[arg(long)] + pub freedom: bool, + #[arg(long)] + pub strict_space: bool, + /// Explicit supported subset; see --capabilities. + #[arg(long, value_delimiter = ',')] + pub funbox: Option>, + #[arg(long)] + pub capabilities: bool, + /// auto negotiates Kitty keyboard events; basic avoids protocol queries. + #[arg(long, default_value="auto", value_parser=["auto","basic"])] + pub keyboard: String, + #[arg(long)] + pub config_dir: Option, + #[arg(long)] + pub data_dir: Option, + /// Export all stored results (or --history-mode) without entering a terminal. + #[arg(long)] + pub export: Option, + #[arg(long, value_parser=["time","words","quote","custom","zen"])] + pub history_mode: Option, + /// Print effective storage directories without opening databases. + #[arg(long)] + pub paths: bool, + /// Explicit opt-in: persist target text and full input log for replay. + #[arg(long)] + pub record_replay: bool, + #[arg(long, value_parser=["terminal","high-contrast","light"])] + pub theme: Option, + /// Version/platform diagnostics; no environment variables or private text. + #[arg(long)] + pub diagnostics: bool, +} +impl Cli { + pub fn prepare( + &self, + mut config: Config, + ) -> Result<(Config, OfflineContent, Option)> { + let content = match &self.pack { + Some(path) => OfflineContent::load(path)?, + None => OfflineContent::bundled()?, + }; + macro_rules! project { ($($name:ident),*) => { $(if let Some(value) = &self.$name { config.$name = value.clone(); })* }; } + project!( + mode, + words, + time, + language, + quote_length, + difficulty, + stop_on_error, + delete_on_error, + funbox + ); + if let Some(value) = &self.confidence { + config.confidence_mode = value.clone(); + } + if self.language.is_none() { + config.language = content.language.name.clone(); + } + config.punctuation |= self.punctuation; + config.numbers |= self.numbers; + config.freedom_mode |= self.freedom; + config.strict_space |= self.strict_space; + let text = match (&self.text, &self.file) { + (Some(text), _) => Some(text.clone()), + (_, Some(path)) => Some(read_utf8(path)?), + _ => None, + }; + if text.is_some() && self.mode.is_none() { + config.mode = "custom".into(); + } + if text.is_some() && config.mode != "custom" { + return Err("--text/--file requires custom mode".into()); + } + let custom = text + .map(|text| { + custom_text_auto( + &text, + &self.custom_order, + &self.custom_limit, + self.custom_length, + ) + }) + .transpose()?; + validate(&config, &content, custom.as_ref())?; + Ok((config, content, custom)) + } +} +pub fn custom_text(text: &str, order: &str, limit: &str, value: f64) -> Result { + let text = crate::content::line_endings(text); + // Decode plain-text entry boundaries using the core's separator set. Leaving + // a nonbreaking space inside one entry would make Space commit it early. + // Work in UTF-16 so non-BMP scalars cannot alias a BMP separator by truncation. + let units: Vec<_> = text + .encode_utf16() + .map(|unit| { + if typing_engine::text::is_space(unit) { + 32 + } else { + unit + } + }) + .collect(); + let text = String::from_utf16(&units)?; + safe_text(&text)?; + if text.trim().is_empty() { + return Err("Custom text is empty".into()); + } + if !["repeat", "random", "shuffle"].contains(&order) + || !["word", "time", "section"].contains(&limit) + { + return Err("Invalid custom order/limit".into()); + } + // A terminal line ending is a commit separator. Keep it at the end of its + // supplied word, rather than hiding following text inside an untypable word. + let text = text.replace('\n', "\n "); + let custom = CustomText { + text: text + .split(' ') + .filter(|part| !part.is_empty()) + .map(Text::from) + .collect(), + mode: order.into(), + limit: CustomLimit { + mode: limit.into(), + value, + }, + pipe_delimiter: false, + }; + typing_engine::validation::custom(&custom)?; + Ok(custom) +} +pub fn custom_text_auto( + text: &str, + order: &str, + limit: &str, + value: Option, +) -> Result { + let mut custom = custom_text(text, order, limit, 0.0)?; + custom.limit.value = value.unwrap_or_else(|| { + if limit == "time" { + Config::default().time + } else { + custom.text.len() as f64 + } + }); + typing_engine::validation::custom(&custom)?; + Ok(custom) +} +pub fn validate( + config: &Config, + content: &OfflineContent, + custom: Option<&CustomText>, +) -> Result<()> { + config.validate()?; + if config.language != content.language.name { + return Err("Selected language is unavailable; supply a matching --pack".into()); + } + if config.mode == "custom" && custom.is_none() { + return Err("Custom mode requires private text: use --text/--file, or --mode words then F4 for the editor".into()); + } + if config.quote_length.is_empty() || config.quote_length.iter().any(|g| !(0..=3).contains(g)) { + return Err("Terminal quote lengths must be groups 0,1,2,3".into()); + } + for name in &config.funbox { + if !FUNBOXES.contains(&name.as_str()) { + return Err( + format!("Terminal does not support funbox {name}; see --capabilities").into(), + ); + } + } + if !typing_engine::funboxes::compatible(&config.funbox) { + return Err("Incompatible funbox combination".into()); + } + // Ask shared metadata about forced settings; never silently substitute config. + if let serde_json::Value::Object(values) = serde_json::to_value(config)? { + for (key, value) in values { + if !typing_engine::funboxes::can_set(&key, &value, &config.funbox)? { + return Err(format!("Funbox disallows setting {key}={value}").into()); + } + } + } + Ok(()) +} +pub fn capabilities() -> String { + format!( + "Platforms: Linux, macOS\nModes: time, words, quote, custom text/file, zen\nOffline English + original quotes; local v1 packs\nFunboxes: {}\nOther web-only visual/audio/network/layout hooks: unavailable (rejected)\nInput: committed characters, Backspace, Enter, Tab, repeats\nKeyboard: auto negotiates Kitty enhancements; --keyboard basic skips queries\nWord delete: Ctrl/Alt+Backspace when distinguishable; try Alt in basic mode\nGraphics: text only; no image protocol required\nPhysical-key identity/composition lifecycle: unavailable\nKey duration/spacing/overlap: unavailable; no synthetic releases\nPaste: blocked during tests; unmarked paste cannot be detected\n", + FUNBOXES.join(", ") + ) +} diff --git a/crates/oxytype-tui/src/render.rs b/crates/oxytype-tui/src/render.rs new file mode 100644 index 000000000..361e07005 --- /dev/null +++ b/crates/oxytype-tui/src/render.rs @@ -0,0 +1,129 @@ +//! Display coordinates are cells; engine/event positions remain UTF-16 units. +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; +use typing_engine::Text; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +pub struct TextView { + pub lines: Vec>, + pub caret: (u16, u16), + pub errors: bool, +} +pub fn clip_cells(value: &str, width: usize) -> String { + let mut result = String::new(); + let mut used = 0; + for grapheme in value.graphemes(true) { + let cells = UnicodeWidthStr::width(grapheme); + if used + cells > width { + break; + } + result.push_str(grapheme); + used += cells; + } + result +} +/// Expand controls visibly and wrap whole graphemes without splitting wide cells. +pub fn text_view(target: &Text, input: &Text, language: &str, width: u16, height: u16) -> TextView { + let width = width.max(2) as usize; + let mut lines = vec![Line::default()]; + let mut x = 0usize; + let mut offset_utf16 = 0; + let mut caret = None; + let mut errors = false; + let target_string = target.display_lossy(); + let input_string = input.display_lossy(); + let source = if target.is_empty() { + &input_string + } else { + &target_string + }; + for grapheme in source.graphemes(true) { + let units = grapheme.encode_utf16().count(); + let mut shown = match grapheme { + "\t" => "→ ".to_string(), + "\n" => "↵".to_string(), + _ => grapheme.to_string(), + }; + if UnicodeWidthStr::width(shown.as_str()) == 0 { + shown.insert(0, '◌'); + } + let cells = UnicodeWidthStr::width(shown.as_str()).max(1).min(width); + if x + cells > width { + lines.push(Line::default()); + x = 0; + } + if caret.is_none() && offset_utf16 + units > input.len() { + caret = Some((x, lines.len() - 1)); + } + let typed_end = (offset_utf16 + units).min(input.len()); + let wrong = (offset_utf16..typed_end).any(|i| { + target.0.get(i).is_some_and(|t| { + typing_engine::text::normalize(input.0[i], Some(*t), language) != *t + }) + }); + errors |= wrong; + let style = if wrong { + Style::new() + .fg(Color::Red) + .add_modifier(Modifier::UNDERLINED) + } else if typed_end > offset_utf16 { + Style::new().fg(Color::Green) + } else { + Style::default() + }; + lines + .last_mut() + .unwrap() + .spans + .push(Span::styled(shown, style)); + x += cells; + offset_utf16 += units; + if grapheme == "\n" { + lines.push(Line::default()); + x = 0; + } + } + // Extra input is visible and marked; it never changes the target or score. + if !target.is_empty() && input.len() > target.len() { + errors = true; + let mut utf16 = 0; + for grapheme in input_string.graphemes(true) { + utf16 += grapheme.encode_utf16().count(); + if utf16 <= target.len() { + continue; + } + let cells = UnicodeWidthStr::width(grapheme).max(1).min(width); + if x + cells > width { + lines.push(Line::default()); + x = 0; + } + lines.last_mut().unwrap().spans.push(Span::styled( + grapheme.to_string(), + Style::new() + .fg(Color::Red) + .add_modifier(Modifier::UNDERLINED), + )); + x += cells; + } + } + let (mut cx, mut cy) = caret.unwrap_or((x, lines.len() - 1)); + if cx >= width { + lines.push(Line::default()); + cx = 0; + cy += 1; + } + let start = cy.saturating_sub(height.max(1) as usize - 1); + let lines = lines + .into_iter() + .skip(start) + .take(height as usize) + .collect(); + TextView { + lines, + caret: (cx as u16, (cy - start) as u16), + errors, + } +} diff --git a/crates/oxytype-tui/src/replay.rs b/crates/oxytype-tui/src/replay.rs new file mode 100644 index 000000000..eb94daf2f --- /dev/null +++ b/crates/oxytype-tui/src/replay.rs @@ -0,0 +1,111 @@ +//! Optional recorded replay; core owns every step, cursor and correctness flag. +use crate::Result; +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; +use serde_json::Value; +use typing_engine::{ + Text, + replay::{Cursor, Playback, PlaybackSnapshot}, + stats::Log, +}; +use unicode_segmentation::UnicodeSegmentation; + +pub struct Replay { + playback: Playback, + pub frame: PlaybackSnapshot, +} +impl Replay { + pub fn new(record: &Value) -> Result { + if record["formatVersion"] != 1 + || record["engineBehaviorVersion"] != typing_engine::BEHAVIOR_VERSION + || record["eventLogVersion"] != typing_engine::EVENT_LOG_VERSION + { + return Err( + "Replay needs a compatible engine/log version; stored summary remains available" + .into(), + ); + } + if record["replayLog"].is_null() { + return Err( + "Replay unavailable: this result did not record its private input log".into(), + ); + } + let log: Log = serde_json::from_value(record["replayLog"].clone())?; + let playback = Playback::new(&log, record["config"]["mode"] == "zen")?; + let frame = playback.snapshot(); + Ok(Self { playback, frame }) + } + pub fn step(&mut self) { + let update = self.playback.step(); + for change in update.words { + self.frame.words[change.index] = change.word; + } + self.frame.cursor = update.cursor; + self.frame.next_index = update.next_index; + self.frame.next_time = update.next_time; + } + pub fn seek(&mut self, cursor: Cursor) { + self.frame = self.playback.seek(cursor); + } + pub fn lines(&self) -> Vec> { + let cursor = self.frame.cursor; + let mut lines = vec![ + Line::from(format!( + "Recorded replay · word {} / UTF-16 unit {} · step {}", + cursor.word_index, cursor.letter_index, self.frame.next_index + )), + Line::from("→ / Space step · Home restart · PgUp/PgDn seek word · Esc history"), + ]; + let mut spans = vec![]; + for (index, word) in self + .frame + .words + .iter() + .enumerate() + .skip(cursor.word_index.max(0) as usize) + .take(35) + { + let units: Vec<_> = word + .letters + .iter() + .flat_map(|l| l.character.0.iter().copied()) + .collect(); + let text = Text(units).display_lossy(); + let mut unit = 0; + for grapheme in text.graphemes(true) { + let len = grapheme.encode_utf16().count(); + let letters = &word.letters + [unit.min(word.letters.len())..(unit + len).min(word.letters.len())]; + let wrong = letters.iter().any(|l| l.incorrect || l.extra); + let correct = letters.iter().all(|l| l.correct); + let mut style = if wrong { + Style::new() + .fg(Color::Red) + .add_modifier(Modifier::UNDERLINED) + } else if correct { + Style::new().fg(Color::Green) + } else { + Style::default() + }; + if index as i32 == cursor.word_index + && unit <= cursor.letter_index + && cursor.letter_index < unit + len + { + style = style.add_modifier(Modifier::REVERSED); + } + spans.push(Span::styled( + grapheme.replace('\t', "→ ").replace('\n', "↵\n"), + style, + )); + unit += len; + } + if word.error { + spans.push(Span::from("! ")); + } + } + lines.push(Line::from(spans)); + lines + } +} diff --git a/crates/oxytype-tui/src/setup.rs b/crates/oxytype-tui/src/setup.rs new file mode 100644 index 000000000..fcf57f070 --- /dev/null +++ b/crates/oxytype-tui/src/setup.rs @@ -0,0 +1,167 @@ +//! Presentation controls edit a draft core configuration, validated on apply. +use crate::{Result, content::OfflineContent, engine::EngineHost, options}; +use ratatui::text::Line; +use typing_engine::{Config, generation::CustomText}; + +pub struct Setup { + pub config: Config, + pub selected: usize, + pub text: String, + pub order: String, + pub limit: String, + pub length: Option, + pub theme: String, + pub reduced_animation: bool, + pub record_replay: bool, +} +fn cycle(value: &mut String, values: &[&str], direction: i32) { + let index = values.iter().position(|v| v == value).unwrap_or(0) as i32; + *value = values[(index + direction).rem_euclid(values.len() as i32) as usize].into(); +} +impl Setup { + pub fn new(config: Config, custom: Option<&CustomText>) -> Self { + Self { + config, + selected: 0, + text: custom + .map(|c| { + c.text + .iter() + .map(|t| t.display_lossy()) + .collect::>() + .join(" ") + }) + .unwrap_or_default(), + order: custom.map_or("repeat", |c| c.mode.as_str()).into(), + limit: custom.map_or("word", |c| c.limit.mode.as_str()).into(), + length: custom.map(|c| c.limit.value), + theme: "terminal".into(), + reduced_animation: true, + record_replay: false, + } + } + pub fn custom(&self) -> Result> { + if self.config.mode != "custom" { + return Ok(None); + } + Ok(Some(options::custom_text_auto( + &self.text, + &self.order, + &self.limit, + self.length, + )?)) + } + pub fn build(&self, content: &OfflineContent, seed: u32) -> Result { + EngineHost::offline(self.config.clone(), content.clone(), self.custom()?, seed) + } + pub fn select(&mut self, direction: i32) { + self.selected = (self.selected as i32 + direction).rem_euclid(16) as usize; + } + pub fn adjust(&mut self, direction: i32) { + let c = &mut self.config; + match self.selected { + 0 => cycle( + &mut c.mode, + &["time", "words", "quote", "custom", "zen"], + direction, + ), + 1 => { + c.words = + (i64::from(c.words) + i64::from(direction) * 10).clamp(0, 1_000_000) as u32 + } + 2 => c.time = (c.time + f64::from(direction) * 15.0).clamp(0.0, 1_000_000.0), + 3 => c.punctuation = !c.punctuation, + 4 => c.numbers = !c.numbers, + 5 => cycle( + &mut c.difficulty, + &["normal", "expert", "master"], + direction, + ), + 6 => cycle(&mut c.stop_on_error, &["off", "word", "letter"], direction), + 7 => cycle( + &mut c.delete_on_error, + &["off", "word", "letter", "word_hard", "letter_hard"], + direction, + ), + 8 => cycle(&mut c.confidence_mode, &["off", "on", "max"], direction), + 9 => c.freedom_mode = !c.freedom_mode, + 10 => c.strict_space = !c.strict_space, + 11 => c.quote_length = vec![(c.quote_length[0] + direction).rem_euclid(4)], + 12 => { + let choices: Vec<_> = std::iter::once("") + .chain(options::FUNBOXES.iter().copied()) + .collect(); + let mut name = c.funbox.first().cloned().unwrap_or_default(); + cycle(&mut name, &choices, direction); + c.funbox = if name.is_empty() { vec![] } else { vec![name] }; + } + 13 => cycle( + &mut self.theme, + &["terminal", "high-contrast", "light"], + direction, + ), + 14 => self.reduced_animation = !self.reduced_animation, + 15 => self.record_replay = !self.record_replay, + _ => {} + } + } + pub fn lines(&self) -> Vec> { + let c = &self.config; + let labels = [ + format!("Mode: {}", c.mode), + format!("Words: {} (0 infinite)", c.words), + format!("Time: {}s (0 infinite)", c.time), + format!("Punctuation: {}", c.punctuation), + format!("Numbers: {}", c.numbers), + format!("Difficulty: {}", c.difficulty), + format!("Stop on error: {}", c.stop_on_error), + format!("Delete on error: {}", c.delete_on_error), + format!("Confidence: {}", c.confidence_mode), + format!("Freedom: {}", c.freedom_mode), + format!("Strict space: {}", c.strict_space), + format!("Quote group: {:?}", c.quote_length), + format!("Funbox: {}", c.funbox.join(", ")), + format!("Theme: {}", self.theme), + format!("Reduced animation: {}", self.reduced_animation), + format!( + "Record replay (stores PRIVATE text/input): {}", + self.record_replay + ), + ]; + std::iter::once(Line::from( + "↑↓ select · ←→ change · Enter apply · F4 custom text · Esc cancel", + )) + .chain(labels.into_iter().enumerate().map(|(i, s)| { + Line::from(format!( + "{} {s}", + if i == self.selected { ">" } else { " " } + )) + })) + .collect() + } + pub fn editor_lines(&self) -> Vec> { + vec![ + Line::from("Private custom text · type or paste · F8 apply · Esc cancel"), + Line::from(format!( + "F6 order: {} · F7 limit: {} · F9 length: {}", + self.order, + self.limit, + self.length + .map(|v| v.to_string()) + .unwrap_or_else(|| "auto".into()) + )), + ] + } + pub fn increase_length(&mut self) { + let value = options::custom_text_auto(&self.text, &self.order, &self.limit, self.length) + .map(|c| c.limit.value) + .unwrap_or(0.0); + self.length = Some(value + 1.0); + } + pub fn cycle_order(&mut self) { + cycle(&mut self.order, &["repeat", "random", "shuffle"], 1); + } + pub fn cycle_limit(&mut self) { + cycle(&mut self.limit, &["section", "word", "time"], 1); + } +} diff --git a/crates/oxytype-tui/src/storage.rs b/crates/oxytype-tui/src/storage.rs new file mode 100644 index 000000000..dd7867ab1 --- /dev/null +++ b/crates/oxytype-tui/src/storage.rs @@ -0,0 +1,311 @@ +//! Offline durable storage. SQLite transactions arbitrate concurrent instances. +use crate::{Result, engine::EngineHost, input::Capabilities}; +use directories::ProjectDirs; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + time::Duration, +}; +use typing_engine::{ + Config, + results::{Limit, Validity, ValidityResult}, +}; + +#[derive(Clone, Debug)] +pub struct Paths { + pub config: PathBuf, + pub data: PathBuf, +} +impl Paths { + pub fn resolve(config: Option<&Path>, data: Option<&Path>) -> Result { + let dirs = ProjectDirs::from("com", "Oxytype", "Oxytype"); + Ok(Self { + config: config + .map(Path::to_path_buf) + .or_else(|| dirs.as_ref().map(|d| d.config_dir().into())) + .ok_or("No config directory; use --config-dir")?, + data: data + .map(Path::to_path_buf) + .or_else(|| dirs.as_ref().map(|d| d.data_local_dir().into())) + .ok_or("No data directory; use --data-dir")?, + }) + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Settings { + pub format_version: u32, + pub config: Config, + pub theme: String, + pub reduced_animation: bool, + pub record_replay: bool, +} +impl Default for Settings { + fn default() -> Self { + Self { + format_version: 1, + config: Config::default(), + theme: "terminal".into(), + reduced_animation: true, + record_replay: false, + } + } +} +impl Settings { + pub fn validate(&self) -> Result<()> { + if self.format_version != 1 { + return Err("Unsupported settings version; existing file preserved".into()); + } + self.config.validate()?; + if !["terminal", "high-contrast", "light"].contains(&self.theme.as_str()) { + return Err("Unknown terminal theme".into()); + } + Ok(()) + } +} +pub struct Store { + config: Connection, + history: Connection, + pub revision: i64, +} +fn open(path: &Path, schema: &str) -> Result { + let dir = path.parent().ok_or("Missing storage directory")?; + if !dir.exists() { + fs::create_dir_all(dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; + } + } + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(path) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + let mut db = Connection::open(path)?; + db.busy_timeout(Duration::from_secs(2))?; + let version: i64 = db.pragma_query_value(None, "user_version", |r| r.get(0))?; + if version > 1 { + return Err(format!( + "Unsupported database version {version}; file preserved at {}", + path.display() + ) + .into()); + } + db.pragma_update(None, "journal_mode", "WAL")?; + db.pragma_update(None, "synchronous", "FULL")?; + let transaction = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute_batch(schema)?; + transaction.pragma_update(None, "user_version", 1)?; + transaction.commit()?; + Ok(db) +} +impl Store { + pub fn open(paths: &Paths) -> Result { + Ok(Self { + config: open( + &paths.config.join("settings.sqlite3"), + "CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL, json TEXT NOT NULL);", + )?, + history: open( + &paths.data.join("history.sqlite3"), + "CREATE TABLE IF NOT EXISTS results (id TEXT PRIMARY KEY NOT NULL, created_ms REAL NOT NULL, mode TEXT NOT NULL, json TEXT NOT NULL); CREATE INDEX IF NOT EXISTS results_mode_time ON results(mode,created_ms);", + )?, + revision: 0, + }) + } + pub fn settings(&mut self) -> Result { + let row: Option<(i64, String)> = self + .config + .query_row("SELECT revision,json FROM settings WHERE id=1", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .optional()?; + let settings = match row { + Some((revision, json)) => { + self.revision = revision; + serde_json::from_str(&json)? + } + None => Settings::default(), + }; + settings.validate()?; + Ok(settings) + } + pub fn save_settings(&mut self, settings: &Settings) -> Result<()> { + settings.validate()?; + let transaction = self + .config + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let current: i64 = transaction + .query_row("SELECT revision FROM settings WHERE id=1", [], |r| r.get(0)) + .optional()? + .unwrap_or(0); + if current != self.revision { + return Err("Settings changed in another instance; reload before saving (existing data preserved)".into()); + } + let revision = current + 1; + transaction.execute("INSERT INTO settings(id,revision,json) VALUES(1,?1,?2) ON CONFLICT(id) DO UPDATE SET revision=excluded.revision,json=excluded.json",params![revision,serde_json::to_string(settings)?])?; + transaction.commit()?; + self.revision = revision; + Ok(()) + } + pub fn save(&mut self, record: &Value) -> Result<()> { + let id = record["id"].as_str().ok_or("Result missing ID")?; + let json = serde_json::to_string(record)?; + let transaction = self + .history + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing: Option = transaction + .query_row("SELECT json FROM results WHERE id=?1", [id], |r| r.get(0)) + .optional()?; + if let Some(existing) = existing { + if existing != json { + return Err("Result ID collision; existing result preserved".into()); + } + } else { + transaction.execute( + "INSERT INTO results(id,created_ms,mode,json) VALUES(?1,?2,?3,?4)", + params![ + id, + record["createdMs"] + .as_f64() + .ok_or("Missing result timestamp")?, + record["config"]["mode"] + .as_str() + .ok_or("Missing result mode")?, + json + ], + )?; + } + transaction.commit()?; + Ok(()) + } + pub fn history(&self, mode: Option<&str>, limit: usize) -> Result> { + self.history_page(mode, limit, 0) + } + pub fn history_page( + &self, + mode: Option<&str>, + limit: usize, + offset: usize, + ) -> Result> { + let mut query=self.history.prepare("SELECT json FROM results WHERE (?1 IS NULL OR mode=?1) ORDER BY created_ms DESC,id DESC LIMIT ?2 OFFSET ?3")?; + let rows = query.query_map( + params![mode, limit.min(100_000) as i64, offset as i64], + |r| r.get::<_, String>(0), + )?; + rows.map(|row| Ok(serde_json::from_str(&row?)?)).collect() + } + pub fn best(&self, group: &str) -> Result> { + Ok(self.history.query_row("SELECT MAX(json_extract(json,'$.summary.wpm')) FROM results WHERE json_extract(json,'$.formatVersion')=1 AND json_extract(json,'$.personalBestEligible')=1 AND json_extract(json,'$.personalBestGroup')=?1", [group], |r|r.get(0))?) + } + /// Export the stored bytes/fields semantically; never recompute old scores. + pub fn export(&self, path: &Path, mode: Option<&str>) -> Result<()> { + let mut query = self.history.prepare( + "SELECT json FROM results WHERE (?1 IS NULL OR mode=?1) ORDER BY created_ms,id", + )?; + let rows = query.query_map([mode], |r| r.get::<_, String>(0))?; + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary.write_all(b"{\"formatVersion\":1,\"results\":[")?; + for (i, row) in rows.enumerate() { + let raw = row?; + let _: Value = serde_json::from_str(&raw)?; + if i > 0 { + temporary.write_all(b",")?; + } + temporary.write_all(raw.as_bytes())?; + } + temporary.write_all(b"]}\n")?; + temporary.as_file().sync_all()?; + temporary.persist_noclobber(path).map_err(|e| e.error)?; + #[cfg(unix)] + { + fs::File::open(parent)?.sync_all()?; + } + Ok(()) + } +} +pub fn hash(value: &impl Serialize) -> Result { + Ok(format!("{:x}", Sha256::digest(serde_json::to_vec(value)?))) +} +pub fn record( + host: &EngineHost, + capabilities: &Capabilities, + replay: bool, + created: f64, +) -> Result { + if !host.state().ended { + return Err("Cannot save unfinished result".into()); + } + let log = host.log()?; + let quote = host.session().snapshot().current_quote; + let mode2 = log.context.mode2.clone(); + let limit = host.custom_limit(); + let invalid = host + .result + .as_ref() + .map(|summary| { + typing_engine::results::invalid_reason( + &log, + &Validity { + result: ValidityResult { + mode: host.config.mode.clone(), + mode2: mode2.clone(), + test_duration: summary.test_duration, + wpm: summary.wpm, + raw_wpm: summary.raw_wpm, + acc: summary.acc, + }, + bailed_out: log.context.bailed_out, + failed: host.state().reason.is_some(), + repeated: host.repeated, + lb_opt_out: false, + custom_limit: limit.clone().unwrap_or(Limit { + mode: "word".into(), + value: 0.0, + }), + }, + ) + .map(str::to_string) + }) + .unwrap_or_else(|| Some("unavailable metrics".into())); + let mut summary = serde_json::to_value(&host.result)?; + if let Value::Object(values) = &mut summary { + for field in [ + "keySpacing", + "keyDuration", + "keyOverlap", + "keyConsistency", + "startToFirstKey", + "lastKeyToEnd", + ] { + values.insert(field.into(), Value::Null); + } + } + let pb_eligible = invalid.is_none() + && host + .config + .funbox + .iter() + .all(|n| typing_engine::funboxes::get(n).is_some_and(|f| f.can_get_pb)); + Ok( + json!({ "formatVersion":1,"id":uuid::Uuid::new_v4().to_string(),"createdMs":created,"startedMs":log.events.iter().find(|e|e.kind=="timer" && e.data["event"]=="start").map(|e|e.data["date"].clone()),"endedMs":log.events.iter().find(|e|e.kind=="timer" && e.data["event"]=="end").map(|e|e.data["date"].clone()),"engineBehaviorVersion":typing_engine::BEHAVIOR_VERSION,"eventLogVersion":typing_engine::EVENT_LOG_VERSION,"buildVersion":crate::version(),"config":host.config,"mode2":mode2,"seed":host.seed,"contentIdentity":host.content_identity,"quoteId":quote.map(|q|q.id),"customLimit":limit,"capabilities":capabilities,"measurements":{"clock":"monotonic terminal event delivery","physicalKeyTiming":null},"summary":summary,"summaryError":host.result_error,"invalidReason":invalid,"personalBestEligible":pb_eligible,"personalBestGroup":hash(&(typing_engine::BEHAVIOR_VERSION,&host.config,&host.content_identity,&mode2))?,"repeated":host.repeated,"bailedOut":log.context.bailed_out,"failureReason":host.state().reason,"replayLog":if replay { Some(log) } else { None } }), + ) +} diff --git a/crates/oxytype-tui/src/terminal.rs b/crates/oxytype-tui/src/terminal.rs new file mode 100644 index 000000000..0c7d0a30b --- /dev/null +++ b/crates/oxytype-tui/src/terminal.rs @@ -0,0 +1,119 @@ +use crate::Result; +use crossterm::{ + cursor::Show, + event::{ + DisableBracketedPaste, EnableBracketedPaste, KeyboardEnhancementFlags, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + }, + execute, + terminal::{EnterAlternateScreen, enable_raw_mode, supports_keyboard_enhancement}, +}; +use ratatui::{DefaultTerminal, backend::CrosstermBackend}; +use std::{ + io::{self, IsTerminal}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +pub struct TerminalGuard { + pub quit: Arc, + pub suspend: Arc, + signals: Vec, + pub enhanced: Arc, + auto_keyboard: bool, +} +fn restore(enhanced: &AtomicBool) { + if enhanced.swap(false, Ordering::SeqCst) { + let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags); + } + let _ = execute!(io::stdout(), DisableBracketedPaste, Show); + ratatui::restore(); +} + +fn initialize(enhanced: &AtomicBool, auto_keyboard: bool) -> std::io::Result { + // Install our cleanup hook once, rather than nesting Ratatui hooks on every + // suspend/resume cycle. The guard already exists if any setup step fails. + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?; + // Query before any event poll/read. Basic terminals may time out and use F8. + if auto_keyboard && supports_keyboard_enhancement().unwrap_or(false) { + enhanced.store(true, Ordering::SeqCst); + execute!( + io::stdout(), + // Committed-text typing needs disambiguation, not key-up telemetry. + // Older Kitty versions emit duplicate legacy Backspace on release + // with REPORT_EVENT_TYPES. Keep text/IME in their ordinary UTF-8 path. + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + )?; + } + ratatui::Terminal::new(CrosstermBackend::new(io::stdout())) +} +impl TerminalGuard { + pub fn enter(auto_keyboard: bool) -> Result<(Self, DefaultTerminal)> { + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err( + "Oxytype requires an interactive terminal; use --help or --smoke-test without one" + .into(), + ); + } + if std::env::var("TERM").is_ok_and(|term| term == "dumb") { + return Err("TERM=dumb cannot render Oxytype".into()); + } + let enhanced = Arc::new(AtomicBool::new(false)); + let cleanup_enhanced = enhanced.clone(); + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore(&cleanup_enhanced); + hook(info); + })); + let mut guard = Self { + quit: Arc::new(AtomicBool::new(false)), + suspend: Arc::new(AtomicBool::new(false)), + signals: vec![], + enhanced, + auto_keyboard, + }; + for signal in [signal_hook::consts::SIGINT, signal_hook::consts::SIGTERM] { + guard + .signals + .push(signal_hook::flag::register(signal, guard.quit.clone())?); + } + #[cfg(unix)] + for (signal, flag) in [ + (signal_hook::consts::SIGHUP, guard.quit.clone()), + (signal_hook::consts::SIGTSTP, guard.suspend.clone()), + ] { + guard + .signals + .push(signal_hook::flag::register(signal, flag)?); + } + let terminal = initialize(&guard.enhanced, guard.auto_keyboard)?; + Ok((guard, terminal)) + } + pub fn resume_if_requested(&self, terminal: &mut DefaultTerminal) -> Result { + #[cfg(not(unix))] + let _ = terminal; + if !self.suspend.swap(false, Ordering::SeqCst) { + return Ok(false); + } + #[cfg(unix)] + { + restore(&self.enhanced); + // Suspend the foreground job too when launched through vp/Cargo; + // stopping only the binary leaves its launcher holding the terminal. + nix::sys::signal::killpg(nix::unistd::getpgrp(), nix::sys::signal::Signal::SIGSTOP)?; + *terminal = initialize(&self.enhanced, self.auto_keyboard)?; + } + Ok(true) + } +} +impl Drop for TerminalGuard { + fn drop(&mut self) { + restore(&self.enhanced); + for signal in self.signals.drain(..) { + signal_hook::low_level::unregister(signal); + } + } +} diff --git a/crates/oxytype-tui/tests/input_render.rs b/crates/oxytype-tui/tests/input_render.rs new file mode 100644 index 000000000..84afba550 --- /dev/null +++ b/crates/oxytype-tui/tests/input_render.rs @@ -0,0 +1,156 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use oxytype_tui::{ + content::OfflineContent, + engine::EngineHost, + input::{self, Capabilities}, + render::text_view, +}; +use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph}; +use typing_engine::{Config, Text}; + +#[test] +fn terminal_commits_repeats_once_ignores_releases_and_deletes_graphemes_in_utf16() { + let mut host = EngineHost::offline( + Config { + mode: "zen".into(), + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + None, + 42, + ) + .unwrap(); + host.insert("e\u{301}😀", 100.0, 1000.0).unwrap(); + assert_eq!(host.state().input_value.len(), 4); + let backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); + input::apply(&mut host, backspace, 200.0, 1100.0).unwrap(); + assert_eq!(host.state().input_value, Text::from("e\u{301}")); + input::apply(&mut host, backspace, 300.0, 1200.0).unwrap(); + assert!(host.state().input_value.is_empty()); + let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE); + input::apply(&mut host, key, 400.0, 1300.0).unwrap(); + input::apply( + &mut host, + KeyEvent { + kind: KeyEventKind::Repeat, + ..key + }, + 500.0, + 1400.0, + ) + .unwrap(); + let before = host.log().unwrap().events.len(); + input::apply( + &mut host, + KeyEvent { + kind: KeyEventKind::Release, + ..key + }, + 600.0, + 1500.0, + ) + .unwrap(); + assert_eq!(host.log().unwrap().events.len(), before); + assert_eq!(host.state().input_value, Text::from("xx")); + assert!( + host.log() + .unwrap() + .events + .iter() + .all(|e| !["keydown", "keyup"].contains(&e.kind.as_str())) + ); + let mut capabilities = Capabilities::default(); + capabilities.observe(KeyEvent { + kind: KeyEventKind::Release, + ..key + }); + assert!(capabilities.release_observed); + assert!(!capabilities.physical_key_timing); +} +#[test] +fn ratatui_cells_and_caret_keep_graphemes_separate_from_utf16_positions() { + let target = Text::from("e\u{301}界😀a"); + let view = text_view(&target, &Text::from("e\u{301}界"), "english", 4, 4); + assert_eq!(view.caret, (0, 1)); + let mut terminal = Terminal::new(TestBackend::new(4, 4)).unwrap(); + terminal + .draw(|frame| { + frame.render_widget(Paragraph::new(view.lines), frame.area()); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(0, 0)].symbol(), "e\u{301}"); + assert_eq!(buffer[(1, 0)].symbol(), "界"); + assert_eq!(buffer[(0, 1)].symbol(), "😀"); + assert_eq!(buffer[(2, 1)].symbol(), "a"); + let control = text_view( + &Text::from("a\tb\n界"), + &Text::from("a\tb\n"), + "english", + 8, + 4, + ); + assert_eq!(control.caret, (0, 1)); + assert!(control.lines[0].to_string().contains("→ b↵")); + let scrolled = text_view( + &Text::from("abcdefghijk"), + &Text::from("abcdefghij"), + "english", + 2, + 2, + ); + assert_eq!(scrolled.caret, (0, 1)); + assert_eq!(scrolled.lines.len(), 2); +} +#[test] +fn extra_emoji_and_error_markers_never_split_surrogates() { + let view = text_view(&Text::from("a"), &Text::from("😀"), "english", 8, 4); + assert!(view.errors); + assert!(view.lines[0].to_string().contains("😀")); + assert!(!view.lines[0].to_string().contains('\u{fffd}')); + let normalized = text_view(&Text::from("’"), &Text::from("'"), "english", 8, 4); + assert!(!normalized.errors); + let config = Config { + mode: "words".into(), + words: 5, + funbox: vec!["backwards".into()], + ..Default::default() + }; + let content = OfflineContent::parse(r#"{"formatVersion":1,"license":"CC0","attribution":"test","language":{"name":"english","words":["😀","abc"]}}"#).unwrap(); + assert!(EngineHost::offline(config, content, None, 42).is_err()); +} + +#[test] +fn stopped_letter_projects_native_visual_feedback_without_mutating_input() { + let mut host = EngineHost::offline( + Config { + stop_on_error: "letter".into(), + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + None, + 42, + ) + .unwrap(); + host.insert("☃", 100.0, 1000.0).unwrap(); + assert!(host.state().input_value.is_empty()); + assert_eq!(host.visual_input, Some(Text::from("☃"))); + assert!( + text_view( + &host.words[0], + host.visual_input.as_ref().unwrap(), + "english", + 20, + 4 + ) + .errors + ); +} + +#[test] +fn deleting_partial_surrogate_feedback_preserves_original_utf16_prefix() { + let original = Text(vec![0xd83d, 97, 0x301]); + let after = input::delete_observation(&original).unwrap(); + assert_eq!(after, Text(vec![0xd83d])); + assert!(input::delete_observation(&after).unwrap().is_empty()); +} diff --git a/crates/oxytype-tui/tests/local_history.rs b/crates/oxytype-tui/tests/local_history.rs new file mode 100644 index 000000000..6adbc205a --- /dev/null +++ b/crates/oxytype-tui/tests/local_history.rs @@ -0,0 +1,92 @@ +use oxytype_tui::{ + content::OfflineContent, + engine::EngineHost, + input::Capabilities, + local::Local, + replay::Replay, + storage::{Paths, Settings, Store}, +}; +use typing_engine::{Config, replay::Cursor}; + +fn finished() -> EngineHost { + let mut host = EngineHost::offline( + Config { + mode: "words".into(), + words: 10, + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + None, + 42, + ) + .unwrap(); + for i in 0..10 { + let word = host.words[i].display_lossy(); + host.insert(&word, 100.0 + i as f64 * 1000.0, 1100.0 + i as f64 * 1000.0) + .unwrap(); + } + host +} +#[test] +fn failed_write_queue_retries_the_same_uuid_once_and_history_filters_page() { + let temp = tempfile::tempdir().unwrap(); + let paths = Paths { + config: temp.path().join("config"), + data: temp.path().join("data"), + }; + let store = Store::open(&paths).unwrap(); + let mut local = Local::new(store, Settings::default()); + let host = finished(); + let lock = rusqlite::Connection::open(paths.data.join("history.sqlite3")).unwrap(); + lock.execute_batch("BEGIN IMMEDIATE").unwrap(); + assert!( + local + .capture(&host, &Capabilities::default(), 10000.0) + .is_err() + ); + assert_eq!(local.pending.len(), 1); + let id = local.pending[0]["id"].clone(); + assert!( + !local + .capture(&host, &Capabilities::default(), 10001.0) + .unwrap() + ); + lock.execute_batch("ROLLBACK").unwrap(); + local.retry().unwrap(); + assert!(local.pending.is_empty()); + local.history.refresh(&local.store).unwrap(); + assert_eq!(local.history.rows[0]["id"], id); + local.history.filter(&local.store).unwrap(); + assert!(local.history.rows.is_empty()); // time + local.history.filter(&local.store).unwrap(); + assert_eq!(local.history.rows.len(), 1); // words + local.history.page(1, &local.store).unwrap(); + assert_eq!(local.history.rows.len(), 1); + assert!(local.store.history_page(None, 100, 1).unwrap().is_empty()); +} +#[test] +fn replay_requires_opt_in_and_compatible_versions_and_uses_native_steps_and_seek() { + let host = finished(); + let cap = Capabilities::default(); + let summary_only = oxytype_tui::storage::record(&host, &cap, false, 10000.0).unwrap(); + assert!(Replay::new(&summary_only).is_err()); + let record = oxytype_tui::storage::record(&host, &cap, true, 10000.0).unwrap(); + let mut replay = Replay::new(&record).unwrap(); + let mut steps = 0; + while replay.frame.next_time.is_some() { + replay.step(); + steps += 1; + } + assert!(steps > 10); + assert_eq!(replay.frame.next_index, steps); + replay.seek(Cursor::default()); + assert_eq!(replay.frame.next_index, 0); + replay.seek(Cursor { + word_index: 5, + letter_index: 0, + }); + assert_eq!(replay.frame.cursor.word_index, 5); + let mut future = record; + future["engineBehaviorVersion"] = serde_json::json!(99); + assert!(Replay::new(&future).is_err()); +} diff --git a/crates/oxytype-tui/tests/native_host.rs b/crates/oxytype-tui/tests/native_host.rs new file mode 100644 index 000000000..fb57eca72 --- /dev/null +++ b/crates/oxytype-tui/tests/native_host.rs @@ -0,0 +1,47 @@ +use oxytype_tui::engine::EngineHost; +use typing_engine::{Config, Text, generation::Language}; + +#[test] +fn native_host_preserves_input_clocks_repeat_targets_and_missing_keyboard_data() { + let mut host = EngineHost::new( + Config { + mode: "words".into(), + words: 2, + ..Default::default() + }, + Language { + name: "english".into(), + words: ["a", "b", "c", "d"].into_iter().map(Text::from).collect(), + ..Default::default() + }, + None, + 42, + ) + .unwrap(); + let original = host.words.clone(); + host.insert(&original[0].display_lossy(), 100.0, 10_100.0) + .unwrap(); + assert_eq!(host.state().word_index, 1); + assert_eq!(host.inputs[0], original[0]); + host.insert(&original[1].display_lossy(), 1100.0, 11_100.0) + .unwrap(); + assert!(host.state().ended); + let result = host.result.as_ref().unwrap(); + assert_eq!(result.wpm, 36.0); + assert_eq!(result.acc, 100.0); + assert_eq!(result.test_duration, 1.0); + assert!(result.key_duration.is_empty()); + assert!( + host.log() + .unwrap() + .events + .iter() + .all(|event| event.kind != "keydown" && event.kind != "keyup") + ); + host.restart(true, 1200.0, 11_200.0).unwrap(); + assert_eq!(host.words, original); + assert_eq!(host.seed, 42); + assert!(!host.state().active); + assert!(!host.state().ended); + assert!(host.inputs.is_empty()); +} diff --git a/crates/oxytype-tui/tests/offline_content.rs b/crates/oxytype-tui/tests/offline_content.rs new file mode 100644 index 000000000..d9cbd12bf --- /dev/null +++ b/crates/oxytype-tui/tests/offline_content.rs @@ -0,0 +1,227 @@ +use clap::Parser; +use oxytype_tui::{ + content::OfflineContent, + engine::EngineHost, + options::{Cli, FUNBOXES, custom_text}, +}; +use typing_engine::{Command, Config}; + +#[test] +fn bundled_modes_run_without_files_or_network() { + for mode in ["words", "time", "quote", "custom", "zen"] { + let content = OfflineContent::bundled().unwrap(); + assert_eq!(content.language.words.len(), 200); + let custom = (mode == "custom") + .then(|| custom_text("one two three", "repeat", "section", 1.0).unwrap()); + let mut host = EngineHost::offline( + Config { + mode: mode.into(), + words: 3, + time: 1.0, + ..Default::default() + }, + content, + custom, + 42, + ) + .unwrap(); + if mode == "zen" { + host.insert("hello ", 100.0, 1000.0).unwrap(); + host.command(Command::Bailout { + now: 1100.0, + date: 2000.0, + }) + .unwrap(); + } else if mode == "time" { + let word = host.words[0].display_lossy(); + host.insert(&word, 100.0, 1000.0).unwrap(); + host.command(Command::Advance { + now: 1200.0, + date: 2100.0, + }) + .unwrap(); + } else { + for index in 0..100 { + if host.state().ended { + break; + } + let word = host.words[index].display_lossy(); + host.insert( + &word, + 100.0 + index as f64 * 1000.0, + 1000.0 + index as f64 * 1000.0, + ) + .unwrap(); + } + } + assert!(host.state().ended, "{mode}"); + assert!(host.result.is_some(), "{mode}: {:?}", host.result_error); + let log = host.log().unwrap(); + assert!( + log.events + .iter() + .all(|e| !["keydown", "keyup"].contains(&e.kind.as_str())) + ); + assert_eq!( + serde_json::to_value(host.result.unwrap()).unwrap(), + serde_json::to_value(typing_engine::stats::typed_summary(&log).unwrap()).unwrap() + ); + } +} +#[test] +fn all_bundled_quote_groups_and_advertised_transforms_prepare() { + for group in 0..=3 { + let host = EngineHost::offline( + Config { + mode: "quote".into(), + quote_length: vec![group], + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + None, + 42, + ) + .unwrap(); + assert!(!host.words.is_empty()); + } + for name in FUNBOXES { + let host = EngineHost::offline( + Config { + mode: "words".into(), + words: 5, + funbox: vec![(*name).into()], + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + None, + 42, + ) + .unwrap(); + assert_eq!(host.words.len(), 5, "{name}"); + } +} +#[test] +fn cli_uses_core_defaults_and_rejects_unavailable_features() { + let (config, _, _) = Cli::parse_from(["oxytype"]) + .prepare(Config::default()) + .unwrap(); + assert_eq!( + serde_json::to_value(config).unwrap(), + serde_json::to_value(Config::default()).unwrap() + ); + for args in [ + vec!["oxytype", "--language", "missing"], + vec!["oxytype", "--mode", "custom"], + vec!["oxytype", "--funbox", "tts"], + vec!["oxytype", "--time", "NaN"], + vec!["oxytype", "--quote-length", "-2"], + ] { + let parsed = Cli::try_parse_from(args); + assert!(parsed.is_err() || parsed.unwrap().prepare(Config::default()).is_err()); + } + let (config, _, custom) = Cli::parse_from([ + "oxytype", + "--text", + "one two", + "--custom-order", + "shuffle", + "--custom-limit", + "word", + "--custom-length", + "7", + ]) + .prepare(Config::default()) + .unwrap(); + assert_eq!(config.mode, "custom"); + assert_eq!(custom.unwrap().limit.value, 7.0); +} +#[test] +fn local_pack_validation_is_lossless_utf16_and_bounded() { + let pack = serde_json::json!({"formatVersion":1,"license":"CC0-1.0","attribution":"Test","language":{"name":"test","words":["e\u{301}","界","😀"]},"quotes":[{"id":1,"text":"😀 界","source":"Test"}]}); + let content = OfflineContent::parse(&pack.to_string()).unwrap(); + assert_eq!(content.language.words[2].0, vec![0xd83d, 0xde00]); + for patch in [ + serde_json::json!({"formatVersion":2}), + serde_json::json!({"language":{"name":"test","words":["\u{1b}[31m"]}}), + serde_json::json!({"license":""}), + serde_json::json!({"quotes":[{"id":1,"text":"a","source":"x"},{"id":1,"text":"b","source":"x"}]}), + ] { + let mut bad = pack.clone(); + for (key, value) in patch.as_object().unwrap() { + bad[key] = value.clone(); + } + assert!(OfflineContent::parse(&bad.to_string()).is_err()); + } + assert!(OfflineContent::parse(&" ".repeat(8 * 1024 * 1024 + 1)).is_err()); +} + +#[test] +fn custom_crlf_newline_tab_and_non_bmp_text_remain_typable() { + let custom = + oxytype_tui::options::custom_text_auto("one\r\n\t界😀", "repeat", "word", None).unwrap(); + let mut host = EngineHost::offline( + Config { + mode: "custom".into(), + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + Some(custom), + 42, + ) + .unwrap(); + assert_eq!(host.words[0], typing_engine::Text::from("one\n")); + host.insert("one", 100.0, 1000.0).unwrap(); + host.insert("\n", 1100.0, 2000.0).unwrap(); + assert_eq!(host.state().word_index, 1); + host.insert("\t界😀", 2100.0, 3000.0).unwrap(); + assert!(host.state().ended); + assert_eq!(host.result.unwrap().acc, 100.0); +} + +#[test] +fn default_custom_uses_the_whole_passage_and_explicit_zero_stays_infinite() { + let (config, content, custom) = Cli::parse_from(["oxytype", "--text", "one two three"]) + .prepare(Config::default()) + .unwrap(); + assert_eq!(custom.as_ref().unwrap().limit.value, 3.0); + assert_eq!( + EngineHost::offline(config, content, custom, 42) + .unwrap() + .words + .len(), + 3 + ); + let (_, _, custom) = Cli::parse_from(["oxytype", "--text", "one two", "--custom-length", "0"]) + .prepare(Config::default()) + .unwrap(); + assert_eq!(custom.unwrap().limit.value, 0.0); +} + +#[test] +fn plain_text_separators_and_bom_do_not_truncate_non_bmp_units() { + let custom = oxytype_tui::options::custom_text_auto( + "\u{feff}one\u{a0}two\u{200b}\u{12000}", + "repeat", + "word", + None, + ) + .unwrap(); + assert_eq!(custom.text.len(), 3); + assert_eq!(custom.text[2], typing_engine::Text::from("\u{12000}")); + let mut host = EngineHost::offline( + Config { + mode: "custom".into(), + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + Some(custom), + 42, + ) + .unwrap(); + for (i, text) in ["one ", "two ", "\u{12000}"].iter().enumerate() { + host.insert(text, 100.0 + i as f64 * 1000.0, 1000.0 + i as f64 * 1000.0) + .unwrap(); + } + assert!(host.state().ended); + assert_eq!(host.result.unwrap().acc, 100.0); +} diff --git a/crates/oxytype-tui/tests/portable_terminal.rs b/crates/oxytype-tui/tests/portable_terminal.rs new file mode 100644 index 000000000..ba9e8c9a8 --- /dev/null +++ b/crates/oxytype-tui/tests/portable_terminal.rs @@ -0,0 +1,171 @@ +//! Actual native terminal event decoding on the supported Linux/macOS PTYs. +use oxytype_tui::storage::{Paths, Store}; +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use std::{ + io::{Read, Write}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +fn wait_screen( + parser: &Arc>, + trace: &Arc>>, + child: &mut ChildGuard, + needle: &str, +) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let screen = parser.lock().unwrap().screen().contents(); + if screen.contains(needle) { + return; + } + let exit = child.0.try_wait().unwrap(); + assert!( + exit.is_none() && Instant::now() < deadline, + "Missing {needle:?}; exit={exit:?}; screen={screen:?}; raw={:?}", + String::from_utf8_lossy(&trace.lock().unwrap()) + ); + std::thread::sleep(Duration::from_millis(10)); + } +} +struct ChildGuard(Box); +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[test] +fn native_terminal_decodes_edits_and_persists_actual_input_without_physical_telemetry() { + let temp = tempfile::tempdir().unwrap(); + let paths = Paths { + config: temp.path().join("config"), + data: temp.path().join("data"), + }; + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 100, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let parser = Arc::new(Mutex::new(vt100::Parser::new(24, 100, 0))); + let mut reader = pair.master.try_clone_reader().unwrap(); + let output = parser.clone(); + let trace = Arc::new(Mutex::new(Vec::new())); + let raw = trace.clone(); + let writer = Arc::new(Mutex::new(pair.master.take_writer().unwrap())); + let responses = writer.clone(); + let reading = std::thread::spawn(move || { + let mut buffer = [0u8; 8192]; + let mut query = std::collections::VecDeque::new(); + loop { + let count = match reader.read(&mut buffer) { + Ok(count) => count, + Err(error) => { + eprintln!("PTY reader ended: {error}"); + break; + } + }; + if count == 0 { + break; + } + { + let mut trace = raw.lock().unwrap(); + trace.extend_from_slice(&buffer[..count]); + let excess = trace.len().saturating_sub(16384); + trace.drain(..excess); + } + let mut parser = output.lock().unwrap(); + for byte in &buffer[..count] { + parser.process(&[*byte]); + query.push_back(*byte); + if query.len() > 4 { + query.pop_front(); + } + if query.make_contiguous() == b"\x1b[6n" { + // ConPTY inherits its host cursor and asks the emulator for + // its actual position. Reply from the parsed screen state; + // this is terminal protocol, never typing/timing telemetry. + let (row, col) = parser.screen().cursor_position(); + let mut reply = responses.lock().unwrap(); + let response = format!("\x1b[{};{}R", row + 1, col + 1); + // One complete protocol packet, rather than write_fmt's + // separate writes for CSI prefix, numbers and terminator. + reply.write_all(response.as_bytes()).unwrap(); + reply.flush().unwrap(); + eprintln!("PTY cursor response: {response:?}"); + } + } + } + }); + let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_oxytype")); + command.args([ + "--keyboard", + "basic", + "--mode", + "custom", + "--text", + "one two", + "--record-replay", + "--config-dir", + ]); + command.arg(&paths.config); + command.arg("--data-dir"); + command.arg(&paths.data); + command.env("TERM", "xterm-256color"); + let mut child = ChildGuard(pair.slave.spawn_command(command).unwrap()); + drop(pair.slave); + wait_screen(&parser, &trace, &mut child, "Input:"); + writer.lock().unwrap().write_all(b"one twx").unwrap(); + writer.lock().unwrap().flush().unwrap(); + wait_screen(&parser, &trace, &mut child, "Input: twx"); + writer.lock().unwrap().write_all(b"\x7f").unwrap(); + writer.lock().unwrap().flush().unwrap(); + wait_screen(&parser, &trace, &mut child, "Input: tw "); + assert!( + !parser + .lock() + .unwrap() + .screen() + .contents() + .contains("Input: twx") + ); + std::thread::sleep(Duration::from_millis(1100)); + writer.lock().unwrap().write_all(b"o").unwrap(); + writer.lock().unwrap().flush().unwrap(); + wait_screen(&parser, &trace, &mut child, "Result saved locally."); + writer.lock().unwrap().write_all(b"\x1bOR").unwrap(); + writer.lock().unwrap().flush().unwrap(); + wait_screen(&parser, &trace, &mut child, "Local history"); + writer.lock().unwrap().write_all(b"\x1b[21~").unwrap(); + writer.lock().unwrap().flush().unwrap(); // F10 works with basic keyboard reports. + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.0.try_wait().unwrap() { + assert!(status.success()); + break; + } + assert!(Instant::now() < deadline, "App did not quit"); + std::thread::sleep(Duration::from_millis(10)); + } + drop(writer); + drop(pair.master); + drop(child); + reading.join().unwrap(); + let records = Store::open(&paths).unwrap().history(None, 100).unwrap(); + assert_eq!(records.len(), 1); + let record = &records[0]; + assert_eq!(record["summary"]["acc"], 87.5); + assert!(record["summary"]["keyDuration"].is_null()); + let log: typing_engine::stats::Log = + serde_json::from_value(record["replayLog"].clone()).unwrap(); + assert!( + log.events + .iter() + .all(|event| !["keydown", "keyup"].contains(&event.kind.as_str())) + ); + assert_eq!(typing_engine::stats::typed_summary(&log).unwrap().acc, 87.5); +} diff --git a/crates/oxytype-tui/tests/setup.rs b/crates/oxytype-tui/tests/setup.rs new file mode 100644 index 000000000..b2fb80c9d --- /dev/null +++ b/crates/oxytype-tui/tests/setup.rs @@ -0,0 +1,28 @@ +use oxytype_tui::{content::OfflineContent, setup::Setup}; +use typing_engine::Config; + +#[test] +fn settings_edit_drafts_and_apply_the_same_native_configuration() { + let content = OfflineContent::bundled().unwrap(); + let mut setup = Setup::new(Config::default(), None); + let original = setup.build(&content, 42).unwrap(); + setup.adjust(1); // time -> words + setup.select(1); + setup.adjust(-1); // 50 -> 40 + let changed = setup.build(&content, 42).unwrap(); + assert_eq!(original.config.mode, "time"); + assert_eq!(changed.config.mode, "words"); + assert_eq!(changed.words.len(), 40); + setup.config.mode = "custom".into(); + assert!(setup.build(&content, 42).is_err()); + setup.text = "one two".into(); + setup.cycle_order(); + setup.cycle_limit(); + assert_eq!(setup.limit, "time"); + setup.limit = "word".into(); + setup.length = Some(7.0); + assert_eq!(setup.build(&content, 42).unwrap().words.len(), 7); + setup.config.funbox = vec!["tts".into()]; + assert!(setup.build(&content, 42).is_err()); + assert_eq!(changed.words.len(), 40); // Failed draft never replaces running host. +} diff --git a/crates/oxytype-tui/tests/storage.rs b/crates/oxytype-tui/tests/storage.rs new file mode 100644 index 000000000..fd4388843 --- /dev/null +++ b/crates/oxytype-tui/tests/storage.rs @@ -0,0 +1,218 @@ +use oxytype_tui::{ + content::OfflineContent, + engine::EngineHost, + input::Capabilities, + storage::{self, Paths, Store}, +}; +use serde_json::{Value, json}; +use typing_engine::Config; + +fn paths(root: &std::path::Path) -> Paths { + Paths { + config: root.join("config"), + data: root.join("data"), + } +} +fn finished() -> EngineHost { + let mut host = EngineHost::offline( + Config { + mode: "words".into(), + words: 10, + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + None, + 42, + ) + .unwrap(); + for i in 0..10 { + let word = host.words[i].display_lossy(); + host.insert(&word, 100.0 + i as f64 * 1000.0, 1100.0 + i as f64 * 1000.0) + .unwrap(); + } + assert!(host.state().ended); + host +} +#[test] +fn restart_persistence_idempotency_private_defaults_and_lossless_export() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(temp.path()); + let mut store = Store::open(&paths).unwrap(); + let mut settings = store.settings().unwrap(); + settings.config.words = 25; + store.save_settings(&settings).unwrap(); + let host = finished(); + let record = storage::record(&host, &Capabilities::default(), false, 10000.0).unwrap(); + assert!(record["replayLog"].is_null()); + for field in [ + "keySpacing", + "keyDuration", + "keyOverlap", + "keyConsistency", + "startToFirstKey", + "lastKeyToEnd", + ] { + assert!(record["summary"][field].is_null()); + } + assert_eq!( + record["summary"]["wpm"], + serde_json::to_value(host.result.as_ref().unwrap().wpm).unwrap() + ); + assert_eq!(record["personalBestEligible"], true); + store.save(&record).unwrap(); + assert_eq!( + store + .best(record["personalBestGroup"].as_str().unwrap()) + .unwrap(), + host.result.as_ref().map(|s| s.wpm) + ); + store.save(&record).unwrap(); + drop(store); + let mut store = Store::open(&paths).unwrap(); + assert_eq!(store.settings().unwrap().config.words, 25); + let history = store.history(Some("words"), 100).unwrap(); + assert_eq!(history, vec![record.clone()]); + assert!(store.history(Some("zen"), 100).unwrap().is_empty()); + let mut future = record.clone(); + future["id"] = json!("future"); + future["formatVersion"] = json!(99); + future["unknownFutureField"] = json!({"preserve":true}); + store.save(&future).unwrap(); + let output = temp.path().join("export.json"); + store.export(&output, None).unwrap(); + let exported: Value = serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap(); + assert!(exported["results"].as_array().unwrap().contains(&future)); + assert!(store.export(&output, None).is_err()); // Never overwrite a prior export. + let mut collision = record.clone(); + collision["seed"] = json!(99); + assert!(store.save(&collision).is_err()); + assert_eq!(store.history(None, 100).unwrap().len(), 2); + let replay = storage::record(&host, &Capabilities::default(), true, 11000.0).unwrap(); + assert_eq!( + replay["replayLog"], + serde_json::to_value(host.log().unwrap()).unwrap() + ); +} +#[test] +fn concurrent_settings_use_revision_checks_and_result_writes_do_not_clobber() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(temp.path()); + let mut a = Store::open(&paths).unwrap(); + let mut b = Store::open(&paths).unwrap(); + let mut settings = a.settings().unwrap(); + b.settings().unwrap(); + settings.config.words = 25; + a.save_settings(&settings).unwrap(); + assert!(b.save_settings(&settings).is_err()); + assert_eq!(b.settings().unwrap().config.words, 25); + let base = storage::record(&finished(), &Capabilities::default(), false, 10000.0).unwrap(); + let workers: Vec<_> = (0..8) + .map(|i| { + let paths = paths.clone(); + let mut record = base.clone(); + record["id"] = json!(format!("worker-{i}")); + std::thread::spawn(move || { + Store::open(&paths).unwrap().save(&record).unwrap(); + }) + }) + .collect(); + for worker in workers { + worker.join().unwrap(); + } + assert_eq!(a.history(None, 100).unwrap().len(), 8); +} +#[test] +fn malformed_future_and_interrupted_storage_preserve_prior_data() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(temp.path()); + let mut store = Store::open(&paths).unwrap(); + store.settings().unwrap(); + drop(store); + let dbpath = paths.data.join("history.sqlite3"); + let mut db = rusqlite::Connection::open(&dbpath).unwrap(); + { + let tx = db.transaction().unwrap(); + tx.execute( + "INSERT INTO results VALUES('interrupted',1,'words','{}')", + [], + ) + .unwrap(); + } // Uncommitted write rolled back. + assert!( + Store::open(&paths) + .unwrap() + .history(None, 100) + .unwrap() + .is_empty() + ); + db.pragma_update(None, "user_version", 99).unwrap(); + drop(db); + assert!(Store::open(&paths).is_err()); + assert_eq!( + rusqlite::Connection::open(&dbpath) + .unwrap() + .pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0)) + .unwrap(), + 99 + ); + let bad = temp.path().join("malformed"); + std::fs::create_dir_all(&bad).unwrap(); + let path = bad.join("settings.sqlite3"); + std::fs::write(&path, b"not a database").unwrap(); + assert!( + Store::open(&Paths { + config: bad, + data: paths.data + }) + .is_err() + ); + assert_eq!(std::fs::read(path).unwrap(), b"not a database"); +} + +#[test] +#[ignore = "Helper launched and terminated by abrupt_process_interruption_keeps_committed_history"] +fn interrupted_writer_process() { + let path = std::env::var_os("OXYTYPE_INTERRUPTION_DB").expect("Test database path"); + let ready = std::env::var_os("OXYTYPE_INTERRUPTION_READY").expect("Test readiness path"); + let db = rusqlite::Connection::open(path).unwrap(); + db.execute_batch( + "BEGIN IMMEDIATE; INSERT INTO results VALUES('interrupted-process',1,'words','{}');", + ) + .unwrap(); + std::fs::write(ready, b"transaction open").unwrap(); + loop { + std::thread::park(); + } +} + +#[test] +fn abrupt_process_interruption_keeps_committed_history() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(temp.path()); + let mut store = Store::open(&paths).unwrap(); + let record = storage::record(&finished(), &Capabilities::default(), false, 10000.0).unwrap(); + store.save(&record).unwrap(); + drop(store); + let ready = temp.path().join("writer-ready"); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--ignored", "--exact", "interrupted_writer_process"]) + .env( + "OXYTYPE_INTERRUPTION_DB", + paths.data.join("history.sqlite3"), + ) + .env("OXYTYPE_INTERRUPTION_READY", &ready) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !ready.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let initialized = ready.exists(); + child.kill().unwrap(); + child.wait().unwrap(); + assert!(initialized, "Writer did not begin transaction"); + assert_eq!( + Store::open(&paths).unwrap().history(None, 100).unwrap(), + vec![record] + ); +} diff --git a/docs/rust-engine/archive/terminal-artifacts-40068412137.json b/docs/rust-engine/archive/terminal-artifacts-40068412137.json new file mode 100644 index 000000000..81a5c7956 --- /dev/null +++ b/docs/rust-engine/archive/terminal-artifacts-40068412137.json @@ -0,0 +1,39 @@ +{ + "formatVersion": 1, + "workflowRun": "https://github.com/voltcrash/oxytype/actions/runs/40068412137", + "sourceHead": "5703c1f10bd949478d3801d027965ac2214f3738", + "artifactSourceRevision": "b62151798e1c1f469d13501a1cf9479a83bdca0b", + "status": "All five native build/test/archive smoke jobs and corresponding-source job passed; downloaded hashes and cross-archive source pairing verified. Candidate CI artifacts only; no release published.", + "artifacts": [ + { + "file": "oxytype-v0.1.0-aarch64-apple-darwin.tar.gz", + "bytes": 1579709, + "sha256": "4cb2dfea5e35ea3564a490c33c60dd92493fb03680ed3a5aa6c9f82213622c48" + }, + { + "file": "oxytype-v0.1.0-aarch64-unknown-linux-gnu.tar.gz", + "bytes": 1635357, + "sha256": "786ec2a486c8de0903e89e261b7112ee41baf73649cbfcc937561dc87fd0502a" + }, + { + "file": "oxytype-v0.1.0-source.tar.gz", + "bytes": 72362837, + "sha256": "5e42065bf844eeb2be2bef9b419ca2b23558f6c57f8425e7f32d1dffcf78fcb9" + }, + { + "file": "oxytype-v0.1.0-x86_64-apple-darwin.tar.gz", + "bytes": 1651285, + "sha256": "9d97b757d2e47886c24e07eb51e1c57b21425535ad713788ede29787664a75a7" + }, + { + "file": "oxytype-v0.1.0-x86_64-pc-windows-msvc.zip", + "bytes": 2077503, + "sha256": "db476da1059a4f1605907d89e7ef0e36b2f5e6b04d8822fff0e6c1fcb700c43b" + }, + { + "file": "oxytype-v0.1.0-x86_64-unknown-linux-gnu.tar.gz", + "bytes": 1640282, + "sha256": "34aa336e8a50b18bc84d07e04ae01657f5192b4dee025bc6cd1869c4134c6db8" + } + ] +} diff --git a/docs/rust-engine/decisions.md b/docs/rust-engine/decisions.md index 4cb2c64fa..78efceaf3 100644 --- a/docs/rust-engine/decisions.md +++ b/docs/rust-engine/decisions.md @@ -114,3 +114,32 @@ restores Infinity only for presentation. Completed metrics retain strict finite validation. Do not allocate an intermediate Option array solely for JSON conversion. Historical capture sources that refer to removed functions are archived as inert text; parity CI runs directly against immutable outputs without the old engine. + + +### Terminal scope and merge gate superseded by owner + +After merging PRs #184/#185, the owner explicitly requested Linux/macOS only, +Kitty-compatible terminal coverage, and PR #186 ready for merge. Windows is removed +from release targets and verification requirements; historical CI evidence remains +an immutable record of earlier scope. Linux/macOS Intel/ARM64 are the four targets. +The owner will verify macOS manually after merge; recruiting external testers is +not a pre-merge requirement. Automated native/archive and real Linux emulator +checks govern merge readiness. Unperformed macOS/IME checks stay pending and are +not reported as passed. Release publication remains separately authorized/gated. +The application renders text; Kitty graphics support is independent of keyboard +protocol support, negotiated without terminal-brand or TERM-name assumptions. + +Linux's former manual/contributor gate is replaced by required real-emulator CI; +macOS is the sole manual publication gate, owned by the user after merge. The +publisher depends on native builds, both Linux emulator jobs and corresponding +source, then checks the nonempty macOS record. Physical Linux IME/font inspection +remains explicitly unperformed; it is not an implicit external tester requirement. + +### Minimal Kitty keyboard enhancement + +Real Kitty 0.32.2 emits duplicate legacy Backspace deliveries when release reports +are requested. The app only needs key disambiguation; auto now requests flag 1, +leaving text/IME on ordinary UTF-8. It does not request event types/all keys/text +association. Repeats remain one insertion per delivery; unobserved event kinds +remain false/null in history. Explicit release/repeat wire fixtures still verify +correct handling if such packets arrive. Core input/scoring rules are unchanged. diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index ee71a14a4..455099921 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -12,9 +12,13 @@ empty commits to inflate count. Do not publish production or releases implicitly ## Current position -Stage 1 and Stage 2 gates passed. Stage 3 begins next on a branch stacked above `stack/rust-engine-website`. Read root AGENTS.md and the complete migration -plan before edits. Installed workspace dependencies with frozen lockfile. -Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen oracle and mismatch detection. Existing website engine remains authoritative until Stage 2. +Stages 1/2 merged into main (#184/#185). PR #186 now targets main. Owner revised +terminal scope to Linux/macOS only and explicitly requested merge readiness with +macOS manual verification after merge. Windows targets removed; no contributor +or external-machine requirement blocks merge. Real Linux Kitty/Alacritty/Ghostty +regressions, four-target CI and full website CI pass at runtime source 6f6b25f7f. Release publication remains a +separate authorized action; pending manual checks are never fabricated. +Earlier entries below are chronological evidence, superseded where scope changed. | Phase | Status | Evidence / remaining gate | | --- | --- | --- | @@ -24,14 +28,18 @@ Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen o | 1.4 Input / lifecycle / timing | Complete | Native/built-Wasm traces, observed browser input, delayed completion, stale sources, reset/restart and 12,800 randomized commands. | | 1.5 Generation / configuration | Complete | 61 original snapshots; section/fractional/live generation; all funbox policies; quote refresh RNG; typed recovery and bounded malformed content. | | 1.6 Events / results / replay | Complete | Eight exact original CompletedEvents/hashes/schema/backend outcomes, replay/seek, Unicode history, 1,387 live prefixes and malformed-log validation. | -| 1.7 Binding / engine gate | Complete | 24 built-browser cases, unchanged results/hashes, frozen payload/performance limits, fresh production build/typechecks; CI engine/native/Wasm and quality/package/SSR/delivery/update jobs pass at e7b7ad864. Existing website browser shards still running when this record was written. | +| 1.7 Binding / engine gate | Complete | Native/browser frozen parity, payload/performance, fresh builds/types and full CI passed; final Stage 1 CI 39932792599. | | 2.1 Browser owner / loading | Complete | App-owned loader/content/session resource; 51 unit cases; actual Cloudflare delayed-download/503/retry browser checks pass; server bundles contain no Wasm initializer. | | 2.2 Input / timer projection | Complete | Native website loop; 1,575 unit cases, 19 built typing parity cases and 8 loading/input/IME/commit browser checks pass. | | 2.3 Generation / settings consumers | Complete | Native generation/catalog/settings/restart/practice; source cancellation/recovery; SSR-safe settings extraction; 1,577 unit tests and built-browser gates pass. | | 2.4 Results / replay | Complete | Native validity/progress/history/charts/inspector/playback; unit/JSX, frozen parity, save retry and real Start/D1 persistence pass. | | 2.5 Delivery / performance | Complete | Fresh Cloudflare build/preview/types, 86 Start checks, update/rollback, full website and controlled Wasm budgets pass. | | 2.6 TypeScript retirement | Complete | Legacy package removed; full native/Wasm/website/quality CI, fresh build/types and frozen performance gates pass. | -| 3.1–3.5 Terminal | Pending | Requires Stage 2 gate. | +| 3.1 Terminal shell | Complete | Native smoke/score test; real PTY typing, finish/restart, resize, error/panic, SIGTERM and suspend/resume pass. | +| 3.2 Offline content | Complete | Bundled words/eight original quotes; five modes, CLI/settings/editor, bounded v1 packs; native/full-fixture and actual PTY checks pass. | +| 3.3 Input / Unicode | Complete for revised merge scope | Real Kitty 0.32.2/0.49.2, Alacritty 0.17.0 and Ghostty 1.3.1 auto/basic pass; native UTF-16/TestBackend/wire/PTY pass. Mac manual deferred by owner. | +| 3.4 Settings / history | Complete | SQLite reopen/concurrency/corruption/retry/export and actual history/replay PTY pass. | +| 3.5 Packaging / release | Merge gates complete; publication gated | Four Linux/macOS targets, two real emulator jobs/source pass CI 40085257392; full CI 40085257400 passes. Hash/source pairing verified. Owner Mac manual record remains pending only for publication. | ## Verification log @@ -64,13 +72,19 @@ Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen o ## Blockers -None established. Missing verification is pending work, not a passed gate. +No external blocker under the owner's revised merge requirements. macOS manual +checks will be performed by the owner after merge; Windows is out of scope. +All available merge checks pass at runtime source 6f6b25f7f. macOS manual +verification remains pending for later publication, owned by the user after +merge. No external contributor, Windows machine or physical Linux tester is +required by the revised merge scope. No manual check is falsely marked passed. ## Resume -Read this file, inventory, contract, decisions, then `vp exec git status --short`. -Continue the first incomplete gate. Record commands and actual outcomes here; -do not rerun completed checks without a new reason. +Implementation and verification are complete for the revised merge scope. +Only final evidence commits/PR readiness and their CI remain to settle. Do not +redo completed engine/website/native work. After merge, follow terminal-macos-check.md; +publication still requires its actual record and separately authorized version tag. ## Latest implementation evidence @@ -533,3 +547,279 @@ Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: bro claimed. Local matched-artifact rollback/offline/save checks pass. - Continue Stage 3 immediately. Native Ratatui implementation, capabilities, local history and platform release gates remain required; do not stop at this stage. + +## Phase 3.1 gate passed + +- `cd94afa0c`: native Ratatui shell, direct owned engine sessions and display deltas; + input timestamps captured after terminal read using `Instant`. No physical-key + events or durations invented. `92c1a5203`: vp terminal tasks and real PTY checks. +- `vp run terminal:test`: native score/projection/repeat regression passes. + `vp run terminal:dev -- --smoke-test`: deterministic synthetic smoke passes, + never persisted. `vp exec cargo clippy -p oxytype-tui --all-targets -- -D warnings`: pass. +- `vp run terminal:test:pty`: actual POSIX PTY passes typing/finish/restart/resize, + normal quit, injected error/panic, SIGTERM and suspend/resume. Raw/canonical + mode and alternate screen restored. Process-group suspend covers vp launcher. +- CLI help/version pass without a terminal; interactive invocation without a + terminal fails clearly (exit 1). Idle loop polls and redraws only when dirty; + active timer draws at 10 Hz. Minimum size is 60×18. +- This is the shell gate only. Current settings/history are temporary in-memory + screens. Full offline content, input/rendering, durable storage and packaging + remain required. Linux PTY coverage does not claim real macOS/Windows hardware. + +## Phase 3.2 gate passed + +- Bundled exact 200-word English asset plus eight original GPL-3.0-only practice + passages spanning all four native quote groups. Attribution/source hash retained; + binary embeds content. No first-run download. Local UTF-8 v1 packs/text files + bounded at 8 MiB; invalid encoding, controls, metadata, duplicate IDs and unknown + versions rejected. No unavailable-language fallback. +- Time/words/quote/custom/zen, custom ordering/limits, punctuation/numbers and + applicable difficulty/error settings use native Config/generation/session/stats. + CLI and settings draft share validation. Private editor accepts setup paste; + unsupported web hooks and conflicting forced settings rejected explicitly. +- `vp run engine:test`: full native workspace/frozen-fixture suite passes unchanged. + Six terminal integration tests pass (all modes/groups/transforms, exact known + score/repeat, invalid packs/options, draft application). Clippy passes. +- `vp run terminal:test:pty`: five actual offline modes, settings/editor/paste and + lifecycle checks pass. Test reader now reconstructs incremental VT cells because + unchanged characters are not re-emitted by Ratatui; no app behavior weakened. +- Accurate grapheme/cell rendering, negotiated capabilities and durable history + remain next. No native physical-key timing is inferred from committed input. + +## Phase 3.3 implementation / Linux checks + +- Grapheme Backspace supplies observed UTF-16 text to native deletion; releases + never become input and each repeat delivery commits once. Native rules retain + eligibility/progression. Isolated-surrogate generated targets fail explicitly. +- Cell-aware target rendering handles combining marks, CJK, emoji, visible Tab/LF, + wrapping, caret scrolling and color-independent underlined error notice. Input + disabled below minimum dimensions; native clock continues. +- Auto keyboard negotiation runs before event polling. Owned enhanced flags are + popped on exit/error/panic/suspend; basic mode avoids queries. Negotiated event + support and observed releases/repeats are distinct from physical timing, which + remains unavailable. F8 provides finish without modified Enter support. +- `vp run terminal:test:pty` passes five modes/editor/lifecycle plus simulated + enhanced negotiation/press/repeat/release/cleanup. `cargo test -p oxytype-tui` + (via vp) passes nine integration tests, including Ratatui TestBackend Unicode + cells/caret/extra emoji and UTF-16 deletion. Clippy passes. +- Real macOS/Windows Terminal and enhanced hardware-terminal manual matrix is + unperformed, not a passed acceptance gate. Asked for machine/test access; + continue independent durable history and packaging work while awaiting it. + +## Phase 3.4 gate passed + +- SQLite settings/history with OS directories and explicit overrides, WAL/FULL + transactions, settings revision conflict detection, append-only UUID results and + idempotent retry. No network/accounts/tokens. New Unix data is private. +- Native summaries/config/content identity/seed/quote/timestamps/engine versions, + capabilities and native validity stored. Unavailable physical timing fields null. + Raw custom text/logs absent by default; explicit replay opt-in warns in UI. +- History pages/filter/details/PBs/export use stored summaries; native PB validity + and funbox metadata preserved. Compatible recorded replay steps/seeks call core. + Unknown record fields/versions export unchanged, with no score recomputation. +- Fourteen terminal integration tests pass: reopen, concurrent instances, malformed + and future formats, dropped transaction, write-lock queue retry, native PB value, + no-clobber export, privacy defaults and replay compatibility. Clippy passes. +- Actual PTY passes saved-result/history/export and opted-in replay stepping, plus + prior input/settings/lifecycle cases. PTY profiles isolated in temporary dirs. + Readable themes, reduced redraws and color-independent errors implemented. +- Private custom text is intentionally not restored from settings; a saved custom + mode requires new --text/--file (or choose --mode words and F4 editor). Local + packs must be explicitly supplied again. No silent content substitution. +- Platform/manual terminal gate remains unperformed; continue packaging. + +## Phase 3.5 verification checkpoint (platform gate still active) + +- Source `ba66f8ebb`: isolated fresh checkout `/tmp/oxytype-stage3-clean`, empty + target/generated/dist, frozen install, native tests, actual Cloudflare build, + Wrangler dry-run and all nine workspace typechecks pass. Whole-repo format/Knip + pass. Full website/native/Wasm CI `40064612700` succeeds at this source. +- Wasm remains 765,852 bytes, exact SHA-256 matches current/fresh checkout. Shared + core/browser runtime is unchanged by terminal implementation; original web + performance budgets and references remain untouched. +- Native release tooling produces GPL/attribution/third-party-notice archives and + checksums; Linux ARM64 local archive 1.6 MiB passes installed binary checks with + empty PATH/cwd, bundled synthetic modes, storage/export and release PTY. Host + GNU libc 2.43; do not infer lower compatibility from that local check. +- `vp run terminal:source`: 69 MiB corresponding-source archive, locked vendored + dependencies, relative Cargo vendor config and separate offline native compile + pass. Pinned upstream ts-rs LICENSE supplies text omitted by its crates.io + packages; revision/license/hash mechanically checked. Missing notices fail. +- Five native jobs plus source job added separately from website CI. Narrowed + Rust runtime paths; 64 routing checks pass, including terminal-only independence. + PR #186 draft: https://github.com/voltcrash/oxytype/pull/186, stacked on #185. +- Initial platform CI `40064530451`: both Linux archives/source pass. Windows + found an unused Unix-only suspend parameter; fixed. macOS PTY output required + continuous draining; then Darwin revoked the PTY after session-leader exit. + Harness now retains a shell-like session leader to inspect actual cleanup; + Linux revised lifecycle checks pass. macOS rerun is required, not claimed. +- Additional terminal review fixed full-passage custom defaults (previous section-1 + default stopped after first word), CRLF/LF entry preparation, native stopped + letter visual feedback and lossless deletion of partial UTF-16 feedback. Tests + require all multiline/emoji text, not only completion. No shared fixture changed. +- Nineteen terminal integration tests pass (plus one ignored subprocess helper + explicitly launched/killed by its parent test). Actual process termination during + a write preserves committed history. Clippy passes. Pending writes block new + sessions until retry; unknown stored formats are labelled without validation + claims. Latest native platform rerun follows the PTY keeper fix. +- Real Linux enhanced/macOS/Windows Terminal manual checks remain unavailable. + Requested access/test records asynchronously; no answer yet. Publish workflow + refuses the pending manual matrix. No release tag/publication or production + deployment performed. Continue all available checks before stopping. + +## Latest terminal portability checkpoint + +- Terminal runtime source is implemented through all modes, Unicode/input, local + history/export/replay and packaging. Twenty native integration tests pass locally + (one intentionally ignored helper is launched/killed by the durability parent). + Native Clippy, format, package routing and ZIP regression checks pass. +- Native/core tests also pass fully offline from the extracted vendored source at + `1495a30fc`, using a fresh separate target directory. Current local Linux archive + matches its SOURCE revision and passes empty-PATH install plus full release PTY. +- Wasm matches the Stage 2 clean checkout byte-for-byte: SHA-256 + `4b1a0c8d5d006b367888b7cf4c76b104ab5037a7473ff7b46983a9109aba9e3d`, + 765,852 raw / 303,619 Node gzip6 bytes. No shared runtime/fixture/budget changes. +- Platform CI `40065958315`: Linux x86/ARM and both macOS candidates pass archive + and PTY checks; Windows native tests/build pass, ZIP failed on registry files + predating 1980. ZIP now clamps only unsupported timestamps; real byte/checksum + regression passes. Pinned license EOL and source-provenance issues also fixed. +- Added portable PTY integration to exercise native event decoding on all targets, + including actual Windows ConPTY, with edits, known native accuracy, saved history + and no invented physical telemetry. Linux passes. Windows first run waited for + ConPTY's inherited-cursor query; test emulator now answers DSR from actual parsed + screen coordinates. This is protocol handling, not fabricated key/timing data. +- Latest platform rerun includes the ConPTY query fix; check GitHub runs for current + `stack/rust-engine-terminal` head. Shared fixtures/native terminal/packaging steps + are separated for useful diagnostics. Do not skip failed checks. +- Manual Linux enhanced/macOS emulator/Windows Terminal records remain pending; + no machine/test record supplied to the asynchronous question yet. Publication + gate correctly rejects the pending matrix. PR #186 stays draft. + + +## Final automated verification / remaining external gate + +- Runtime source `5703c1f10bd949478d3801d027965ac2214f3738` passes full + [website/native/Wasm CI 40068412135](https://github.com/voltcrash/oxytype/actions/runs/40068412135): + frozen parity, frontend/backend/packages, lint/types/Knip, actual Cloudflare + SSR/workerd/delivery, all four browser shards and updates/rollback. No frozen + reference, backend acceptance rule or performance budget changed. +- [Terminal CI 40068412137](https://github.com/voltcrash/oxytype/actions/runs/40068412137) + succeeds on Linux x86_64/ARM64, macOS Intel/Apple Silicon and Windows x86_64. + All five run native format/Clippy/core fixtures, 21 terminal integration tests, + archive portability regression, native compilation and extracted archive smoke + without runtime tools/source files. The one ignored durability helper is + explicitly launched and killed by its parent test, not omitted coverage. +- Linux/macOS jobs additionally pass debug and extracted-release POSIX PTY checks + (input, five modes, paste policy, resize, history/replay, cleanup and supported + signals/suspend). Windows passes actual ConPTY typing/editing/history/native + accuracy and quit. ConPTY cursor replies must be one write_all call; fragmented + formatted writes caused the earlier harness stall. No physical timing invented. +- Corresponding-source job passes vendoring and a separate offline locked build. + Downloaded all six archives, verified every sidecar hash and all five binary + SOURCE.md revisions against source SOURCE_REVISION; publisher checksum collector + passes. Exact sizes/hashes and GitHub PR merge revision are recorded in + terminal-artifacts.json. This immutable record describes CI candidates, not a + published release; subsequent documentation commits do not replace it. +- Existing fresh-checkout native/Cloudflare build, Wrangler dry-run, all nine + typechecks and vendored full offline tests remain valid. Browser Wasm remains + exactly Stage 2: 765,852 raw / 303,619 Node gzip6 bytes, SHA-256 + `4b1a0c8d5d006b367888b7cf4c76b104ab5037a7473ff7b46983a9109aba9e3d`. +- User confirmed no other test machine is available. Real Linux enhanced, + macOS emulator and Windows Terminal font/IME/rapid-input/scrollback verification + remains unperformed. `vp run terminal:release-gate` correctly exits 1 and names + linuxEnhanced, macOS, windowsTerminal. terminal-verification.json stays pending; + no waiver, release tag, artifact publication or production deployment made. +- Review stack: [#184 engine](https://github.com/voltcrash/oxytype/pull/184) → + [#185 website](https://github.com/voltcrash/oxytype/pull/185) → + [#186 terminal](https://github.com/voltcrash/oxytype/pull/186). First two ready; + terminal draft solely for the unmet real-terminal gate. All available independent + work is complete; resume that gate when actual machine access/records exist. + + +## Owner-directed merge readiness follow-up + +- PRs #184/#185 merged; integrated origin/main with a merge commit and retargeted + PR #186 to main, preserving all coherent implementation commits. +- Owner removed Windows support entirely. Four release targets now: Linux GNU + x86_64/ARM64, macOS Intel/Apple Silicon. Removed Windows archive/CI machinery; + actual tar integrity, unsupported-target and four-binary/source checks pass. +- Owner will check macOS after merge. Original external-contributor/manual + pre-merge gate is superseded by this explicit instruction. Manual evidence is + still pending; release publication is neither requested nor performed. +- Extended real PTY suite passes CSI-u shifted/Unicode/modifier/repeat/release, + fragmented CSI payload/UTF-8, unknown TERM fallback, and enhanced error/panic/ + signal/suspend cleanup. Query simulation now handles every resume handshake. + The test screen reader correctly joins combining marks and wide continuations. +- Investigated a bare ESC split from its CSI introducer: Crossterm treats an + isolated ESC as Esc immediately. Fragmentation checks retain the atomic CSI + introducer and split its payload/UTF-8; no SSH/transport-wide robustness claim. +- Real Kitty 0.49.2/Alacritty 0.17.0/Ghostty 1.3.1 launch on Linux. Added a private + Xvfb/XTest harness with untouched emulator input/query bytes, native results, + Unicode, repeats, resize/history/editor/replay and terminal cleanup assertions. + Kitty auto/basic pass; Alacritty follow-up still under investigation. Do not + mark the new emulator/CI gates passed until final runs complete. + + +## Linux emulator acceptance checkpoint (revised scope) + +- Runtime/harness `6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f`: all eight real + auto/basic runs pass on Kitty 0.32.2 and 0.49.2, Alacritty 0.17.0 and Ghostty + 1.3.1. Exact binary version/hash, host, capabilities, Unicode fixture and repeat + deliveries recorded in terminal-emulators.json. Each run uses an isolated Xvfb + server and fresh app/emulator config; no user windows or profiles are targeted. +- Older Kitty reproduced two legacy Backspace deliveries per action with release + reporting enabled. Fixed native auto negotiation to request disambiguation only + (flag 1), preserving ordinary committed UTF-8. Real old/new Kitty now pass with + one grapheme deleted. No scoring/input rules moved out of shared Rust core. +- All runs verify actual native result/history/replay/editor flow, 87.5% edited + custom accuracy, resize blocking, held repeat deliveries, unavailable physical + timing, raw mode restored and the real terminal's keyboard query returns zero + after exit. Release/repeat kinds remain false when unobserved; explicit CSI-u + release/repeat/fallback/fragmented-payload behavior is still covered by PTY tests. +- Harness fixes: Xvfb -noreset retains the preloaded Unicode keymap; direct XTest + function/Unicode codes avoid xdotool aliases; await mapped/visible focus before + handshake. Unknown multimedia scancodes, dynamic keymap races and async resize + were test-driver problems, not evidence of app typing support or failures. +- Ghostty's default Ctrl+Enter is intercepted for fullscreen; F8 is the portable + finish action. GTK basic standalone combining-key input can remain preedit; + its basic case uses committed é. Enhanced cases verify combining clusters; + hardware/IME/font appearance remains unperformed and is not falsely certified. +- Current local native 21 integration tests, engine Clippy/format, all shared core + tests, enhanced lifecycle PTY, four release-tool/gate regressions and task-wrapper + diagnostics pass. `terminal:release-gate` correctly fails only on pending macOS. +- CI 40085257392 (four native targets, two emulator jobs, source) and full website + CI 40085257400 started at this source. Finish these checks and fix failures; + then record packaging evidence and make #186 ready. Earlier CI failures were + investigated: missing Alacritty XKB runtime, map-before-focus race and old Kitty + key-up duplication. No check skipped or fixture/budget changed to pass. + + +## Current-scope merge acceptance + +- Runtime/harness source `6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f`: + [native CI 40085257392](https://github.com/voltcrash/oxytype/actions/runs/40085257392) + passes all four Linux/macOS native format/Clippy/shared fixtures/terminal tests, + debug and extracted-release PTY checks, packaging and empty-PATH install smoke. + Both real Linux emulator jobs (Kitty 0.32.2 and Alacritty 0.13.2 auto/basic) and + vendored corresponding-source offline build pass. Publication job is skipped + because no authorized tag was pushed. +- [Full website CI 40085257400](https://github.com/voltcrash/oxytype/actions/runs/40085257400) + succeeds: native/Wasm parity, frontend/backend/packages, lint/types/Knip, + Cloudflare SSR/workerd/delivery, all four browser shards and update/rollback. + Final local full format (2,753 files) and Knip pass. Wasm is byte-identical to + Stage 2: 765,852 raw / 303,619 Node gzip6 bytes, SHA-256 + `4b1a0c8d5d006b367888b7cf4c76b104ab5037a7473ff7b46983a9109aba9e3d`. +- Downloaded all four native archives plus source. Every sidecar hash and all + binary/source revision pairs match tested GitHub merge revision + `1258809f217c112e5566f33e803b9a19d6b6aefb`; actual publisher checksum collector + succeeds. terminal-artifacts.json records exact five candidates. Prior Windows + evidence is retained unchanged under archive/terminal-artifacts-40068412137.json, + separate from current supported targets. No release published. +- terminal-emulators.json retains eight exact local runs plus four CI runs, + including binary version/hash and real observed capabilities. Native key-up + fields stay unavailable; explicit release/repeat packet regressions still pass. +- Stage 3 satisfies the owner's revised merge scope. Physical macOS/IME/font + checks are explicitly unperformed; owner will perform them after merge using + terminal-macos-check.md. Linux emulator CI replaces the external tester gate; + macOS is the sole manual publication gate. No requested production deployment, + release publication, account or cloud sync added. diff --git a/docs/rust-engine/terminal-artifacts.json b/docs/rust-engine/terminal-artifacts.json new file mode 100644 index 000000000..d274718ca --- /dev/null +++ b/docs/rust-engine/terminal-artifacts.json @@ -0,0 +1,34 @@ +{ + "formatVersion": 1, + "workflowRun": "https://github.com/voltcrash/oxytype/actions/runs/40085257392", + "sourceHead": "6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f", + "artifactSourceRevision": "1258809f217c112e5566f33e803b9a19d6b6aefb", + "status": "Four Linux/macOS native build/test/archive smoke jobs, both real Linux emulator jobs and corresponding-source job passed. Downloaded checksums and binary/source revision pairing verified. CI candidates only; no release published.", + "artifacts": [ + { + "file": "oxytype-v0.1.0-aarch64-apple-darwin.tar.gz", + "bytes": 1580840, + "sha256": "626bbbabffabeada070b392f1480ab32378ebe69a7de36bf2ccf081d228abce6" + }, + { + "file": "oxytype-v0.1.0-aarch64-unknown-linux-gnu.tar.gz", + "bytes": 1636398, + "sha256": "22d8edd98ed5dcf3491be22636e03d7fe98adaeddd9cf2e712185df224cb0a0d" + }, + { + "file": "oxytype-v0.1.0-source.tar.gz", + "bytes": 72373083, + "sha256": "4589632e679d1ff55ee76d52d0a2f9357fb92375b60c5d10fd72b1837faa8464" + }, + { + "file": "oxytype-v0.1.0-x86_64-apple-darwin.tar.gz", + "bytes": 1651751, + "sha256": "fa32a8b12023f87c2221f172e916c779b975db1ee92553e72df312e1440c1068" + }, + { + "file": "oxytype-v0.1.0-x86_64-unknown-linux-gnu.tar.gz", + "bytes": 1641068, + "sha256": "3cec443a921e6c986f3e3cb1c0c732f0d7c174a8edb3addb252ae70cd05a2c11" + } + ] +} diff --git a/docs/rust-engine/terminal-content.md b/docs/rust-engine/terminal-content.md new file mode 100644 index 000000000..63bff68c8 --- /dev/null +++ b/docs/rust-engine/terminal-content.md @@ -0,0 +1,54 @@ +# Offline terminal content + +The binary embeds the existing 200-word English asset and eight original Oxytype +practice passages. Both are GPL-3.0-only; attribution and the English source hash +are in `crates/oxytype-tui/assets/ATTRIBUTION.md`. Nothing downloads at startup. + +`oxytype --mode words --words 25 --seed 42` uses the shared native generator. +Other modes: `time`, `quote`, `custom`, `zen`. Defaults come from core Config. +`--punctuation`, `--numbers`, `--difficulty`, `--stop-on-error`, +`--delete-on-error`, `--confidence`, `--freedom`, `--strict-space` project into +that same Config. `--capabilities` lists the explicit terminal funbox subset; +other hooks fail rather than silently substituting content. Incompatible forced +settings are rejected using native metadata. + +Custom text: `--text 'one two three'` or `--file practice.txt` (UTF-8, ≤8 MiB). +This implies custom mode unless another mode is explicitly supplied (an error). +`--custom-order repeat|random|shuffle`, `--custom-limit word|time|section`, and +`--custom-length N` feed the core's custom content contract. Default order is repeat; +default word limit covers all supplied words. An omitted time length uses core's +default time. Explicit zero retains native infinite behavior. Zero means infinite +where defined by core. Literal spaces separate supplied entries. CRLF/CR decode as LF; LF terminates +an entry so Enter commits it. The core's space equivalents (including NBSP and a UTF-8 BOM) become entry +separators. Tab and other Unicode text retain their literal UTF-16 units. +No NFC rewrite is imposed by the terminal adapter. Native rules retain all normalization and scoring responsibility. + +Extend offline content with `--pack pack.json`. Version 1: + +```json +{ + "formatVersion": 1, + "license": "CC0-1.0", + "attribution": "Your content author", + "language": { + "name": "example", + "bcp47": "en", + "words": ["alpha", "beta", "gamma"] + }, + "quotes": [{"id": 1, "text": "An original practice passage.", "source": "Your author"}] +} +``` + +The file must be valid UTF-8 JSON, ≤8 MiB. License/attribution required; unique +quote IDs and nonempty words/text. Language names: ASCII alphanumeric/underscore, +≤128 bytes. Optional language fields: `rightToLeft`, `joiningScript`, +`originalPunctuation`, `noLazyMode`, `additionalAccents` (pairs of strings). +Language metadata passes shared engine bounds; terminal control sequences and +direction overrides are rejected. Only Tab/LF control characters are accepted. +Terminal shaping limitations still apply; a pack cannot grant keyboard/display +capabilities. No fetching or silent fallback to English for a missing language. + +Quote lengths count UTF-16 units: group 0 = 0–124, 1 = 125–249, 2 = 250–499, +3 = 500–1,000,000. `--quote-length 0,1,2,3` selects groups; default group 1 is the +native default. Missing selected groups fail clearly. Packs need not contain +quotes when used for word/time tests. Quotes are selected/normalized by core. diff --git a/docs/rust-engine/terminal-emulators.json b/docs/rust-engine/terminal-emulators.json new file mode 100644 index 000000000..533ab50ee --- /dev/null +++ b/docs/rust-engine/terminal-emulators.json @@ -0,0 +1,284 @@ +{ + "formatVersion": 1, + "sourceHead": "6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f", + "platform": "Linux-7.1.13-402.asahi.fc44.aarch64+16k-aarch64-with-glibc2.43", + "testHost": "Fedora Asahi Remix 44 / Linux ARM64; private Xvfb 21.1.25 displays; XTest input", + "coverage": [ + "auto/basic negotiation", + "actual emulator text/key encoding", + "native UTF-16/grapheme deletion", + "held-key repeat deliveries", + "resize input blocking", + "native finish and 87.5% edited custom accuracy", + "durable history, editor and replay", + "raw-mode restoration and zero keyboard flags after exit" + ], + "limitations": [ + "Automated emulator checks; no physical keyboard/IME or pixel/font inspection claimed", + "Ghostty basic fixture uses committed é; standalone combining-key preedit is not a committed-text observation", + "Ghostty default Ctrl+Enter is intercepted for fullscreen; F8 is tested instead", + "macOS manual verification is owned by the user after merge" + ], + "runs": [ + { + "emulator": "kitty", + "version": "kitty 0.49.2 created by Kovid Goyal", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "auto", + "term": "xterm-kitty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": true, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "kitty", + "version": "kitty 0.49.2 created by Kovid Goyal", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "basic", + "term": "xterm-kitty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": false, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "alacritty", + "version": "alacritty 0.17.0", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "auto", + "term": "alacritty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 6, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": true, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "alacritty", + "version": "alacritty 0.17.0", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "basic", + "term": "alacritty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 6, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": false, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "ghostty", + "version": "Ghostty 1.3.1-4.fc44\n\nVersion\n - version: 1.3.1-4.fc44\n - channel: tip\nBuild Config\n - Zig version : 0.15.2\n - build mode : .ReleaseFast\n - app runtime : .gtk\n - font engine : .fontconfig_freetype\n - renderer : renderer.generic.Renderer(renderer.OpenGL)\n - libxev : io_uring\n - kernel version: 7.1.13-402.asahi.fc44.aarch64+16k\n - desktop env : other\n - GTK version :\n build : 4.22.4\n runtime : 4.22.5\n - libadwaita : enabled\n build : 1.9.3\n runtime : 1.9.4\n - libX11 : enabled\n - libwayland : enabled", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "auto", + "term": "xterm-ghostty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": true, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "ghostty", + "version": "Ghostty 1.3.1-4.fc44\n\nVersion\n - version: 1.3.1-4.fc44\n - channel: tip\nBuild Config\n - Zig version : 0.15.2\n - build mode : .ReleaseFast\n - app runtime : .gtk\n - font engine : .fontconfig_freetype\n - renderer : renderer.generic.Renderer(renderer.OpenGL)\n - libxev : io_uring\n - kernel version: 7.1.13-402.asahi.fc44.aarch64+16k\n - desktop env : other\n - GTK version :\n build : 4.22.4\n runtime : 4.22.5\n - libadwaita : enabled\n build : 1.9.3\n runtime : 1.9.4\n - libX11 : enabled\n - libwayland : enabled", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "basic", + "term": "xterm-ghostty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": false, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "kitty", + "version": "kitty 0.32.2 created by Kovid Goyal", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "auto", + "term": "xterm-kitty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 6, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": true, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "kitty", + "version": "kitty 0.32.2 created by Kovid Goyal", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "basic", + "term": "xterm-kitty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": false, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + } + ], + "ciRuns": { + "workflowRun": "https://github.com/voltcrash/oxytype/actions/runs/40085257392", + "sourceHead": "6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f", + "artifactSourceRevision": "1258809f217c112e5566f33e803b9a19d6b6aefb", + "platform": "Ubuntu 24.04 / Linux x86_64; distribution Kitty/Alacritty; private Xvfb/XTest", + "runs": [ + { + "emulator": "alacritty", + "version": "alacritty 0.13.2", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 1258809f217c112e5566f33e803b9a19d6b6aefb)", + "binarySha256": "cf1f6706af8e34ac9836af2566dbfe870fc554a6fcfaa04723be20684f8d0218", + "keyboard": "auto", + "term": "alacritty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": true, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "alacritty", + "version": "alacritty 0.13.2", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 1258809f217c112e5566f33e803b9a19d6b6aefb)", + "binarySha256": "cf1f6706af8e34ac9836af2566dbfe870fc554a6fcfaa04723be20684f8d0218", + "keyboard": "basic", + "term": "alacritty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": false, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "kitty", + "version": "kitty 0.32.2 created by Kovid Goyal", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 1258809f217c112e5566f33e803b9a19d6b6aefb)", + "binarySha256": "cf1f6706af8e34ac9836af2566dbfe870fc554a6fcfaa04723be20684f8d0218", + "keyboard": "auto", + "term": "xterm-kitty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": true, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "kitty", + "version": "kitty 0.32.2 created by Kovid Goyal", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 1258809f217c112e5566f33e803b9a19d6b6aefb)", + "binarySha256": "cf1f6706af8e34ac9836af2566dbfe870fc554a6fcfaa04723be20684f8d0218", + "keyboard": "basic", + "term": "xterm-kitty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": false, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + } + ] + } +} diff --git a/docs/rust-engine/terminal-macos-check.md b/docs/rust-engine/terminal-macos-check.md new file mode 100644 index 000000000..7b838d1f8 --- /dev/null +++ b/docs/rust-engine/terminal-macos-check.md @@ -0,0 +1,45 @@ +# macOS check after merge + +The owner will run this check after PR #186 merges. It does not block merge. +Linux emulator CI covers the shared keyboard/text paths; macOS native CI covers +both CPU architectures and PTY lifecycle. This check adds actual Mac emulator, +font, function-key and input-method observations. No Windows check is required. + +Use the merged checkout and pinned tools, or the matching native CI/release +candidate. Keep its `--version` output so the source revision is identifiable. +Repository commands below use fresh temporary profiles; select your preferred +Kitty-compatible emulator (Kitty, Ghostty or Alacritty). + +```sh +vp run terminal:dev -- --version +vp run terminal:dev -- --config-dir /tmp/oxytype-mac-check/config --data-dir /tmp/oxytype-mac-check/data --mode custom --text 'one two' +``` + +- Type `one twx`, Backspace, then `o`. Result accuracy should be 87.5%; duration + and WPM reflect your actual typing. F3 opens history; relaunch with the same + profile and confirm persistence. F7 exports from history without overwriting. +- Run words, time, quote and zen (`--mode MODE`); F8 finishes, F5 starts new, + F6 repeats. F2 edits settings, F4 opens custom text; paste is allowed there and + blocked during typing. Use Fn with function keys if macOS reserves them. + Ghostty may capture Ctrl+Enter for fullscreen; F8 remains the finish binding. +- F1 shows enhanced/basic negotiation. Repeat a held key and delete it. Releases + need not be reported: auto requests disambiguation only and physical timing + stays unavailable. `--keyboard basic` checks fallback; try Option/Alt+Backspace + if Ctrl+Backspace is indistinguishable. The emulator's Option-as-Alt setting + determines whether that combination reaches the app. +- Check combining text (`e` plus U+0301), CJK and emoji with your actual input + method and font. Committed text should remain intact; Backspace removes one + grapheme. Note preedit/complex-shaping limitations instead of inferring support. +- Resize below 60×18 and back; typing is blocked while small, clock continues. + Check rapid input and scrollback. Quit normally and with Ctrl+C; verify shell + echo/cursor remain usable. Ctrl+Z then shell `fg` checks suspend/resume. + +Record macOS version, CPU, emulator/version, font, source revision, keyboard mode, +input method, exact cases performed and any failures in a new verification record. +Only after actual success, set `manualTerminals.macOS.status` to `pass` and its +`evidence` to that record in terminal-verification.json. `vp run terminal:release-gate` +then checks it alongside the publishing workflow's native/emulator/source jobs. +A failed case needs investigation; do not mark an unperformed case passed. + +No account, deployment, release tag or publication is required for this check. +Delete the temporary config/data directories afterwards if no longer needed. diff --git a/docs/rust-engine/terminal-release-notes.md b/docs/rust-engine/terminal-release-notes.md new file mode 100644 index 000000000..a2783a956 --- /dev/null +++ b/docs/rust-engine/terminal-release-notes.md @@ -0,0 +1,11 @@ +Offline Oxytype terminal release using the same Rust typing engine as the website. + +- Bundled English words/original quotes; time, words, quote, custom/file and zen. +- Native input/scoring, Unicode cell rendering, explicit keyboard limitations. +- Durable private local history, settings, native validity/PBs and JSON export. +- Replay logs opt in explicitly; no accounts, sync, telemetry or first-run download. + +Verify SHA256SUMS, extract the matching platform archive and run oxytype --help. +No Rust/Node/vp or source checkout required. LICENSE, attribution, third-party +notices and corresponding source accompany the release. See included README for +tested OS/terminal requirements, data paths and uninstall instructions. diff --git a/docs/rust-engine/terminal-release.md b/docs/rust-engine/terminal-release.md new file mode 100644 index 000000000..af917c188 --- /dev/null +++ b/docs/rust-engine/terminal-release.md @@ -0,0 +1,79 @@ +# Native release procedure + +No terminal release has been published by this migration. Candidate archives and +their build/smoke evidence must pass review before a maintainer creates a release +tag. Website production authorization/workflow remains separate. + +`terminal-release.yml` builds/test/smokes four native candidates on PRs touching +native/core/build inputs. It does not run for unrelated website edits. Each job +uses pinned Rust 1.99.1, the lockfile, vp tasks and a native runner. Core fixtures, +Clippy and terminal tests run before release compilation. Every native job also +tests debug lifecycle and extracted release binaries in a real PTY. Separate +Linux emulator jobs exercise Kitty and Alacritty through actual XTest events in +private Xvfb displays, retaining exact versions and input/output/cleanup evidence. + +| Candidate target | Runner / conservative requirements | Build/smoke status | +| --- | --- | --- | +| x86_64-unknown-linux-gnu | Ubuntu 24.04; GNU libc 2.39 baseline; not static musl | Pass | +| aarch64-unknown-linux-gnu | Ubuntu 24.04 ARM; GNU libc 2.39 baseline; not static musl | Pass | +| x86_64-apple-darwin | macOS 15 Intel; deployment target explicitly 15.0 | Pass | +| aarch64-apple-darwin | macOS 15 Apple Silicon; deployment target explicitly 15.0 | Pass | + +All four native candidates, both real Linux emulator jobs and corresponding +source passed [run 40085257392](https://github.com/voltcrash/oxytype/actions/runs/40085257392) +for source head `6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f`. +[terminal-artifacts.json](terminal-artifacts.json) records exact downloaded sizes +and SHA-256 hashes. All five checksums and binary/source revision pairs were +verified with the publisher's checksum collector. All SOURCE.md/SOURCE_REVISION +files identify GitHub's tested PR merge revision +`1258809f217c112e5566f33e803b9a19d6b6aefb`. +These are temporary CI candidates (14-day retention), not published releases. +Subsequent evidence-only commits do not retroactively change this record. + +The owner narrowed support to Linux/macOS after #184/#185 merged; Windows is out +of scope. [Earlier five-target evidence](archive/terminal-artifacts-40068412137.json) +is archived unchanged for provenance and is not the supported target list. + +Do not claim lower OS/libc versions or untested terminals supported. Runner labels +were checked against [GitHub's runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). +Linux emulator checks plus four native/archive jobs govern merge readiness. +The owner will perform [macOS manual verification](terminal-macos-check.md) after +merge; that remains the sole manual publication record in terminal-verification.json. +Linux automated emulator jobs replace the former external-contributor requirement. +Unperformed physical IME/font checks remain explicit. This does not block PR +review/merge under the owner's revised scope; publication remains a separate gate. + +Build locally: `vp run terminal:package -- --target TARGET`; use the matching native +toolchain/runner. Archives go under `target/terminal-release/` with individual +`.sha256` files. `vp run terminal:archive-smoke -- --archive PATH` verifies the hash, +extracts, and runs the executable with an empty PATH/working directory. It checks +version/help/diagnostics, explicitly synthetic bundled-mode smoke, local storage +and export; supported POSIX hosts additionally test actual interactive input. +No saved result is fabricated by the synthetic smoke path. + +Each archive includes GPL LICENSE, bundled content attribution, third-party +notices/license text, install/data docs and exact source revision. Notices cover +the complete locked workspace dependency set (a conservative superset). The two +ts-rs packages omit their upstream LICENSE from crates.io packages: the exact +upstream revision/license/hash is pinned under third-party and mechanically +checked against cargo VCS metadata. Missing notices stop packaging. + +`vp run terminal:source` archives committed source, vendors locked dependencies, +adds SOURCE_REVISION, and proves `cargo check --offline --locked` from that tree +with a separate target directory. The resulting corresponding-source archive is +published beside binaries. Its `.cargo/config.toml` uses a relative vendor path. +Source builds use the pinned toolchain; set OXYTYPE_BUILD_REV from SOURCE_REVISION +to retain provenance. No source registry access is needed once tools are installed. + +After all four native jobs, emulator jobs, source build and the release matrix pass, review +the PR and final source commit. Follow maintainer authorization, update the Cargo +workspace version/release notes, then push the matching `terminal-vVERSION` tag. +Tag mismatch fails. The tag rebuilds all artifacts, and publication additionally +requires `vp run terminal:release-gate` to accept recorded manual evidence. +Publication alone gets contents:write; PR/build jobs have contents:read, checkout +credentials are not persisted. No account or deployment secrets are used. + +The publishing job verifies all five platform/source checksums and produces +SHA256SUMS before creating the GitHub Release. Development PRs and manual workflow +runs upload temporary CI candidates only. They do not publish releases or deploy +the website. Never bypass the manual gate by marking unperformed checks passed. diff --git a/docs/rust-engine/terminal-storage.md b/docs/rust-engine/terminal-storage.md new file mode 100644 index 000000000..415087bb0 --- /dev/null +++ b/docs/rust-engine/terminal-storage.md @@ -0,0 +1,52 @@ +# Offline local data + +Settings use `settings.sqlite3` under the OS config directory. History uses +`history.sqlite3` under the OS local data directory. Paths are resolved with +`directories::ProjectDirs("com", "Oxytype", "Oxytype")`; explicit `--config-dir` +and `--data-dir` overrides support portable installs/testing. No account, tokens, +telemetry or transport. Data remains until the user removes these directories. + +SQLite is bundled into the executable. Each database uses `user_version=1`, WAL, +FULL synchronization and a two-second busy timeout. Transactions keep completed +writes atomic; concurrent app instances append distinct result IDs. Settings use +a revision comparison so one instance cannot silently overwrite another's edits. +New Unix directories/files are private (0700/0600); platform ACLs apply elsewhere. +Do not copy only the main file while the app runs: WAL may contain committed data. +Use JSON export or close all instances before copying the entire data directory. + +Malformed files and future database/settings versions fail clearly, preserving +the original file. No automatic deletion, repair, reset or score recalculation. +Back up a damaged directory before attempting external recovery. Disk errors +must be reported; the current unsaved result can be retried while the app runs. + +Result JSON envelopes independently carry `formatVersion=1`, UUID, timestamp, +effective native config, native mode2, seed, content SHA-256 identity, quote ID, +custom limit, engine behavior/log/build versions, native validity, capabilities +and summary. Content identity fingerprints source assets/custom text without +storing its path or raw text; a hash is not encryption. Replay logging is off by +default because target words and input events can contain private text. + +Physical identity, hold durations, spacing, overlap, key consistency and +first/last physical-key timing are unavailable. Those summary fields are null, +including when the terminal reports releases: terminal delivery is not a +measurement of physical switch timing. WPM/accuracy/duration/charts remain the +native engine's values based on actual committed input and monotonic delivery +timestamps. No fabricated releases or wall-clock input intervals. + +Stored summaries are authoritative historical values. Export preserves unknown +record versions/fields as JSON; it does not reinterpret them using today's core. +Exports write a synced temporary file and publish without replacing an existing +path. Result UUID retries are idempotent; a collision with different content fails. +Optional replay is offered only when an actual compatible event log was recorded. + +Local personal bests compare stored native WPM within the effective config, +native mode2 and content identity. Shared validity and funbox PB metadata decide +eligibility; repeated, failed or otherwise invalid results remain in history. +No score formula or acceptance policy is reimplemented by storage. + +Optional replay logs use the native Rust DTO: Text fields retain arrays of UTF-16 +units, including partial surrogates where legacy rules produce them. The local +envelope version distinguishes this representation from browser transport JSON. +Core event semantics/log version remain shared; converting stored web records +into local terminal history is outside this release. Never interpret Rust scalar +or byte offsets as JavaScript event indices. diff --git a/docs/rust-engine/terminal-verification.json b/docs/rust-engine/terminal-verification.json new file mode 100644 index 000000000..ad20a4972 --- /dev/null +++ b/docs/rust-engine/terminal-verification.json @@ -0,0 +1,9 @@ +{ + "formatVersion": 1, + "manualTerminals": { + "macOS": { + "status": "pending", + "evidence": null + } + } +} diff --git a/docs/rust-engine/terminal.md b/docs/rust-engine/terminal.md new file mode 100644 index 000000000..adf6f02f3 --- /dev/null +++ b/docs/rust-engine/terminal.md @@ -0,0 +1,134 @@ +# Oxytype offline terminal + +First native release: Linux/macOS offline typing and local history, using the same +Rust engine as the website. No account login, cloud sync, telemetry or first-run download. + +Download the matching `oxytype-vVERSION-TARGET` archive from the repository's +GitHub Releases when a maintainer publishes one. Verify its SHA-256 against +`SHA256SUMS`, extract, and put `oxytype` on PATH. +Users need no Rust, Node, vp, source checkout or network access. Keep LICENSE, +attribution and third-party notices with redistributed copies. + +```sh +oxytype --help +oxytype --mode words --words 25 +oxytype --mode time --time 60 --punctuation --numbers +oxytype --mode quote --quote-length 0,1 +oxytype --text 'Your private practice text.' +oxytype --file practice.txt --custom-order shuffle --custom-limit word --custom-length 50 +oxytype --mode zen +oxytype --paths +oxytype --diagnostics +oxytype --export history.json +``` + +Binary invocation above is for installed users. Repository development commands: + +```sh +vp run terminal:dev +vp run terminal:test +vp run terminal:test:pty +vp run terminal:test:emulator -- --emulator kitty --command /usr/bin/kitty +vp run terminal:build +vp run terminal:package +vp run engine:lint +vp run engine:fmt +``` + +Use F1 for help. F5 starts a new test; F6 repeats the native wordset; F8 finishes +manually. F2 edits settings when inactive; arrows select/change, Enter validates +and applies. F4 opens private custom text setup (paste allowed there). F3 browses +history: arrows select, Enter details, m filters, PgUp/PgDn pages, F7 exports the +filter, F8 opens a recorded compatible replay. Replay: Right/Space step, Home +restart, PgUp/PgDn seek by word. Ctrl+Q/C or F10 quit. Unix Ctrl+Z suspends the +foreground job; resume with the shell. Native elapsed time continues while +suspended or too small. Typing is disabled below 60×18; resizing does not pause. + +Input maps characters, Backspace, Ctrl/Alt+Backspace word deletion, Enter and Tab. +Ctrl+Backspace can be indistinguishable from Backspace in basic mode; use +Alt+Backspace when the terminal reports that combination. Kitty keyboard reporting +is queried before reading events; fallback is basic. Auto mode requests key +ambiguity disambiguation only, preserving ordinary UTF-8/IME delivery. Release +reports are unnecessary for typing and can duplicate legacy Backspace on older +Kitty versions, so they are not requested. Repeats still type once per delivery; +unreported release/repeat distinctions remain unavailable. Kitty, Ghostty, Alacritty and +other implementations use the same negotiated protocol, without a terminal-brand +allowlist or reliance on TERM naming. Graphics/image support is independent: this +app renders text and does not require or send Kitty graphics commands. Protocol +reference: [Kitty keyboard](https://sw.kovidgoyal.net/kitty/keyboard-protocol/). + +`--keyboard basic` skips queries for troublesome terminals/multiplexers. Modified +Enter may be indistinguishable in basic mode or intercepted by emulator bindings +(e.g. Ghostty fullscreen); use F8 to finish. Repeat +deliveries type once each; release events never type. Bracketed paste is blocked +during tests. Terminals that send unmarked paste cannot be distinguished from +typing. IME committed text works when delivered as ordinary characters; terminal +preedit/composition lifecycle and physical key identity are unavailable. + +The renderer keeps UTF-16 engine offsets separate from grapheme clusters and +terminal columns. It handles combining marks, CJK, emoji, tabs/newlines and wraps; +font/emulator Unicode width versions can differ. RTL ordering, Arabic/Indic +shaping, emoji appearance and terminal IME behavior are not browser-equivalent +guarantees. See the tested matrix below. Generated isolated surrogates are rejected +as untypable rather than replaced. Underline plus `!` indicates errors without +relying on color. Terminal/default, light and high-contrast themes are available; +reduced animation lowers active redraw frequency. + +Physical-key timing remains unavailable even if releases are reported. History +stores null for absent measurements. WPM, accuracy, duration and validity come +from native core with actual committed text and monotonic delivery timestamps. +Short/failed/repeated results remain in history with their native invalid reason. + +[Content packs and supported rules](terminal-content.md) document offline assets, +CLI settings and explicit unavailable hooks. [Local storage](terminal-storage.md) +documents paths, format, concurrency, privacy and export. Replay recording is an +explicit opt-in in settings or `--record-replay`; it stores private target text and +input. The header warns while enabled. Default summaries contain neither. Saved +custom mode requires private text again; use `--text`, `--file`, or launch with +`--mode words` and open F4. Packs must be supplied again with `--pack`. + +Failed result writes stay queued in memory with an UNSAVED indicator; F9 retries. +Quitting with pending results reports the loss and leaves prior history intact. +Malformed/future data is preserved with a clear error. No automatic deletion or +silent reset. Export never replaces an existing path. For uninstall, remove the +binary; optionally remove the config/data directories printed by `--paths` after +closing all instances. Copy whole closed databases/directories when backing up. + +Cleanup covers normal exit, errors, panic and supported signals (Unix HUP/TERM/INT, +suspend/resume). SIGKILL, power loss and forced process termination cannot run +cleanup; use the shell's terminal reset command if needed. SQLite transactions +protect prior committed data; a result not yet committed can be lost. + +## Verification matrix + +| Environment | Evidence | Status | +| --- | --- | --- | +| Linux ARM64, POSIX PTY/basic protocol | Five modes, settings/editor, history/export/replay, resize, cleanup, SIGTERM, suspend/resume | Pass | +| Linux ARM64, simulated enhanced protocol in PTY | Negotiation, press/repeat/release, owned flag cleanup | Pass; protocol simulation | +| Linux x86_64/ARM64 and macOS Intel/Apple Silicon CI PTYs | Native input/editing/history; POSIX lifecycle, resize, suspend/resume, extracted release input | Pass; automated PTY | +| Kitty 0.32.2 and 0.49.2, Linux Xvfb | Auto/basic, real text/key encoding, Unicode, repeats, resize, history/editor/replay, native results, cleanup | Pass; automated real emulator | +| Alacritty 0.13.2 (CI) / 0.17.0, Linux Xvfb | Same auto/basic flow; native UTF-16/grapheme input and cleanup | Pass; automated real emulator | +| Ghostty 1.3.1, Linux Xvfb | Same auto/basic flow; basic uses committed é; F8 avoids default fullscreen binding | Pass; automated real emulator | +| Ratatui TestBackend | Combining/CJK/emoji/tab/newline/cell wrapping/caret/UTF-16 deletion | Pass; simulated display | +| Physical Linux input/IME | Hardware/IME, font appearance and scrollback | Unperformed; not a merge requirement | +| Real macOS terminal | Hardware/IME, font appearance, resize, suspend | Owner will check after merge | + +Build/smoke archive verification is recorded separately in terminal-release.md. +Real Linux emulator tests run actual Kitty/Ghostty/Alacritty processes with +XTest input in private Xvfb displays. They exercise key encoding, combining/CJK/ +emoji UTF-16/grapheme deletion, repeats/releases, resize/input blocking, native +scores, history/replay/editor and actual keyboard-stack/raw-mode cleanup. They +are automated emulator checks; [terminal-emulators.json](terminal-emulators.json) +records exact binary/emulator versions and capabilities. Hardware IME and visual font inspection remain +unperformed. Exact versions/results are recorded in progress.md. + +`terminal:test:emulator` requires Linux Xvfb, xdotool, libX11 and the selected +emulator. It creates its own display, fresh emulator configuration and app profile; +it never targets the user's desktop windows. `--emulator` accepts kitty, alacritty +or ghostty; `--command` names the installed executable. `--evidence DIR` retains +version/capability JSON and private test input/output traces. Distribution Kitty +and Alacritty are checked in native CI; local checks also cover Ghostty. + +Compatibility depends on the implemented text/keyboard protocol, terminal version +and configuration. Do not infer certification of every version/fork from graphics +support alone. Basic text fallback and unavailable observations remain explicit. diff --git a/docs/rust-engine/third-party/license-overrides.json b/docs/rust-engine/third-party/license-overrides.json new file mode 100644 index 000000000..0d8407a23 --- /dev/null +++ b/docs/rust-engine/third-party/license-overrides.json @@ -0,0 +1,16 @@ +{ + "ts-rs@12.0.1": { + "source": "https://raw.githubusercontent.com/Aleph-Alpha/ts-rs/7182ad8289596097235406b715fa04506443b4ad/LICENSE", + "vcsRevision": "7182ad8289596097235406b715fa04506443b4ad", + "license": "MIT", + "file": "ts-rs-12.0.1.LICENSE", + "sha256": "db7f7f8e7236a2d0b41b855b2501b7b913caebbcda6ce498637a304ad1706ce1" + }, + "ts-rs-macros@12.0.1": { + "source": "https://raw.githubusercontent.com/Aleph-Alpha/ts-rs/7182ad8289596097235406b715fa04506443b4ad/LICENSE", + "vcsRevision": "7182ad8289596097235406b715fa04506443b4ad", + "license": "MIT", + "file": "ts-rs-12.0.1.LICENSE", + "sha256": "db7f7f8e7236a2d0b41b855b2501b7b913caebbcda6ce498637a304ad1706ce1" + } +} diff --git a/docs/rust-engine/third-party/ts-rs-12.0.1.LICENSE b/docs/rust-engine/third-party/ts-rs-12.0.1.LICENSE new file mode 100644 index 000000000..c23d24d80 --- /dev/null +++ b/docs/rust-engine/third-party/ts-rs-12.0.1.LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Aleph Alpha GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json index 03c613163..3c6261fed 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,17 @@ "engine:fmt": "node scripts/engine.ts fmt", "engine:fmt-fix": "node scripts/engine.ts fmt-fix", "engine:test:wasm": "node scripts/engine.ts browser", - "engine:parity": "node scripts/engine.ts parity" + "engine:parity": "node scripts/engine.ts parity", + "terminal:dev": "node scripts/terminal.ts dev", + "terminal:build": "node scripts/terminal.ts build", + "terminal:test": "node scripts/terminal.ts test", + "terminal:test:pty": "node scripts/terminal.ts pty", + "terminal:test:emulator": "node scripts/terminal.ts emulator", + "terminal:package": "node scripts/terminal.ts package", + "terminal:source": "node scripts/terminal.ts source", + "terminal:archive-smoke": "node scripts/terminal.ts archive-smoke", + "terminal:release-gate": "node scripts/terminal.ts release-gate", + "terminal:test:release-tools": "node scripts/terminal.ts tools-test" }, "devDependencies": { "@commitlint/cli": "21.2.3", diff --git a/packages/release/__tests__/ci-routing.spec.js b/packages/release/__tests__/ci-routing.spec.js index b64f3d97b..f63c91988 100644 --- a/packages/release/__tests__/ci-routing.spec.js +++ b/packages/release/__tests__/ci-routing.spec.js @@ -309,3 +309,13 @@ it("builds the production Worker once and shares it with SSR and browser shards" } expect(workflow.jobs["ci-ok"].needs).toContain("start-build"); }); + +// Native UI changes belong to the separate terminal matrix, not web deployments. +it.each([ + "crates/oxytype-tui/src/main.rs", + "crates/oxytype-tui/tests/storage.rs", + "scripts/terminal_release.py", + ".github/workflows/terminal-release.yml", +])("keeps terminal-only %s independent from web consumers", (file) => { + expect(selected([file])).toEqual(["code"]); +}); diff --git a/scripts/terminal.ts b/scripts/terminal.ts new file mode 100644 index 000000000..fb15dc363 --- /dev/null +++ b/scripts/terminal.ts @@ -0,0 +1,60 @@ +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; + +const command = process.argv[2]; +const args = process.argv.slice(3); +// vp forwards its separator; strip only that first token, preserving values. +if (args[0] === "--") args.shift(); +if (command === "tools-test") { + const result = spawnSync( + "python3", + ["-m", "unittest", "scripts/terminal_release_test.py"], + { cwd: resolve(import.meta.dirname, ".."), stdio: "inherit" }, + ); + if (result.error !== undefined) throw result.error; + process.exit(result.status ?? 1); +} +if ( + command !== undefined && + ["package", "source", "archive-smoke", "release-gate"].includes(command) +) { + const result = spawnSync( + "python3", + ["scripts/terminal_release.py", command, ...args], + { cwd: resolve(import.meta.dirname, ".."), stdio: "inherit" }, + ); + if (result.error !== undefined) throw result.error; + process.exit(result.status ?? 1); +} +const tasks: Record = { + dev: ["run", "--locked", "-p", "oxytype-tui", "--", ...args], + build: ["build", "--locked", "--release", "-p", "oxytype-tui", ...args], + test: ["test", "--locked", "-p", "oxytype-tui", ...args], + pty: ["build", "--locked", "-p", "oxytype-tui"], + emulator: ["build", "--locked", "-p", "oxytype-tui"], +}; +if (command === undefined || tasks[command] === undefined) { + throw new Error( + "Usage: vp run terminal:dev|terminal:build|terminal:test|terminal:test:emulator", + ); +} +const result = spawnSync("cargo", tasks[command], { + cwd: resolve(import.meta.dirname, ".."), + stdio: "inherit", +}); +if (result.error !== undefined) throw result.error; +if ((command === "pty" || command === "emulator") && result.status === 0) { + const check = spawnSync( + "python3", + command === "pty" + ? ["scripts/terminal_pty.py", "target/debug/oxytype"] + : ["scripts/terminal_emulator.py", ...args], + { + cwd: resolve(import.meta.dirname, ".."), + stdio: "inherit", + }, + ); + if (check.error !== undefined) throw check.error; + process.exit(check.status ?? 1); +} +process.exit(result.status ?? 1); diff --git a/scripts/terminal_emulator.py b/scripts/terminal_emulator.py new file mode 100644 index 000000000..43dd9a8c4 --- /dev/null +++ b/scripts/terminal_emulator.py @@ -0,0 +1,300 @@ +"""Real Linux X11 emulator checks via XTest; no simulated keyboard replies. + +Starts its own private Xvfb server; requires Xvfb, xdotool and an emulator. +The recorder forwards bytes unchanged through a PTY, retaining the app output +and cleanup evidence. Only test-owned windows, profiles and processes are used. +""" +import argparse +import ctypes +import hashlib +import json +import os +import pathlib +import re +import select +import signal +import sqlite3 +import subprocess +import sys +import tempfile +import termios +import time +import tty +from terminal_pty import Child, dimensions, screen_text + + +def write_all(fd, data): + while data: + count=os.write(fd,data) + data=data[count:] + + +def record(binary, profile, keyboard): + original=termios.tcgetattr(0) + child=Child(binary, args=["--mode","zen","--record-replay"], + keyboard=keyboard, term=os.environ.get("TERM","xterm-256color")) + written=0 + def resized(*_): + size=os.get_terminal_size(0) + dimensions(child.slave,size.columns,size.lines) + try: + tty.setraw(0) + signal.signal(signal.SIGWINCH,resized) + resized() + (profile / "ready.json").write_text(json.dumps({"pid":child.process.pid,"term":os.environ.get("TERM"),"dataDir":child.profile.name+"/data"})) + with (profile / "output.vt").open("wb",buffering=0) as trace, (profile / "input.vt").open("wb",buffering=0) as input_trace: + while not child.status_path.exists(): + child.read(0.01) + if len(child.output)>written: + data=child.output[written:] + trace.write(data) + write_all(1,data) + written=len(child.output) + if select.select([0],[],[],0)[0]: + data=os.read(0,65536) + if not data: raise RuntimeError("Emulator input closed before app exit") + input_trace.write(data) + child.send(data) + status=child.wait_exit() + child.restored() + data=child.output[written:] + trace.write(data) + write_all(1,data) + # Ask the real emulator after the app popped its owned alternate-screen + # keyboard frame. A fresh query must report zero, not leaked flags. + write_all(1,b"\x1b[?u\x1b[c") + reply=bytearray() + deadline=time.monotonic()+2 + while time.monotonic()7,final + assert all(c==ord("x") for c in final[6:]),final + assert zen["capabilities"]["enhancedKeyboardNegotiated"]==(keyboard=="auto") + assert zen["summary"]["keyDuration"] is None + assert custom["summary"]["acc"]==87.5,custom + evidence.append({"emulator":args.emulator,"version":version,"appVersion":binary_version,"binarySha256":binary_sha256,"keyboard":keyboard, + "term":json.loads((profile / "ready.json").read_text())["term"], + "exitCode":0,"keyboardFlagsAfterExit":0,"customAccuracy":87.5, + "repeatDeliveries":len(final)-6,"accentInput":accent,"capabilities":zen["capabilities"]}) + print(f"{args.emulator}/{keyboard}: real keys, Unicode/grapheme delete, repeat/release, resize, finish, history/replay/editor, native score, cleanup pass",flush=True) + finally: + if output: + import shutil + shutil.copytree(profile,output / (args.emulator+"-"+keyboard),dirs_exist_ok=True) + if (profile / "ready.json").exists(): + pid=json.loads((profile / "ready.json").read_text())["pid"] + try: os.killpg(pid,signal.SIGCONT);os.killpg(pid,signal.SIGKILL) + except ProcessLookupError: pass + if process.poll() is None: process.terminate() + try: process.wait(timeout=5) + except subprocess.TimeoutExpired: process.kill();process.wait(timeout=5) + if output: (output / (args.emulator+".json")).write_text(json.dumps(evidence,indent=2)+"\n") + + +if __name__=="__main__": + if len(sys.argv)>1 and sys.argv[1]=="--record": + record(sys.argv[2],pathlib.Path(sys.argv[3]),sys.argv[4]) + else: + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument("--emulator",choices=["kitty","alacritty","ghostty"],required=True) + parser.add_argument("--command",required=True) + parser.add_argument("--binary",default="target/debug/oxytype") + parser.add_argument("--xdotool",default="xdotool") + parser.add_argument("--xvfb",default="Xvfb") + parser.add_argument("--evidence") + check(parser.parse_args()) diff --git a/scripts/terminal_pty.py b/scripts/terminal_pty.py new file mode 100644 index 000000000..54880df06 --- /dev/null +++ b/scripts/terminal_pty.py @@ -0,0 +1,395 @@ +"""Real POSIX PTY lifecycle checks; no claimed hardware-terminal coverage.""" +import errno +import fcntl +import os +import pathlib +import pty +import re +import select +import signal +import sqlite3 +import json +import struct +import subprocess +import sys +import termios +import tempfile +import time +import unicodedata + + +def dimensions(slave, columns=100, rows=24): + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0)) + + +class Child: + def __init__(self, binary, failure=None, args=(), enhanced=False, keyboard=None, term="xterm-256color"): + self.profile = tempfile.TemporaryDirectory(prefix="oxytype-pty-") + self.enhanced = enhanced + self.queries_replied = 0 + self.master, self.slave = pty.openpty() + dimensions(self.slave) + self.original = termios.tcgetattr(self.slave) + self.output = bytearray() + self.status_path = pathlib.Path(self.profile.name) / "exit-status" + env = dict(os.environ, TERM=term, OXYTYPE_PTY_STATUS=str(self.status_path)) + if failure: + env["OXYTYPE_TERMINAL_TEST_FAILURE"] = failure + + def controlling_terminal(): + os.setsid() + fcntl.ioctl(self.slave, termios.TIOCSCTTY, 0) + + self.process = subprocess.Popen([sys.executable, str(pathlib.Path(__file__).resolve()), "--keeper", binary, "--config-dir", self.profile.name + "/config", "--data-dir", self.profile.name + "/data", "--keyboard", keyboard or ("auto" if enhanced else "basic"), *args], stdin=self.slave, stdout=self.slave, + stderr=self.slave, env=env, + preexec_fn=controlling_terminal) + + def read(self, seconds=0.05): + if select.select([self.master], [], [], seconds)[0]: + try: + self.output.extend(os.read(self.master, 65536)) + if self.enhanced and self.output.count(b"\x1b[?u") > self.queries_replied: + # Protocol simulation, not a claim of hardware-terminal coverage. + self.send(b"\x1b[?0u\x1b[?1;2c") + self.queries_replied += 1 + except OSError as error: + if error.errno != errno.EIO: + raise + + def until(self, text, seconds=5, fresh=False): + deadline = time.monotonic() + seconds + start = len(self.output) + while time.monotonic() < deadline: + self.read() + if (not fresh or len(self.output) > start) and text.decode("utf-8") in self.screen_text(): + return + if self.status_path.exists() or self.process.poll() is not None: + break + raise AssertionError(f"Missing {text!r}; process={self.process.poll()}; output={bytes(self.output[-2000:])!r}") + + def screen_text(self): + return screen_text(self.output) + + def send(self, text): + os.write(self.master, text) + + def wait_exit(self, timeout=5): + # A real emulator continuously consumes output. macOS PTYs have a small + # output buffer; waiting without reading can block the app before quit. + deadline = time.monotonic() + timeout + while not self.status_path.exists() and time.monotonic() < deadline: + self.read(0.02) + if self.process.poll() is not None: + raise AssertionError("PTY session keeper exited before app status") + if not self.status_path.exists(): + raise subprocess.TimeoutExpired(self.process.args, timeout) + return int(self.status_path.read_text()) + + def restored(self): + current = termios.tcgetattr(self.slave) + mask = termios.ICANON | termios.ECHO + assert current[3] & mask == self.original[3] & mask, "raw/echo state leaked" + while select.select([self.master], [], [], 0)[0]: + self.read(0) + assert b"\x1b[?1049l" in self.output, "alternate screen not restored" + assert b"\x1b[?2004l" in self.output, "bracketed paste not disabled" + + def close(self): + try: + if self.process.poll() is None: + os.killpg(self.process.pid, signal.SIGCONT) + os.killpg(self.process.pid, signal.SIGKILL) + deadline=time.monotonic()+3 + while self.process.poll() is None and time.monotonic()").split(";") if v.isdigit() or not v] + first = (values or [0])[0] + amount = first or 1 + if command in ("H", "f"): + row = amount - 1 + col = (values[1] or 1) - 1 if len(values) > 1 else 0 + elif command == "G": col = amount - 1 + elif command == "d": row = amount - 1 + elif command == "A": row = max(0, row - amount) + elif command == "B": row += amount + elif command == "C": col += amount + elif command == "D": col = max(0, col - amount) + elif command == "J" and first in (2, 3): grid.clear() + elif command == "K": + for key in list(grid): + if key[0] == row and (first == 2 or (first == 0 and key[1] >= col) or (first == 1 and key[1] <= col)): + del grid[key] + elif command == "h" and private and first == 1049: + grid.clear(); row = col = 0 + i += len(match.group()) + continue + i += 2 + continue + char = data[i] + if char == "\r": col = 0 + elif char == "\n": row += 1 + elif not unicodedata.category(char).startswith("C"): + if unicodedata.category(char).startswith("M"): + previous=col-1 + while previous>0 and grid.get((row,previous))=="": previous-=1 + grid[row,previous]=grid.get((row,previous),"")+char + else: + grid[row,col]=char + width=2 if unicodedata.east_asian_width(char) in ("W","F") else 1 + if width==2: grid[row,col+1]="" # Occupied continuation cell, not a space. + col+=width + i += 1 + return "\n".join("".join(grid.get((y, x), " ") for x in range(200)) for y in range(100)) + + +def run(binary, release=False): + child = Child(binary) + try: + child.until(b"Oxytype") + assert termios.tcgetattr(child.slave)[3] & termios.ICANON == 0 + child.send(b"one ") + time.sleep(0.15) + child.send(b"\x1b[19~") # F8 is available without enhanced Ctrl+Enter. + child.until(b"F5 new test") + child.until(b"Result saved locally.") + history_path = pathlib.Path(child.profile.name) / "data/history.sqlite3" + with sqlite3.connect(history_path) as db: + record = json.loads(db.execute("SELECT json FROM results").fetchone()[0]) + assert record["replayLog"] is None + assert record["summary"]["keyDuration"] is None + child.send(b"\x1bOR") # F3 history + child.until(b"Local history") + child.send(b"\x1b[18~") # F7 export + child.until(b"Exported") + exported = list((pathlib.Path(child.profile.name) / "data").glob("export-*.json")) + assert len(exported) == 1 + assert json.loads(exported[0].read_text())["results"][0]["id"] == record["id"] + child.send(b"\x1b[15~") + child.until(b"Input:") + dimensions(child.slave, 30, 8) + os.kill(child.process.pid, signal.SIGWINCH) + # Ratatui skips blank cells with cursor moves; words need not be emitted + # as one contiguous byte string in a real incremental terminal frame. + child.until(b"Resize") + child.send(b"lost") + time.sleep(0.1) + dimensions(child.slave) + os.kill(child.process.pid, signal.SIGWINCH) + child.until(b"Oxytype") + assert "Input: lost" not in child.screen_text() + child.send(b"\x11") + assert child.wait_exit() == 0 + child.restored() + finally: + child.close() + + for mode in ("time", "words", "quote", "custom", "zen"): + args = ["--mode", mode] + if mode == "custom": + args += ["--text", "one two three"] + child = Child(binary, args=args) + try: + child.until(b"Oxytype") + child.send(b"one ") + time.sleep(0.15) + child.send(b"\x1b[19~") + child.until(b"F5 new test") + child.send(b"\x11") + assert child.wait_exit() == 0 + child.restored() + finally: + child.close() + + child = Child(binary) + try: + child.until(b"Oxytype") + child.send(b"\x1bOQ") # F2: settings + child.until(b"select") + child.send(b"\x1bOS") # F4: private editor + child.until(b"Private") + child.send(b"\x1b[200~one two\x1b[201~") + time.sleep(0.1) + child.send(b"\x1b[19~") + child.until(b"Input:") + child.send(b"one ") + time.sleep(0.15) + child.send(b"two") + child.until(b"F5 new test") + child.send(b"\x11") + assert child.wait_exit() == 0 + child.restored() + finally: + child.close() + + child = Child(binary, args=["--mode", "custom", "--text", "one two", "--record-replay"]) + try: + child.until(b"RECORDING PRIVATE TEXT") + child.send(b"\x1b[200~PASTE_BLOCKED\x1b[201~") + child.until(b"Paste blocked during typing.") + child.send(b"one ") + time.sleep(0.15) + child.send(b"two") + child.until(b"Result saved locally.") + child.send(b"\x1bOR") + child.until(b"Local history") + child.send(b"\x1b[19~") + child.until(b"Recorded replay") + with sqlite3.connect(pathlib.Path(child.profile.name) / "data/history.sqlite3") as db: + saved = json.loads(db.execute("SELECT json FROM results").fetchone()[0]) + committed = [event for event in saved["replayLog"]["events"] if event["type"] == "input" and event["data"]["inputType"] == "insertText"] + assert len(committed) == 7, "Paste entered the native log or custom text did not complete" + child.send(b"\x1b[C") + child.until(b"step 1") + child.send(b"\x11") + assert child.wait_exit() == 0 + child.restored() + finally: + child.close() + + child = Child(binary, args=["--mode", "zen"], enhanced=True) + try: + child.until(b"Oxytype") + assert b"\x1b[>1u" in child.output, "enhanced flags not pushed" + child.send(b"\x1b[97;1u\x1b[97;1:2u\x1b[97;1:3u") # press/repeat/release + child.until(b"Input: aa") + child.send(b"\x1bOP") + child.until(b"Keyboard: enhanced") + child.until(b"release observed: true") + child.send(b"\x11") + assert child.wait_exit() == 0 + child.restored() + assert b"\x1b[<1u" in child.output, "enhanced flags not popped" + finally: + child.close() + + # CSI-u protocol boundaries are shared by Kitty-compatible implementations. + # Deliberately fragment sequences and UTF-8; do not assume one read per key. + child = Child(binary, args=["--mode", "zen", "--record-replay"], enhanced=True) + try: + child.until(b"Input:") + for packet in (b"\x1b[65;2u", b"\x1b[98:66;2u", b"\x1b[33;2u", "e\u0301界😀".encode()): + # A bare ESC is ambiguous with Esc; terminal CSI introducers are + # atomic. Exercise buffering after the introducer and within UTF-8. + if packet.startswith(b"\x1b["): + child.send(packet[:2]) + packet=packet[2:] + for octet in packet: + child.send(bytes([octet])) + time.sleep(0.002) + child.until("Input: AB!e\u0301界😀".encode()) + child.send(b"\x1b[127u\x1b[127;1:3u") # Emoji grapheme once, ignore release. + child.until("Input: AB!e\u0301界".encode()) + child.send(b"\x1b[120;5u\x1b[120;3u") # Ctrl/Alt shortcuts never type x. + child.send(b"\x1b[13;5:3u\x1b[19;1:3~") # Finish releases never finish. + child.send(b"\x1b[97;65u") # Caps-lock modifier does not discard committed text. + child.until("Input: AB!e\u0301界a".encode()) + child.send(b"\x1b[127;5u") # Ctrl+Backspace deletes word through the core. + child.until(b"Input:") + child.send(b"\x1b[122;1u\x1b[122;1:2u\x1b[122;1:3u") + child.until(b"Input: zz") + child.send(b"\x1b[13;5u") + child.until(b"Result saved locally.") + with sqlite3.connect(pathlib.Path(child.profile.name) / "data/history.sqlite3") as db: + saved=json.loads(db.execute("SELECT json FROM results").fetchone()[0]) + assert saved["capabilities"]["releaseObserved"] + assert saved["capabilities"]["repeatObserved"] + assert saved["summary"]["keyDuration"] is None + inputs=[e["data"] for e in saved["replayLog"]["events"] if e["type"]=="input"] + assert inputs[-1]["inputValue"]==[122,122], inputs + assert not any(e.get("data")==[120] for e in inputs), "Shortcut became typing" + child.send(b"\x1b[21~") # F10 legacy functional encoding. + assert child.wait_exit()==0 + child.restored() + finally: + child.close() + + # A graphics-capable or unknown terminal need not support enhanced keys. + # The unanswered bounded query falls back without swallowing ordinary input. + child = Child(binary, args=["--mode", "zen"], keyboard="auto", term="xterm-kitty") + try: + child.until(b"Input:") + assert b"\x1b[>1u" not in child.output + child.send(b"fallback") + child.until(b"Input: fallback") + child.send(b"\x1bOP") + child.until(b"Keyboard: basic") + child.send(b"\x11") + assert child.wait_exit()==0 + child.restored() + finally: + child.close() + + for failure in (("signal", "suspend") if release else ("error", "panic", "signal", "suspend")): + child = Child(binary, failure if failure in ("error", "panic") else None, enhanced=True) + try: + if failure in ("error", "panic"): + assert child.wait_exit() != 0 + else: + child.until(b"Oxytype") + if failure == "signal": + os.kill(child.process.pid, signal.SIGTERM) + else: + os.kill(child.process.pid, signal.SIGTSTP) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + child.read(0.02) # Drain restoration output before SIGSTOP. + pid, status = os.waitpid(child.process.pid, os.WNOHANG | os.WUNTRACED) + if pid and os.WIFSTOPPED(status): + break + time.sleep(0.02) + else: + raise AssertionError("Did not suspend") + child.restored() + os.killpg(child.process.pid, signal.SIGCONT) + child.until(b"Oxytype", fresh=True) + child.send(b"\x11") + assert child.wait_exit() == 0 + child.restored() + finally: + child.close() + print("PTY: five modes, settings/editor, history/export/replay, basic/enhanced/fragmented/fallback protocol, typing/finish/restart/resize/quit, SIGTERM, suspend/resume pass" + ("; debug error/panic cleanup pass" if not release else "")) + + +def keep_session(binary_args): + # Like a real shell, remain the session leader after the app exits. Darwin + # revokes a controlling PTY when its session leader dies, making tcgetattr + # impossible; retaining the session lets us verify actual cleanup unchanged. + child = subprocess.Popen(binary_args) + def forward(signum, _frame): + if child.poll() is None: + child.send_signal(signum) + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGTSTP): + signal.signal(signum, forward) + status = child.wait() + status_path=pathlib.Path(os.environ["OXYTYPE_PTY_STATUS"]) + temporary=status_path.with_suffix(".tmp") + temporary.write_text(str(status)) + temporary.replace(status_path) + while True: + signal.pause() + + +if __name__ == "__main__": + if sys.argv[1] == "--keeper": + keep_session(sys.argv[2:]) + else: + run(str(pathlib.Path(sys.argv[1]).resolve()), "--release" in sys.argv) diff --git a/scripts/terminal_release.py b/scripts/terminal_release.py new file mode 100644 index 000000000..16228a611 --- /dev/null +++ b/scripts/terminal_release.py @@ -0,0 +1,177 @@ +"""Native release tooling. No publication; platform workflow owns authorized tags.""" +import argparse +import hashlib +import json +import os +import pathlib +import shutil +import subprocess +import sys +import tarfile +import tempfile +import tomllib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +OUT = ROOT / "target/terminal-release" +TARGETS = ("x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "aarch64-apple-darwin") + + +def run(args, **kwargs): + return subprocess.run(args, cwd=ROOT, check=True, **kwargs) + + +def capture(args): + return run(args, stdout=subprocess.PIPE, text=True).stdout + + +def version(): + return tomllib.loads((ROOT / "Cargo.toml").read_text())["workspace"]["package"]["version"] + + +def checksum(path): + with path.open("rb") as source: + digest = hashlib.file_digest(source, "sha256").hexdigest() + path.with_name(path.name + ".sha256").write_text(f"{digest} {path.name}\n") + + +def archive(directory): + output = directory.with_name(directory.name + ".tar.gz") + with tarfile.open(output, "w:gz") as package: + package.add(directory, arcname=directory.name) + checksum(output) + return output + + +def licenses(directory, target): + metadata = json.loads(capture(["cargo", "metadata", "--locked", "--format-version", "1", "--filter-platform", target])) + notices = [] + override_dir=ROOT / "docs/rust-engine/third-party" + overrides=json.loads((override_dir / "license-overrides.json").read_text()) + for package in sorted(metadata["packages"], key=lambda p: (p["name"], p["version"])): + if package["source"] is None: continue + source = pathlib.Path(package["manifest_path"]).parent + dest = directory / "licenses" / f'{package["name"]}-{package["version"]}' + dest.mkdir(parents=True) + files = [p for p in source.iterdir() if p.name.upper().startswith(("LICENSE", "LICENCE", "COPYING", "COPYRIGHT", "NOTICE"))] + if package.get("license_file"): + explicit = source / package["license_file"] + if explicit not in files: files.append(explicit) + override=overrides.get(f'{package["name"]}@{package["version"]}') + if override: + vcs=json.loads((source / ".cargo_vcs_info.json").read_text()) + license_path=override_dir / override["file"] + assert vcs["git"]["sha1"]==override["vcsRevision"] + assert package["license"]==override["license"] + assert hashlib.sha256(license_path.read_bytes()).hexdigest()==override["sha256"] + files.append(license_path) + if not files: raise RuntimeError(f'No distributable license/notice file for {package["name"]}') + for path in files: + if path.is_dir(): shutil.copytree(path, dest / path.name, dirs_exist_ok=True) + else: shutil.copy2(path, dest / path.name) + notices.append({"name":package["name"],"version":package["version"],"license":package["license"],"repository":package["repository"]}) + (directory / "THIRD_PARTY.json").write_text(json.dumps({"scope":"Locked workspace dependency notices (superset of linked native crates)","packages":notices}, indent=2) + "\n") + + +def package(target): + if target is None: + target = next(line.split(": ", 1)[1] for line in capture(["rustc", "-vV"]).splitlines() if line.startswith("host: ")) + if target not in TARGETS: raise RuntimeError(f"Unsupported archive target: {target}") + tag = os.environ.get("GITHUB_REF_NAME", "") + if os.environ.get("GITHUB_REF_TYPE") == "tag" and tag != f"terminal-v{version()}": + raise RuntimeError("Terminal tag must match Cargo workspace version") + revision = os.environ.get("OXYTYPE_BUILD_REV") or capture(["git","rev-parse","HEAD"]).strip() + if revision != capture(["git","rev-parse","HEAD"]).strip(): raise RuntimeError("Archive build revision must match checked-out source") + run(["git","diff","--quiet","HEAD","--"]) + run(["cargo", "build", "--locked", "--release", "-p", "oxytype-tui", "--target", target],env=dict(os.environ,OXYTYPE_BUILD_REV=revision)) + name = f"oxytype-v{version()}-{target}" + directory = OUT / name + if directory.exists(): shutil.rmtree(directory) + directory.mkdir(parents=True) + binary = "oxytype" + shutil.copy2(ROOT / "target" / target / "release" / binary, directory / binary) + for source, dest in [("LICENSE","LICENSE"),("crates/oxytype-tui/assets/ATTRIBUTION.md","ATTRIBUTION.md"),("docs/rust-engine/terminal.md","README.md"),("docs/rust-engine/terminal-content.md","terminal-content.md"),("docs/rust-engine/terminal-storage.md","terminal-storage.md")]: + shutil.copy2(ROOT / source,directory / dest) + (directory / "SOURCE.md").write_text(f"Source revision: {revision}\n\nGPL-3.0-only corresponding source is the matching oxytype-v{version()}-source.tar.gz release asset, including locked vendored dependencies.\n\nRepository: https://github.com/voltcrash/oxytype/tree/{revision}\n") + licenses(directory,target) + output = archive(directory) + print(f"Archive: {output}") + return output + + +def smoke(path): + expected=path.with_name(path.name+".sha256").read_text().split()[0] + assert hashlib.sha256(path.read_bytes()).hexdigest()==expected, "Archive checksum mismatch" + with tempfile.TemporaryDirectory(prefix="oxytype-install-") as temporary: + directory=pathlib.Path(temporary) + with tarfile.open(path) as package: package.extractall(directory,filter="data") + binaries = list(directory.rglob("oxytype")) + if len(binaries)!=1: raise RuntimeError("Expected one installed executable") + binary=binaries[0] + isolated=directory / "empty";isolated.mkdir() + env=dict(os.environ,PATH="") + for args in [["--version"],["--help"],["--diagnostics"],["--smoke-test"]]: + subprocess.run([str(binary),*args],cwd=isolated,env=env,check=True) + diagnostic=subprocess.run([str(binary),"--diagnostics"],cwd=isolated,env=env,check=True,stdout=subprocess.PIPE,text=True,encoding="utf-8") + version_info=json.loads(diagnostic.stdout) + revision=(binary.parent / "SOURCE.md").read_text(encoding="utf-8").splitlines()[0].removeprefix("Source revision: ") + assert f"build {revision})" in version_info["version"], "Binary/source provenance mismatch" + assert version_info["architecture"]==("aarch64" if "aarch64" in binary.parent.name else "x86_64") + export=isolated / "history.json" + subprocess.run([str(binary),"--config-dir",str(isolated / "config"),"--data-dir",str(isolated / "data"),"--export",str(export)],cwd=isolated,env=env,check=True) + assert json.loads(export.read_text())["results"]==[] + run([sys.executable,"scripts/terminal_pty.py",str(binary),"--release"]) + print("Installed archive: empty PATH/cwd, offline smoke, storage/export and supported POSIX PTY pass") + + +def source(): + OUT.mkdir(parents=True,exist_ok=True) + directory=OUT / f"oxytype-v{version()}-source" + if directory.exists(): shutil.rmtree(directory) + directory.mkdir() + snapshot=run(["git","archive","--format=tar","HEAD"],stdout=subprocess.PIPE).stdout + import io + with tarfile.open(fileobj=io.BytesIO(snapshot)) as package: package.extractall(directory,filter="data") + config=subprocess.run(["cargo","vendor","--locked","--versioned-dirs","vendor"],cwd=directory,check=True,stdout=subprocess.PIPE,text=True).stdout + cargo=directory / ".cargo";cargo.mkdir(exist_ok=True) + with (cargo / "config.toml").open("a") as output: output.write("\n"+config) + revision=os.environ.get("OXYTYPE_BUILD_REV") or capture(["git","rev-parse","HEAD"]).strip() + (directory / "SOURCE_REVISION").write_text(revision+"\n") + # Prove corresponding source can build without registry/network access. + subprocess.run(["cargo","check","--offline","--locked","-p","oxytype-tui","--target-dir",str(ROOT / "target/terminal-source-check")],cwd=directory,env=dict(os.environ,OXYTYPE_BUILD_REV=revision),check=True) + print(f"Corresponding source: {archive(directory)}") + + +def gate(): + matrix=json.loads((ROOT / "docs/rust-engine/terminal-verification.json").read_text()) + if matrix.get("formatVersion")!=1 or set(matrix.get("manualTerminals",{}))!={"macOS"}: + raise RuntimeError("Release verification requires the current macOS manual record; Linux emulator CI is a separate required job") + pending=[name for name,record in matrix["manualTerminals"].items() if record.get("status")!="pass" or not isinstance(record.get("evidence"),str) or not record["evidence"].strip()] + if pending: raise RuntimeError("Release manual terminal gate pending: "+", ".join(pending)) + + +def checksums(directory): + expected=[f"oxytype-v{version()}-{target}.tar.gz" for target in TARGETS]+[f"oxytype-v{version()}-source.tar.gz"] + rows=[] + for name in expected: + path=directory / name + digest=hashlib.sha256(path.read_bytes()).hexdigest() + assert path.with_name(name+".sha256").read_text().split()[0]==digest + rows.append(f"{digest} {name}\n") + (directory / "SHA256SUMS").write_text("".join(rows)) + + +if __name__=="__main__": + parser=argparse.ArgumentParser() + parser.add_argument("command",choices=["package","source","archive-smoke","release-gate","checksums"]) + parser.add_argument("--target") + parser.add_argument("--archive",type=pathlib.Path) + args=parser.parse_args() + if args.command=="package": package(args.target) + elif args.command=="source": source() + elif args.command=="release-gate": gate() + elif args.command=="checksums": checksums(args.archive.resolve()) + else: + if args.archive is None: + if args.target not in TARGETS: parser.error("--archive or a supported --target required") + args.archive=OUT / f"oxytype-v{version()}-{args.target}.tar.gz" + smoke(args.archive.resolve()) diff --git a/scripts/terminal_release_test.py b/scripts/terminal_release_test.py new file mode 100644 index 000000000..f18896f37 --- /dev/null +++ b/scripts/terminal_release_test.py @@ -0,0 +1,73 @@ +"""Archive integrity and supported release targets, using actual files.""" +import hashlib +import json +import os +import pathlib +import tarfile +import tempfile +import unittest +from unittest.mock import patch +from scripts import terminal_release + + +class ArchiveTest(unittest.TestCase): + def test_tar_preserves_binary_bytes_permissions_and_registry_timestamps(self): + with tempfile.TemporaryDirectory() as temporary: + directory=pathlib.Path(temporary) / "candidate" + directory.mkdir() + source=directory / "oxytype" + content=b"Binary bytes\n\x00\xff" + source.write_bytes(content) + source.chmod(0o755) + os.utime(source,(1,1)) + archive=terminal_release.archive(directory) + with tarfile.open(archive) as package: + member=package.getmember("candidate/oxytype") + self.assertEqual(package.extractfile(member).read(),content) + self.assertEqual(member.mode,0o755) + self.assertEqual(member.mtime,1) + self.assertEqual(source.read_bytes(),content) + self.assertEqual(int(source.stat().st_mtime),1) + expected=archive.with_name(archive.name+".sha256").read_text().split()[0] + self.assertEqual(hashlib.sha256(archive.read_bytes()).hexdigest(),expected) + + def test_unsupported_target_rejected_before_building(self): + with patch.object(terminal_release,"run") as run: + with self.assertRaisesRegex(RuntimeError,"Unsupported archive target"): + terminal_release.package("x86_64-pc-windows-msvc") + run.assert_not_called() + + def test_checksums_require_four_unix_binaries_and_corresponding_source(self): + self.assertEqual(set(terminal_release.TARGETS),{ + "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", "aarch64-apple-darwin", + }) + with tempfile.TemporaryDirectory() as temporary: + root=pathlib.Path(temporary) + names=[f"oxytype-v{terminal_release.version()}-{target}.tar.gz" for target in (*terminal_release.TARGETS,"source")] + for name in names: + path=root / name + path.write_bytes(name.encode()) + terminal_release.checksum(path) + terminal_release.checksums(root) + self.assertEqual(len((root / "SHA256SUMS").read_text().splitlines()),5) + (root / names[0]).write_bytes(b"corruption") + with self.assertRaises(AssertionError): terminal_release.checksums(root) + + def test_release_gate_requires_the_owner_mac_record_and_never_accepts_an_empty_matrix(self): + with tempfile.TemporaryDirectory() as temporary: + root=pathlib.Path(temporary) + matrix=root / "docs/rust-engine/terminal-verification.json" + matrix.parent.mkdir(parents=True) + with patch.object(terminal_release,"ROOT",root): + for record in [{}, {"status":"pending","evidence":None}, {"status":"pass","evidence":" "}]: + matrix.write_text(json.dumps({"formatVersion":1,"manualTerminals":{"macOS":record}})) + with self.assertRaises(RuntimeError): terminal_release.gate() + matrix.write_text(json.dumps({"formatVersion":1,"manualTerminals":{}})) + with self.assertRaisesRegex(RuntimeError,"requires the current macOS"): terminal_release.gate() + matrix.write_text(json.dumps({"formatVersion":1,"manualTerminals":{"macOS":{"status":"pass","evidence":"test record only"}}})) + terminal_release.gate() + + +if __name__=="__main__": + unittest.main()