diff --git a/.audit/omacy-impl.tsv b/.audit/omacy-impl.tsv new file mode 100644 index 0000000..5b76523 --- /dev/null +++ b/.audit/omacy-impl.tsv @@ -0,0 +1,15 @@ +ts phase decision why evidence result +2026-08-27T14:54:03Z frame lock done-predicate: engine FFI+parity tests green on Linux; Swift host+appex sources match architecture.md; canary idle/System Settings remains a Mac gate Linux VM cannot compile Swift or run ScreenSaverEngine; prove the engine here and ship complete macOS sources docs/architecture.md:298 docs/ffi.md docs/parity.md framed +2026-08-27T15:09:47Z harness vendor ttfx at v0.3.2 pin and copy Omarchy wordmark scaffold before features; pin is the recorded SHA not floating master vendor/ttfx/PIN assets/branding/screensaver.txt copied +2026-08-27T16:22:38Z impl snapshot fill_grid at frame() emit unstable rumble restores coords after ANSI; live pack desynced Metal from oracle vendor/ttfx/src/engine/terminal.rs store_snapshot; tests/parity.rs 37x2x2x180 green pass +2026-08-27T16:22:38Z impl ascii goldens + Fairfax HD + host/appex sources parity.md conversion matrix and architecture host+appex assets/fixtures/conversion apps/Omacy docs/macos-gates.md engine-proven; Mac idle unverified +2026-08-27T16:50:00Z impl close remaining architecture gaps atomic store, lifecycle, occupancy/atlas, dead recover, copy, panic+budget tests cargo test -p omacy-engine; cargo test --manifest-path vendor/ttfx/Cargo.toml; cargo test -p omacy-engine --release --test budget engine pass; Mac idle unverified +2026-08-27T16:46:50Z impl close remaining architecture gaps atomic store, lifecycle, occupancy/atlas, dead recover, copy, panic+budget tests docs/architecture.md testing table + host/appex sources in-progress +2026-08-27T17:01:46Z impl canary thumbnails, SVG href reject, session contract tests architecture thumbnail + FFI wait/pending/re-entrant + no file SVG cargo test -p omacy-engine pass +2026-08-27T17:06:12Z impl appex per-display loadView + duplicate pluginkit warning Settings preview 30Hz and multi-display need the real frame; PlugInKit sticky if two locations apps/Omacy/OmacyScreensaver/OmacySaverViewController.swift PluginManager.swift in-progress +2026-08-27T17:15:04Z ci add macos-15 xcodebuild of host+appex Linux cannot compile Swift; a GitHub Mac runner can prove the Aerial-path targets link .github/workflows/macos.yml in-progress +2026-08-27T17:37:02Z ci macos-26 xcodebuild + renderer NSObject + cargo PATH PaperSaver 0.2.0 needs Swift 6.2; macos-15 was 6.1; Xcode scripts drop ~/.cargo/bin; displayLink needs NSObject .github/workflows/macos.yml apps/Omacy/scripts/build-engine.sh OmacyRenderer.swift in-progress +2026-08-27T17:49:00Z ci one rust build phase (appex) macos-26 failed: both targets wrote libomacy_engine.a; host already depends on appex apps/Omacy/Omacy.xcodeproj/project.pbxproj in-progress +2026-08-27T18:11:00Z ci fix displayLink non-optional + omacy_status log macos-26 Swift: guard let on CADisplayLink; Int() on C enum apps/Omacy/Shared/OmacyRenderer.swift in-progress +2026-08-27T18:47:00Z ci host+appex BUILD SUCCEEDED on Xcode 26.6 deinit no longer calls MainActor stop; PreviewView is a typealias; one -lomacy_engine link local xcodebuild Release arm64; Omacy.app/Contents/PlugIns/OmacyScreensaver.appex pass +2026-08-27T20:10:30Z impl stop Metal drawable invalidation on layout CAMetalLayer was view.layer; layout rewrote drawableSize + rebuilt atlas every pass; busy ring presented cleared frames preview video 45% black frames; Peter confirmed host preview continuous pass diff --git a/.github/workflows/engine.yml b/.github/workflows/engine.yml new file mode 100644 index 0000000..f813fc7 --- /dev/null +++ b/.github/workflows/engine.yml @@ -0,0 +1,21 @@ +name: engine + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.88.0 + - name: Engine tests + run: cargo test -p omacy-engine + - name: Vendored ttfx tests + run: cargo test --manifest-path vendor/ttfx/Cargo.toml diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..107ed37 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,46 @@ +name: macos + +on: + push: + branches: [main] + pull_request: + +jobs: + host: + # PaperSaver 0.2.0 is swift-tools-version 6.2. macos-15 ships Xcode 16 / Swift 6.1. + # Deployment target stays 15.0; this runner is only the compile host (Peter develops on Tahoe). + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.88.0 + targets: aarch64-apple-darwin + - name: Show compilers + run: | + xcodebuild -version + swift --version + rustc --version + cargo --version + - name: Build host app and screensaver appex + working-directory: apps/Omacy + run: | + export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" + xcodebuild \ + -project Omacy.xcodeproj \ + -scheme Omacy \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + -skipMacroValidation \ + -skipPackagePluginValidation \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ + EXCLUDED_ARCHS=x86_64 \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + AD_HOC_CODE_SIGNING_ALLOWED=YES \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM="" \ + ENABLE_HARDENED_RUNTIME=NO \ + build diff --git a/.gitignore b/.gitignore index cf8de4a..6e37a84 100644 --- a/.gitignore +++ b/.gitignore @@ -57,9 +57,13 @@ Carthage/Build/ # https://docs.fastlane.tools/best-practices/source-control/#source-control fastlane/report.xml +fastlane/README.md fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output +fastlane/.env +*.p8 +build/ # Rust target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..226922e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,858 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[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 = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[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 = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[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 = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "omacy-engine" +version = "0.1.0" +dependencies = [ + "image", + "libc", + "resvg", + "serde", + "serde_json", + "tiny-skia", + "ttfx", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "ttfx" +version = "0.3.2" +dependencies = [ + "clap", + "clap_complete", + "terminal_size", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6fdfb24 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] +members = ["crates/omacy-engine"] +resolver = "2" + +[workspace.package] +edition = "2021" +license = "MIT" +repository = "https://github.com/dedene/omacy-screensaver" + +[profile.dev] +panic = "unwind" + +[profile.release] +panic = "unwind" +lto = true +codegen-units = 1 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..7a118b4 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..ed2be90 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,364 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1282.0) + aws-sdk-core (3.254.1) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.130.0) + aws-sdk-core (~> 3, >= 3.254.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.229.0) + aws-sdk-core (~> 3, >= 3.254.1) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.6) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + erb (6.0.7) + excon (1.7.0) + logger + faraday (2.14.3) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) + fastimage (2.4.1) + fastlane (2.238.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.9.0, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.197) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 2.0.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + irb (>= 1.8) + json (< 3.0.0) + jwt (>= 2.10.3, < 4) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) + naturally (~> 2.2) + nkf (~> 0.2) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 4) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.107.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (1.2.5) + addressable (~> 2.9) + faraday (~> 2.13) + faraday-follow_redirects (~> 0.3) + googleauth (~> 1.14) + mini_mime (~> 1.1) + multi_json (~> 1.11) + representable (~> 3.0) + retriable (>= 3.1, < 5.0) + google-apis-iamcredentials_v1 (0.28.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.18.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.66.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.7.0) + google-cloud-storage (1.62.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + google-logging-utils (0.2.0) + googleauth (1.17.4) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + os (>= 0.9, < 2.0) + pstore (~> 0.1) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + io-console (0.9.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jmespath (1.6.2) + json (2.21.2) + jwt (3.2.0) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.21.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + net-http (0.9.1) + uri (>= 0.11.1) + nkf (0.3.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + pstore (0.2.1) + public_suffix (7.0.5) + rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (4.2.0) + rexml (3.4.4) + rouge (3.28.0) + rubyzip (2.4.1) + security (0.1.5) + signet (0.22.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) + trailblazer-option (0.1.2) + tsort (0.2.0) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + word_wrap (1.0.0) + xcodeproj (1.28.1) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + base64 + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + nkf + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + fastlane + +CHECKSUMS + CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261 + abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 + atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f + aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b + aws-partitions (1.1282.0) sha256=d6c7c4ad2e4f8cd4ca56445bfbb3958fec0deecd959c1e2bcd3a83c9aa308c18 + aws-sdk-core (3.254.1) sha256=518089e32134c3478cd4ec63d07fb966546a45e9e4cbf5f80d2cf16d5699d29b + aws-sdk-kms (1.130.0) sha256=a2e83662ca31b77a2a19c9aa2f40a98165a67270c18c718fe1c70d0cbd7cd749 + aws-sdk-s3 (1.229.0) sha256=7dd11a111875b3505d2ec0a0a383a6aab6c1badb3d4e94e7fbb958a24451f596 + aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 + babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e + colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c + colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a + commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9 + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 + declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9 + digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07 + domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 + dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 + emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + excon (1.7.0) sha256=b8deec2bf978123b7e4b13a103ffec61557c6feaba34706dbc1ce4a94dec33e0 + faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278 + faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb + faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad + faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 + faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588 + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe + fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 + fastlane (2.238.0) sha256=78e9252df2224c7e427012638e41051edfa29b133a40fda883fd2f1c13a77b98 + fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 + gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 + google-apis-androidpublisher_v3 (0.107.0) sha256=b8207eecc89e6a9f7ea11aa88a07bccdcae5ca90121330079db4ef4696a2f3ff + google-apis-core (1.2.5) sha256=e21562b5627a0fc6a0c3165e429c3afed0f6a628eaa514e83f7204dda1adff69 + google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106 + google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254 + google-apis-storage_v1 (0.66.0) sha256=fdf6d65d3fc77e7046b3494333a818ece9e2afd763bb161e1a7a9e70cf198351 + google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4 + google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7 + google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3 + google-cloud-storage (1.62.0) sha256=e2c3c08bf8fd40d50be92304084942203314d4fc0ee52028e99f9359c3ad1330 + google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b + googleauth (1.17.4) sha256=acc2cac2a6011048f149c922cbc6c9675719e165aa5000c7fa3876c480fe9ae3 + highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 + http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 + io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080 + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 + naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 + nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 + optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a + os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace + retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 + rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 + security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 + signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780 + simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b + terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea + terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2 + trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 + tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 + tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 + uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 + xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc + xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 + xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93 + +BUNDLED WITH + 4.0.14 diff --git a/NOTICE b/NOTICE index f8e71c4..bffcf0d 100644 --- a/NOTICE +++ b/NOTICE @@ -13,3 +13,6 @@ LICENSE and NOTICE; those notices must be kept when vendoring ttfx. Omarchy’s default screensaver wordmark and the image-to-Braille conversion rules come from https://github.com/basecamp/omarchy, Copyright (c) 37signals / Omarchy contributors, MIT License. + +The bundled screensaver font is Fairfax HD, Copyright (c) 2017–2020 Kreative +Software, licensed under the SIL Open Font License 1.1. See assets/fonts/OFL.txt. diff --git a/README.md b/README.md index 00ca118..0084d17 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,48 @@ A macOS screensaver that plays Omarchy’s ASCII text-effects loop: your logo (o Rust `ttfx` engine, Swift/Metal view, Aerial-style host app + screensaver extension. -**Status:** architecture only (revision 7). Spec: [docs/architecture.md](docs/architecture.md), [docs/ffi.md](docs/ffi.md), [docs/parity.md](docs/parity.md). +**Status:** engine tested (`cargo test -p omacy-engine`). Host `.app` + `com.apple.screensaver` `.appex` compile on Xcode 26.6. Idle / System Settings listing remains a Mac gate: [docs/macos-gates.md](docs/macos-gates.md). + +Spec: [docs/architecture.md](docs/architecture.md), [docs/ffi.md](docs/ffi.md), [docs/parity.md](docs/parity.md). + +## Engine + +``` +cargo test -p omacy-engine +``` + +Pins: `vendor/ttfx/PIN` is ttfx v0.3.2 (`7203e354498462064b7c0a89375051f65cf2ce99`). Default art is `assets/branding/screensaver.txt`. + +## Mac host + +Open `apps/Omacy/Omacy.xcodeproj` on macOS 15+. The Xcode build phase runs `apps/Omacy/scripts/build-engine.sh`, which builds `libomacy_engine.a` for `aarch64-apple-darwin` and links it into the app and the appex. + +CI compiles that same scheme on `macos-26` (unsigned; deployment target 15.0). Idle / System Settings listing remains a machine gate: [docs/macos-gates.md](docs/macos-gates.md). + +Put `Omacy.app` in `/Applications`, then use the host to register the extension (`pluginkit`) and enable it (PaperSaver). The host will not register from a random path. + +Configuration (paste art, PNG/SVG conversion, effect, restore default) lives in the host and writes App Group `group.be.zenjoy.omacy`. + +## Signing + +Developer ID uses Zenjoy team `25TVW8MSGJ` and the same match repo as Zentty (`zentty-certificates`). Profiles: + +- `match Direct be.zenjoy.omacy macos` +- `match Direct be.zenjoy.omacy.screensaver macos` + +``` +bundle exec fastlane mac generate_signing # once, or to renew +bundle exec fastlane mac sync_signing # readonly install +bundle exec fastlane mac archive # Developer ID export +bundle exec fastlane mac notarize_app +``` + +Env: copy `fastlane/.env.example` or reuse Zentty’s 1Password item (`Zentty Release Environment`). ## Credits -The motion is [Terminal Text Effects](https://github.com/ChrisBuilds/terminaltexteffects) by ChrisBuilds, running on [ttfx](https://github.com/omacom-io/ttfx), the Rust port Omarchy ships. Both are MIT. The Linux wrapper this copies is [Omarchy](https://github.com/basecamp/omarchy), also MIT. +The motion is [Terminal Text Effects](https://github.com/ChrisBuilds/terminaltexteffects) by ChrisBuilds, running on [ttfx](https://github.com/omacom-io/ttfx), the Rust port Omarchy ships. Both are MIT. The Linux wrapper this copies is [Omarchy](https://github.com/basecamp/omarchy), also MIT. The bundled font is Fairfax HD by Kreative Software, SIL OFL. ## License -MIT. See [LICENSE](LICENSE) and [NOTICE](NOTICE). +MIT. See [LICENSE](LICENSE) and [NOTICE](NOTICE). Font: [assets/fonts/OFL.txt](assets/fonts/OFL.txt). diff --git a/apps/Omacy/.gitattributes b/apps/Omacy/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/apps/Omacy/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/apps/Omacy/.gitignore b/apps/Omacy/.gitignore new file mode 100644 index 0000000..d32ab36 --- /dev/null +++ b/apps/Omacy/.gitignore @@ -0,0 +1,6 @@ +/.build +.DS_Store +DerivedData/ +xcuserdata/ +*.xcuserstate +*.xcbkptlist diff --git a/apps/Omacy/BACKGROUND.md b/apps/Omacy/BACKGROUND.md new file mode 100644 index 0000000..7e0e632 --- /dev/null +++ b/apps/Omacy/BACKGROUND.md @@ -0,0 +1,284 @@ +# macOS Appex Screensaver — Technical Background + +This document captures what's known about developing screensavers for macOS Sonoma and later using the modern ExtensionKit (`.appex`) format. It is meant as a companion to the sample code in this repository. + +--- + +## 1. Architecture + +### `.appex` vs. legacy `.saver` + +Modern macOS (Sonoma+) supports screensavers packaged as ExtensionKit `.appex` bundles in addition to the legacy `.saver` plug-in format. Apple's own first-party savers (Hello, Arabesque, Drift, Monterey, Ventura, Shell, Flurry) all use the appex format. + +| | `.appex` | `.saver` | +|---|---|---| +| **Bundle type** | `CFBundlePackageType = XPC!` | `CFBundlePackageType = BNDL` | +| **Process model** | Separate sandboxed XPC process | Loaded into `legacyScreenSaver.appex` | +| **Framework** | `ScreenSaver.framework` + ExtensionKit | `ScreenSaver.framework` | +| **Min macOS** | 14.0 (Sonoma) | All supported macOS | +| **Distribution** | Embedded inside a host `.app`, registered via `pluginkit` | Standalone `.saver` file | + +### Extension Point + +- **Identifier:** `com.apple.screensaver` +- **Version:** `1.0` + +### Process Isolation + +Each appex screensaver runs in its own sandboxed process. The framework handles all inter-process communication via XPC; you don't write XPC code yourself. View rendering, input events, and lifecycle messages all flow through the framework transparently. + +--- + +## 2. Class Hierarchy + +### Public API (`ScreenSaver.framework`) + +| Class | Purpose | +|-------|---------| +| `ScreenSaverView` | Your main view. NSView subclass with `animateOneFrame()`, `startAnimation()`, `stopAnimation()`, `draw(_:)`. | +| `ScreenSaverDefaults` | `UserDefaults` subclass for screensaver preferences. | + +### ExtensionKit classes used by appex screensavers + +These are still partly underdocumented; their declarations live in `OmacyScreensaver/PrivateHeaders/ScreenSaverPrivate.h` in this repo. On recent SDKs the symbols are also available without the bridging header. + +| Class | Purpose | +|-------|---------| +| `ScreenSaverExtension` | Principal class. Set as `NSExtensionPrincipalClass` in Info.plist. | +| `ScreenSaverViewController` | View controller managing the screensaver view. Set as `ScreenSaverViewControllerClass` in Info.plist. | +| `ScreenSaverConfigurationViewController` | Base class for the optional configuration sheet. | + +--- + +## 3. Info.plist Configuration + +### `NSExtension` dictionary + +```xml +NSExtension + + NSExtensionPointIdentifier + com.apple.screensaver + NSExtensionPointVersion + 1.0 + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).YourScreenSaverExtension + +``` + +### Root-level screensaver keys + +| Key | Type | Description | +|-----|------|-------------| +| `ScreenSaverViewControllerClass` | String | Fully-qualified name of the main view controller. | +| `ScreenSaverConfigurationSheetViewControllerClass` | String | Fully-qualified name of the configuration sheet controller. | +| `SSEHasConfigureSheet` | Boolean | Whether the screensaver has a configuration UI. | +| `SSENeedsAnimationTimer` | Boolean | Whether the framework should drive a per-frame animation timer (see §5). | + +Use `$(PRODUCT_MODULE_NAME).ClassName` so the module prefix tracks your target name automatically. + +--- + +## 4. Apple's Built-in Screensavers (Reference) + +Located in `/System/Library/ExtensionKit/Extensions/`. + +| Name | Principal Class | View Controller | Has Config | +|------|-----------------|-----------------|------------| +| Arabesque | `Arabesque.ArabesqueExtension` | `ArabesqueViewController` | No | +| Flurry | `Flurry.FlurryExtension` | `FlurryViewController` | Yes | +| Drift | `Drift.FlowExtension` | `FlowViewController` | Yes | +| Hello | `Hello.HelloExtension` | `HelloViewController` | Yes | +| Monterey | `Monterey.CanyonExtension` | `CanyonViewController` | No | +| Ventura | `Ventura.PetalExtension` | `PetalViewController` | No | +| Shell | `Shell.ShellExtension` | `ShellViewController` | No | + +**Naming conventions** Apple uses: + +- Principal class: `Module.ModuleExtension` (e.g. `Arabesque.ArabesqueExtension`) +- View controller: `ModuleViewController` +- View class: `Module.ModuleView` + +--- + +## 5. Rendering: three valid approaches + +There is no single "right" way to render an appex screensaver. Pick whichever fits your animation model. + +### A. Direct CALayer animation (what this sample uses) + +Manipulate the backing CALayer directly — either with `CABasicAnimation` (Aerial's fallback and this sample's `OmacyCanaryAnimator`) or with your own `Timer` updating layer properties. Set `SSENeedsAnimationTimer = false` because the layer or your timer drives frames itself. + +```swift +class YourScreenSaverView: ScreenSaverView { + override init?(frame: NSRect, isPreview: Bool) { + super.init(frame: frame, isPreview: isPreview) + wantsLayer = true + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let layer = self.layer, window != nil else { return } + let animation = CABasicAnimation(keyPath: "backgroundColor") + animation.fromValue = NSColor.red.cgColor + animation.toValue = NSColor.blue.cgColor + animation.duration = 2.0 + animation.autoreverses = true + animation.repeatCount = .infinity + layer.add(animation, forKey: "demo") + } +} +``` + +### B. Traditional `ScreenSaverView` overrides + +Override `startAnimation()`, `stopAnimation()`, `animateOneFrame()`, and `draw(_:)`. These **do** get called by the framework when `SSENeedsAnimationTimer = true`; they're the same calls a `.saver` plug-in would receive. + +```swift +class YourScreenSaverView: ScreenSaverView { + override init?(frame: NSRect, isPreview: Bool) { + super.init(frame: frame, isPreview: isPreview) + animationTimeInterval = 1.0 / 30.0 + } + + override func animateOneFrame() { + super.animateOneFrame() + // Update model state, then trigger a redraw: + setNeedsDisplay(bounds) + } + + override func draw(_ rect: NSRect) { + NSColor.black.setFill() + bounds.fill() + // ...your drawing here... + } +} +``` + +### C. SwiftUI via `NSHostingView` + +You can host SwiftUI inside an appex screensaver by wrapping your SwiftUI root view in `NSHostingView` and adding it as a subview of the `ScreenSaverView`. The [Aerial](https://github.com/AerialScreensaver/Aerial) screensaver uses this pattern for weather, music, and clock overlays painted on top of its video player. + +```swift +import SwiftUI + +class YourScreenSaverView: ScreenSaverView { + private var hosting: NSHostingView? + + override init?(frame: NSRect, isPreview: Bool) { + super.init(frame: frame, isPreview: isPreview) + wantsLayer = true + + let host = NSHostingView(rootView: YourSwiftUIView()) + host.translatesAutoresizingMaskIntoConstraints = false + addSubview(host) + NSLayoutConstraint.activate([ + host.leadingAnchor.constraint(equalTo: leadingAnchor), + host.trailingAnchor.constraint(equalTo: trailingAnchor), + host.topAnchor.constraint(equalTo: topAnchor), + host.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + hosting = host + } +} +``` + +Note that SwiftUI is hosted *inside* the `ScreenSaverView`; it is not the principal class. The principal class must still be a `ScreenSaverExtension` subclass. + +### View controller setup + +The view controller overrides `loadView()` to instantiate and assign the screensaver view: + +```swift +@objc(YourViewController) +class YourViewController: ScreenSaverViewController { + override func loadView() { + let frame = NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1920, height: 1080) + let isPreview = frame.width < 400 + self.view = YourScreenSaverView(frame: frame, isPreview: isPreview) + ?? NSView(frame: frame) + } +} +``` + +--- + +## 6. Registration and Discovery + +### Using `pluginkit` + +```bash +# Register an appex +pluginkit -a /path/to/YourScreensaver.app/Contents/PlugIns/YourScreensaver.appex + +# List all registered screensavers +pluginkit -m -v -p com.apple.screensaver + +# Unregister +pluginkit -r /path/to/YourScreensaver.appex +``` + +In production the appex must be embedded inside an application bundle: + +``` +YourApp.app/Contents/PlugIns/YourScreensaver.appex +``` + +You typically ship the host app to `/Applications/`, and macOS picks the appex up automatically (or your host app calls `pluginkit -a` on first launch, as this sample does in `PluginManager.swift`). + +--- + +## 7. Thumbnails + +### Required dimensions + +System Settings displays screensaver thumbnails at a specific landscape aspect ratio. Square images won't appear. + +| Scale | Dimensions | +|-------|------------| +| 1x | 107 × 65 | +| 2x | 214 × 130 | + +### Asset catalog setup + +Place thumbnails in an asset catalog imageset named `thumbnail`: + +``` +Assets.xcassets/ +└── thumbnail.imageset/ + ├── Contents.json + ├── thumbnail.png (107 × 65) + └── thumbnail@2x.png (214 × 130) +``` + +`Contents.json`: + +```json +{ + "images": [ + { "filename": "thumbnail.png", "idiom": "universal", "scale": "1x" }, + { "filename": "thumbnail@2x.png", "idiom": "universal", "scale": "2x" } + ], + "info": { "author": "xcode", "version": 1 } +} +``` + +Some Apple screensavers ship both explicit PNGs in `Resources/` and entries inside the compiled `Assets.car`; the explicit PNGs appear to take priority when present. + +### Caching + +macOS caches screensaver thumbnails aggressively. If yours doesn't appear after changes: + +1. Rebuild and re-register: `pluginkit -a /path/to/Extension.appex` +2. Close and reopen System Settings +3. Log out and back in if the cached version persists + +--- + +## References + +- `ScreenSaver.framework` headers in the macOS SDK +- Apple's built-in screensaver extensions at `/System/Library/ExtensionKit/Extensions/` +- ExtensionKit documentation in the Xcode SDK +- [Aerial](https://github.com/AerialScreensaver/Aerial) — full appex screensaver using approaches A and C together +- [ScreenSaverMinimal](https://github.com/AerialScreensaver/ScreenSaverMinimal) — same author's sample for the legacy `.saver` format diff --git a/apps/Omacy/BuildNumber.xcconfig b/apps/Omacy/BuildNumber.xcconfig new file mode 100644 index 0000000..32736bb --- /dev/null +++ b/apps/Omacy/BuildNumber.xcconfig @@ -0,0 +1 @@ +CURRENT_PROJECT_VERSION = 260827.2045 diff --git a/apps/Omacy/LICENSE b/apps/Omacy/LICENSE new file mode 100644 index 0000000..1a6259a --- /dev/null +++ b/apps/Omacy/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Guillaume Louel + +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/apps/Omacy/Omacy.xcodeproj/project.pbxproj b/apps/Omacy/Omacy.xcodeproj/project.pbxproj new file mode 100644 index 0000000..feb4790 --- /dev/null +++ b/apps/Omacy/Omacy.xcodeproj/project.pbxproj @@ -0,0 +1,825 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 1A000001000000000000000A /* OmacyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000001000000000000000B /* OmacyApp.swift */; }; + 1A000002000000000000000A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000002000000000000000B /* ContentView.swift */; }; + 1A000003000000000000000A /* OmacyScreensaver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000003000000000000000B /* OmacyScreensaver.swift */; }; + 1A000004000000000000000A /* OmacySaverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000004000000000000000B /* OmacySaverViewController.swift */; }; + 1A000005000000000000000A /* OmacySaverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000005000000000000000B /* OmacySaverView.swift */; }; + 1A000006000000000000000A /* ScreenSaver.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A000006000000000000000B /* ScreenSaver.framework */; }; + 1A000007000000000000000A /* OmacyScreensaver.appex in Embed PlugIns */ = {isa = PBXBuildFile; fileRef = 1A000007000000000000000B /* OmacyScreensaver.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 1A000030000000000000000A /* OmacyConfigurationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000030000000000000000B /* OmacyConfigurationViewController.swift */; }; + 1A000031000000000000000A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A000031000000000000000B /* Assets.xcassets */; }; + 1A000032000000000000000A /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000032000000000000000B /* PluginManager.swift */; }; + 1A000060000000000000000A /* OmacyCanaryAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000060000000000000000B /* OmacyCanaryAnimator.swift */; }; + 1A000061000000000000000A /* OmacyCanaryAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000060000000000000000B /* OmacyCanaryAnimator.swift */; }; + 1A000062000000000000000A /* PreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000062000000000000000B /* PreviewView.swift */; }; + 1A000063000000000000000A /* PreviewViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000063000000000000000B /* PreviewViewRepresentable.swift */; }; + 1A000066000000000000000A /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000064000000000000000B /* Logger.swift */; }; + 1A000067000000000000000A /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000064000000000000000B /* Logger.swift */; }; + 1A000070000000000000000A /* PaperSaverKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1A000070000000000000000B /* PaperSaverKit */; }; + 1A0000A0000000000000000A /* OmacyRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000090000000000000000B /* OmacyRenderer.swift */; }; + 1A0000A1000000000000000A /* OmacyRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000090000000000000000B /* OmacyRenderer.swift */; }; + 1A0000A2000000000000000A /* OmacyLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000091000000000000000B /* OmacyLayout.swift */; }; + 1A0000A3000000000000000A /* OmacyLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000091000000000000000B /* OmacyLayout.swift */; }; + 1A0000A4000000000000000A /* OmacyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000092000000000000000B /* OmacyStore.swift */; }; + 1A0000A5000000000000000A /* OmacyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000092000000000000000B /* OmacyStore.swift */; }; + 1A0000A6000000000000000A /* OmacyAtlas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000093000000000000000B /* OmacyAtlas.swift */; }; + 1A0000A7000000000000000A /* OmacyAtlas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000093000000000000000B /* OmacyAtlas.swift */; }; + 1A0000A8000000000000000A /* OmacyShaders.metal in Sources */ = {isa = PBXBuildFile; fileRef = 1A000094000000000000000B /* OmacyShaders.metal */; }; + 1A0000A9000000000000000A /* OmacyShaders.metal in Sources */ = {isa = PBXBuildFile; fileRef = 1A000094000000000000000B /* OmacyShaders.metal */; }; + 1A0000AA000000000000000A /* OmacyHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000095000000000000000B /* OmacyHostView.swift */; }; + 1A0000AB000000000000000A /* ConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000096000000000000000B /* ConfigView.swift */; }; + 1A0000AC000000000000000A /* FairfaxHD.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 1A000097000000000000000B /* FairfaxHD.ttf */; }; + 1A0000AD000000000000000A /* FairfaxHD.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 1A000097000000000000000B /* FairfaxHD.ttf */; }; + 1A0000AE000000000000000A /* screensaver.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1A000098000000000000000B /* screensaver.txt */; }; + 1A0000AF000000000000000A /* screensaver.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1A000098000000000000000B /* screensaver.txt */; }; + 1A0000B0000000000000000A /* OFL.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1A000099000000000000000B /* OFL.txt */; }; + 1A0000B1000000000000000A /* OFL.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1A000099000000000000000B /* OFL.txt */; }; + 1A0000B2000000000000000A /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A00009A000000000000000B /* Metal.framework */; }; + 1A0000B3000000000000000A /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A00009A000000000000000B /* Metal.framework */; }; + 1A0000B4000000000000000A /* libomacy_engine.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A00009B000000000000000B /* libomacy_engine.a */; }; + 1A0000B5000000000000000A /* libomacy_engine.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A00009B000000000000000B /* libomacy_engine.a */; }; + 1A0000B6000000000000000A /* OmacyCanaryAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A00009D000000000000000B /* OmacyCanaryAnimator.swift */; }; + 1A0000B7000000000000000A /* OmacyCanaryAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A00009D000000000000000B /* OmacyCanaryAnimator.swift */; }; + 1A0000E0000000000000000A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A0000E0000000000000000B /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 1A000008000000000000000A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 1A000000000000000000000A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1A000010000000000000000A; + remoteInfo = OmacyScreensaver; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 1A000009000000000000000A /* Embed PlugIns */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 1A000007000000000000000A /* OmacyScreensaver.appex in Embed PlugIns */, + ); + name = "Embed PlugIns"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1A000001000000000000000B /* OmacyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyApp.swift; sourceTree = ""; }; + 1A000002000000000000000B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 1A000003000000000000000B /* OmacyScreensaver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyScreensaver.swift; sourceTree = ""; }; + 1A000004000000000000000B /* OmacySaverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacySaverViewController.swift; sourceTree = ""; }; + 1A000005000000000000000B /* OmacySaverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacySaverView.swift; sourceTree = ""; }; + 1A000006000000000000000B /* ScreenSaver.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ScreenSaver.framework; path = System/Library/Frameworks/ScreenSaver.framework; sourceTree = SDKROOT; }; + 1A000007000000000000000B /* OmacyScreensaver.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OmacyScreensaver.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 1A00000A000000000000000B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 1A00000B000000000000000B /* Omacy.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Omacy.entitlements; sourceTree = ""; }; + 1A00000C000000000000000B /* Omacy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Omacy.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1A00000D000000000000000B /* ScreenSaverPrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ScreenSaverPrivate.h; sourceTree = ""; }; + 1A00000E000000000000000B /* OmacyScreensaver-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OmacyScreensaver-Bridging-Header.h"; sourceTree = ""; }; + 1A00000F000000000000000B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 1A000011000000000000000B /* OmacyScreensaver.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OmacyScreensaver.entitlements; sourceTree = ""; }; + 1A000030000000000000000B /* OmacyConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyConfigurationViewController.swift; sourceTree = ""; }; + 1A000031000000000000000B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 1A000032000000000000000B /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; }; + 1A000050000000000000000B /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = ""; }; + 1A000060000000000000000B /* OmacyCanaryAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyCanaryAnimator.swift; sourceTree = ""; }; + 1A000062000000000000000B /* PreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewView.swift; sourceTree = ""; }; + 1A000063000000000000000B /* PreviewViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewViewRepresentable.swift; sourceTree = ""; }; + 1A000064000000000000000B /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 1A000090000000000000000B /* OmacyRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyRenderer.swift; sourceTree = ""; }; + 1A000091000000000000000B /* OmacyLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyLayout.swift; sourceTree = ""; }; + 1A000092000000000000000B /* OmacyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyStore.swift; sourceTree = ""; }; + 1A000093000000000000000B /* OmacyAtlas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyAtlas.swift; sourceTree = ""; }; + 1A000094000000000000000B /* OmacyShaders.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = OmacyShaders.metal; sourceTree = ""; }; + 1A000095000000000000000B /* OmacyHostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyHostView.swift; sourceTree = ""; }; + 1A000096000000000000000B /* ConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigView.swift; sourceTree = ""; }; + 1A000097000000000000000B /* FairfaxHD.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = FairfaxHD.ttf; path = ../../assets/fonts/FairfaxHD.ttf; sourceTree = SOURCE_ROOT; }; + 1A000098000000000000000B /* screensaver.txt */ = {isa = PBXFileReference; lastKnownFileType = text; name = screensaver.txt; path = ../../assets/branding/screensaver.txt; sourceTree = SOURCE_ROOT; }; + 1A000099000000000000000B /* OFL.txt */ = {isa = PBXFileReference; lastKnownFileType = text; name = OFL.txt; path = ../../assets/fonts/OFL.txt; sourceTree = SOURCE_ROOT; }; + 1A00009A000000000000000B /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; + 1A00009B000000000000000B /* libomacy_engine.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libomacy_engine.a; sourceTree = BUILT_PRODUCTS_DIR; }; + 1A00009C000000000000000B /* Omacy-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Omacy-Bridging-Header.h"; sourceTree = ""; }; + 1A00009D000000000000000B /* OmacyCanaryAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyCanaryAnimator.swift; sourceTree = ""; }; + 1A0000E0000000000000000B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1A000012000000000000000A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A000070000000000000000A /* PaperSaverKit in Frameworks */, + 1A0000B2000000000000000A /* Metal.framework in Frameworks */, + 1A0000B4000000000000000A /* libomacy_engine.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1A000013000000000000000A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A000006000000000000000A /* ScreenSaver.framework in Frameworks */, + 1A0000B3000000000000000A /* Metal.framework in Frameworks */, + 1A0000B5000000000000000A /* libomacy_engine.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1A000014000000000000000C = { + isa = PBXGroup; + children = ( + 1A000050000000000000000B /* BuildNumber.xcconfig */, + 1A000015000000000000000C /* Omacy */, + 1A000016000000000000000C /* OmacyScreensaver */, + 1A0000C0000000000000000C /* Shared */, + 1A000017000000000000000C /* Frameworks */, + 1A000018000000000000000C /* Products */, + ); + sourceTree = ""; + }; + 1A000015000000000000000C /* Omacy */ = { + isa = PBXGroup; + children = ( + 1A000065000000000000000C /* Helpers */, + 1A000001000000000000000B /* OmacyApp.swift */, + 1A000002000000000000000B /* ContentView.swift */, + 1A000032000000000000000B /* PluginManager.swift */, + 1A000095000000000000000B /* OmacyHostView.swift */, + 1A000096000000000000000B /* ConfigView.swift */, + 1A000062000000000000000B /* PreviewView.swift */, + 1A000063000000000000000B /* PreviewViewRepresentable.swift */, + 1A00009C000000000000000B /* Omacy-Bridging-Header.h */, + 1A0000E0000000000000000B /* Assets.xcassets */, + 1A00000A000000000000000B /* Info.plist */, + 1A00000B000000000000000B /* Omacy.entitlements */, + ); + path = Omacy; + sourceTree = ""; + }; + 1A000065000000000000000C /* Helpers */ = { + isa = PBXGroup; + children = ( + 1A000064000000000000000B /* Logger.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 1A000016000000000000000C /* OmacyScreensaver */ = { + isa = PBXGroup; + children = ( + 1A000019000000000000000C /* PrivateHeaders */, + 1A000003000000000000000B /* OmacyScreensaver.swift */, + 1A000004000000000000000B /* OmacySaverViewController.swift */, + 1A000005000000000000000B /* OmacySaverView.swift */, + 1A000030000000000000000B /* OmacyConfigurationViewController.swift */, + 1A000031000000000000000B /* Assets.xcassets */, + 1A00000E000000000000000B /* OmacyScreensaver-Bridging-Header.h */, + 1A00000F000000000000000B /* Info.plist */, + 1A000011000000000000000B /* OmacyScreensaver.entitlements */, + ); + path = OmacyScreensaver; + sourceTree = ""; + }; + 1A0000C0000000000000000C /* Shared */ = { + isa = PBXGroup; + children = ( + 1A00009D000000000000000B /* OmacyCanaryAnimator.swift */, + 1A000090000000000000000B /* OmacyRenderer.swift */, + 1A000091000000000000000B /* OmacyLayout.swift */, + 1A000092000000000000000B /* OmacyStore.swift */, + 1A000093000000000000000B /* OmacyAtlas.swift */, + 1A000094000000000000000B /* OmacyShaders.metal */, + 1A000097000000000000000B /* FairfaxHD.ttf */, + 1A000098000000000000000B /* screensaver.txt */, + 1A000099000000000000000B /* OFL.txt */, + ); + path = Shared; + sourceTree = ""; + }; + 1A000017000000000000000C /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1A000006000000000000000B /* ScreenSaver.framework */, + 1A00009A000000000000000B /* Metal.framework */, + 1A00009B000000000000000B /* libomacy_engine.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 1A000018000000000000000C /* Products */ = { + isa = PBXGroup; + children = ( + 1A00000C000000000000000B /* Omacy.app */, + 1A000007000000000000000B /* OmacyScreensaver.appex */, + ); + name = Products; + sourceTree = ""; + }; + 1A000019000000000000000C /* PrivateHeaders */ = { + isa = PBXGroup; + children = ( + 1A00000D000000000000000B /* ScreenSaverPrivate.h */, + ); + path = PrivateHeaders; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1A000010000000000000000A /* OmacyScreensaver */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1A00001F000000000000000A /* Build configuration list for PBXNativeTarget "OmacyScreensaver" */; + buildPhases = ( + 1A0000D1000000000000000A /* Build Rust engine */, + 1A000020000000000000000A /* Sources */, + 1A000013000000000000000A /* Frameworks */, + 1A000021000000000000000A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OmacyScreensaver; + productName = OmacyScreensaver; + productReference = 1A000007000000000000000B /* OmacyScreensaver.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 1A00001A000000000000000A /* Omacy */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1A00001B000000000000000A /* Build configuration list for PBXNativeTarget "Omacy" */; + buildPhases = ( + 1A00001C000000000000000A /* Sources */, + 1A000012000000000000000A /* Frameworks */, + 1A00001D000000000000000A /* Resources */, + 1A000009000000000000000A /* Embed PlugIns */, + 1A000042000000000000000A /* Register Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 1A00001E000000000000000A /* PBXTargetDependency */, + ); + name = Omacy; + packageProductDependencies = ( + 1A000070000000000000000B /* PaperSaverKit */, + ); + productName = Omacy; + productReference = 1A00000C000000000000000B /* Omacy.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1A000000000000000000000A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + 1A000010000000000000000A = { + CreatedOnToolsVersion = 15.0; + }; + 1A00001A000000000000000A = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = 1A000022000000000000000A /* Build configuration list for PBXProject "Omacy" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 1A000014000000000000000C; + packageReferences = ( + 1A000071000000000000000A /* XCRemoteSwiftPackageReference "PaperSaver" */, + ); + productRefGroup = 1A000018000000000000000C /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1A00001A000000000000000A /* Omacy */, + 1A000010000000000000000A /* OmacyScreensaver */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1A00001D000000000000000A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A0000AC000000000000000A /* FairfaxHD.ttf in Resources */, + 1A0000AE000000000000000A /* screensaver.txt in Resources */, + 1A0000B0000000000000000A /* OFL.txt in Resources */, + 1A0000E0000000000000000A /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1A000021000000000000000A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A000031000000000000000A /* Assets.xcassets in Resources */, + 1A0000AD000000000000000A /* FairfaxHD.ttf in Resources */, + 1A0000AF000000000000000A /* screensaver.txt in Resources */, + 1A0000B1000000000000000A /* OFL.txt in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1A0000D1000000000000000A /* Build Rust engine */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Rust engine"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(BUILT_PRODUCTS_DIR)/libomacy_engine.a", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "sh \"${SRCROOT}/scripts/build-engine.sh\"\n"; + }; + 1A000042000000000000000A /* Register Extensions */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Register Extensions"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ -n \"$CI\" ]; then echo \"skip pluginkit on CI\"; exit 0; fi\nfind \"$CODESIGNING_FOLDER_PATH\" -name '*.appex' -exec pluginkit -a {} \\; || true\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1A00001C000000000000000A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A000001000000000000000A /* OmacyApp.swift in Sources */, + 1A000002000000000000000A /* ContentView.swift in Sources */, + 1A000032000000000000000A /* PluginManager.swift in Sources */, + 1A000066000000000000000A /* Logger.swift in Sources */, + 1A0000A0000000000000000A /* OmacyRenderer.swift in Sources */, + 1A0000A2000000000000000A /* OmacyLayout.swift in Sources */, + 1A0000A4000000000000000A /* OmacyStore.swift in Sources */, + 1A0000A6000000000000000A /* OmacyAtlas.swift in Sources */, + 1A0000A8000000000000000A /* OmacyShaders.metal in Sources */, + 1A0000AA000000000000000A /* OmacyHostView.swift in Sources */, + 1A0000AB000000000000000A /* ConfigView.swift in Sources */, + 1A000062000000000000000A /* PreviewView.swift in Sources */, + 1A000063000000000000000A /* PreviewViewRepresentable.swift in Sources */, + 1A0000B6000000000000000A /* OmacyCanaryAnimator.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1A000020000000000000000A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A000003000000000000000A /* OmacyScreensaver.swift in Sources */, + 1A000004000000000000000A /* OmacySaverViewController.swift in Sources */, + 1A000005000000000000000A /* OmacySaverView.swift in Sources */, + 1A000030000000000000000A /* OmacyConfigurationViewController.swift in Sources */, + 1A000067000000000000000A /* Logger.swift in Sources */, + 1A0000B7000000000000000A /* OmacyCanaryAnimator.swift in Sources */, + 1A0000A1000000000000000A /* OmacyRenderer.swift in Sources */, + 1A0000A3000000000000000A /* OmacyLayout.swift in Sources */, + 1A0000A5000000000000000A /* OmacyStore.swift in Sources */, + 1A0000A7000000000000000A /* OmacyAtlas.swift in Sources */, + 1A0000A9000000000000000A /* OmacyShaders.metal in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 1A00001E000000000000000A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 1A000010000000000000000A /* OmacyScreensaver */; + targetProxy = 1A000008000000000000000A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 1A000023000000000000000A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1A000050000000000000000B /* BuildNumber.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 1A000024000000000000000A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1A000050000000000000000B /* BuildNumber.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 1A000025000000000000000A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Omacy/Omacy.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 25TVW8MSGJ; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Omacy/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = be.zenjoy.omacy; + PRODUCT_NAME = "$(TARGET_NAME)"; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/OmacyScreensaver", + "$(SRCROOT)/../../crates/omacy-engine/include", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + "-framework", + Security, + "-framework", + CoreFoundation, + "-framework", + SystemConfiguration, + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Omacy/Omacy-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 1A000026000000000000000A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Omacy/Omacy.entitlements; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 25TVW8MSGJ; + ENABLE_HARDENED_RUNTIME = YES; + PROVISIONING_PROFILE_SPECIFIER = "match Direct be.zenjoy.omacy macos"; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Omacy/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = be.zenjoy.omacy; + PRODUCT_NAME = "$(TARGET_NAME)"; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/OmacyScreensaver", + "$(SRCROOT)/../../crates/omacy-engine/include", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + "-framework", + Security, + "-framework", + CoreFoundation, + "-framework", + SystemConfiguration, + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Omacy/Omacy-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 1A000027000000000000000A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OmacyScreensaver/OmacyScreensaver.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 25TVW8MSGJ; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OmacyScreensaver/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Omacy; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = be.zenjoy.omacy.screensaver; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/OmacyScreensaver", + "$(SRCROOT)/../../crates/omacy-engine/include", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + "-framework", + Security, + "-framework", + CoreFoundation, + "-framework", + SystemConfiguration, + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "OmacyScreensaver/OmacyScreensaver-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 1A000028000000000000000A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OmacyScreensaver/OmacyScreensaver.entitlements; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = 25TVW8MSGJ; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + PROVISIONING_PROFILE_SPECIFIER = "match Direct be.zenjoy.omacy.screensaver macos"; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OmacyScreensaver/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Omacy; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = be.zenjoy.omacy.screensaver; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/OmacyScreensaver", + "$(SRCROOT)/../../crates/omacy-engine/include", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + "-framework", + Security, + "-framework", + CoreFoundation, + "-framework", + SystemConfiguration, + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "OmacyScreensaver/OmacyScreensaver-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1A00001B000000000000000A /* Build configuration list for PBXNativeTarget "Omacy" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A000025000000000000000A /* Debug */, + 1A000026000000000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1A00001F000000000000000A /* Build configuration list for PBXNativeTarget "OmacyScreensaver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A000027000000000000000A /* Debug */, + 1A000028000000000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1A000022000000000000000A /* Build configuration list for PBXProject "Omacy" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A000023000000000000000A /* Debug */, + 1A000024000000000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 1A000071000000000000000A /* XCRemoteSwiftPackageReference "PaperSaver" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/AerialScreensaver/PaperSaver.git"; + requirement = { + kind = exactVersion; + version = 0.2.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 1A000070000000000000000B /* PaperSaverKit */ = { + isa = XCSwiftPackageProductDependency; + package = 1A000071000000000000000A /* XCRemoteSwiftPackageReference "PaperSaver" */; + productName = PaperSaverKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 1A000000000000000000000A /* Project object */; +} diff --git a/apps/Omacy/Omacy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/Omacy/Omacy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..86faf97 --- /dev/null +++ b/apps/Omacy/Omacy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "58d483173683d9748f852a167c8263542e3ef789aabcff2c1cc4cc0b5dad32d0", + "pins" : [ + { + "identity" : "papersaver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/AerialScreensaver/PaperSaver.git", + "state" : { + "revision" : "1f10d1537fdfa2df8165b46572a9de491ce2d084", + "version" : "0.2.0" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + } + ], + "version" : 3 +} diff --git a/apps/Omacy/Omacy.xcodeproj/xcshareddata/xcschemes/Omacy.xcscheme b/apps/Omacy/Omacy.xcodeproj/xcshareddata/xcschemes/Omacy.xcscheme new file mode 100644 index 0000000..0fc50e2 --- /dev/null +++ b/apps/Omacy/Omacy.xcodeproj/xcshareddata/xcschemes/Omacy.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/Omacy/Omacy.xcodeproj/xcshareddata/xcschemes/OmacyScreensaver.xcscheme b/apps/Omacy/Omacy.xcodeproj/xcshareddata/xcschemes/OmacyScreensaver.xcscheme new file mode 100644 index 0000000..18f4515 --- /dev/null +++ b/apps/Omacy/Omacy.xcodeproj/xcshareddata/xcschemes/OmacyScreensaver.xcscheme @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/Omacy/Omacy/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/Omacy/Omacy/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..2b75839 --- /dev/null +++ b/apps/Omacy/Omacy/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.949", + "green" : "0.451", + "red" : "0.149" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..e8aaf0c --- /dev/null +++ b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { "idiom" : "mac", "size" : "16x16", "scale" : "1x", "filename" : "icon_16.png" }, + { "idiom" : "mac", "size" : "16x16", "scale" : "2x", "filename" : "icon_32.png" }, + { "idiom" : "mac", "size" : "32x32", "scale" : "1x", "filename" : "icon_32.png" }, + { "idiom" : "mac", "size" : "32x32", "scale" : "2x", "filename" : "icon_64.png" }, + { "idiom" : "mac", "size" : "128x128", "scale" : "1x", "filename" : "icon_128.png" }, + { "idiom" : "mac", "size" : "128x128", "scale" : "2x", "filename" : "icon_256.png" }, + { "idiom" : "mac", "size" : "256x256", "scale" : "1x", "filename" : "icon_256.png" }, + { "idiom" : "mac", "size" : "256x256", "scale" : "2x", "filename" : "icon_512.png" }, + { "idiom" : "mac", "size" : "512x512", "scale" : "1x", "filename" : "icon_512.png" }, + { "idiom" : "mac", "size" : "512x512", "scale" : "2x", "filename" : "icon_1024.png" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..94ee268 Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_128.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_128.png new file mode 100644 index 0000000..ba2ad9c Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_16.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_16.png new file mode 100644 index 0000000..65cbaee Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_256.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_256.png new file mode 100644 index 0000000..44e1d5d Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_32.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_32.png new file mode 100644 index 0000000..fea4966 Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_512.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_512.png new file mode 100644 index 0000000..ddb4716 Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_64.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_64.png new file mode 100644 index 0000000..d9f115b Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_64.png differ diff --git a/apps/Omacy/Omacy/Assets.xcassets/Contents.json b/apps/Omacy/Omacy/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/apps/Omacy/Omacy/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/Omacy/Omacy/ConfigView.swift b/apps/Omacy/Omacy/ConfigView.swift new file mode 100644 index 0000000..66b0589 --- /dev/null +++ b/apps/Omacy/Omacy/ConfigView.swift @@ -0,0 +1,130 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct ConfigView: View { + @State private var settings = OmacyStore.loadSettings() + @State private var art = OmacyStore.loadArt() + @State private var status = "Ready" + @State private var importer = false + + var body: some View { + Form { + Section("Art") { + TextEditor(text: $art) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 160) + HStack { + Button("Restore default wordmark") { + art = OmacyStore.bundledArt + persist() + } + Button("Open PNG or SVG…") { importer = true } + } + } + Section("Effect") { + TextField("random or ttfx name", text: $settings.effect) + ColorPicker( + "Background", + selection: Binding( + get: { color(from: settings.background) }, + set: { settings.background = hex(from: $0) } + ), + supportsOpacity: false + ) + } + Section("Conversion") { + Picker("Mode", selection: $settings.asciiMode) { + Text("Braille").tag("braille") + Text("Block").tag("block") + } + Stepper("Threshold \(settings.threshold)%", value: $settings.threshold, in: 0...100) + Toggle("Invert", isOn: $settings.invert) + } + Section("Type") { + Stepper("Font size \(Int(settings.fontSize)) pt", value: $settings.fontSize, in: 8...48) + } + Section("About") { + Text("Effects by Terminal Text Effects (ChrisBuilds), Rust engine ttfx.") + .font(.caption) + .foregroundStyle(.secondary) + } + if let error = OmacyStore.lastLoadError { + Text(error).font(.caption).foregroundStyle(.red) + } + Text(status).font(.caption).foregroundStyle(.secondary) + Button("Save") { persist() } + .keyboardShortcut(.defaultAction) + } + .padding() + .frame(minWidth: 520, minHeight: 560) + .fileImporter(isPresented: $importer, allowedContentTypes: [.png, UTType(filenameExtension: "svg") ?? .xml]) { result in + switch result { + case .success(let url): + convert(url) + case .failure(let error): + status = error.localizedDescription + } + } + } + + private func persist() { + do { + try OmacyStore.save(settings: settings, art: art) + status = "Saved to App Group" + } catch { + status = error.localizedDescription + } + } + + private func convert(_ url: URL) { + guard url.startAccessingSecurityScopedResource() else { + status = "Could not read file" + return + } + defer { url.stopAccessingSecurityScopedResource() } + guard let data = try? Data(contentsOf: url) else { + status = "Could not read file" + return + } + var cfg = OmacyAsciiConfig() + cfg.mode = settings.asciiModeCode + cfg.width = 80 + cfg.height = 26 + cfg.threshold = UInt8(settings.threshold) + cfg.invert = settings.invert ? 1 : 0 + cfg.trim = 1 + var text: OpaquePointer? + let statusCode = data.withUnsafeBytes { raw in + omacy_ascii_from_bytes(&cfg, raw.bindMemory(to: UInt8.self).baseAddress, data.count, &text) + } + defer { omacy_text_free(text) } + guard statusCode == OMACY_OK, let text, let ptr = omacy_text_utf8(text) else { + status = "Conversion failed" + return + } + let n = omacy_text_len(text) + art = String(bytes: UnsafeBufferPointer(start: ptr, count: n).map { UInt8(bitPattern: $0) }, encoding: .utf8) ?? art + persist() + } + + private func color(from hex: String) -> Color { + let t = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard t.count == 6, let n = UInt32(t, radix: 16) else { return .black } + return Color( + red: Double((n >> 16) & 0xFF) / 255, + green: Double((n >> 8) & 0xFF) / 255, + blue: Double(n & 0xFF) / 255 + ) + } + + private func hex(from color: Color) -> String { + let ns = NSColor(color) + guard let rgb = ns.usingColorSpace(.sRGB) else { return "#000000" } + return String( + format: "#%02X%02X%02X", + Int(rgb.redComponent * 255), + Int(rgb.greenComponent * 255), + Int(rgb.blueComponent * 255) + ) + } +} diff --git a/apps/Omacy/Omacy/ContentView.swift b/apps/Omacy/Omacy/ContentView.swift new file mode 100644 index 0000000..8227970 --- /dev/null +++ b/apps/Omacy/Omacy/ContentView.swift @@ -0,0 +1,292 @@ +// +// ContentView.swift +// Omacy +// +// Copyright © 2026 Guillaume Louel. Licensed under the MIT License. +// +// Main view for the host application. Shows extension registration status +// and provides install / uninstall and "set as active screensaver" actions. +// + +import SwiftUI + +private let logger = AppexLog.logger("HostApp") + +struct ContentView: View { + @Environment(\.openWindow) private var openWindow + @StateObject private var pluginManager = PluginManager() + @State private var statusMessage = "Ready" + + var body: some View { + VStack(spacing: 20) { + // MARK: - Header + Image(systemName: "tv") + .font(.system(size: 60)) + .foregroundColor(.accentColor) + + Text("Omacy") + .font(.largeTitle) + .fontWeight(.bold) + + Text("Screensaver Extension") + .font(.title2) + .foregroundColor(.secondary) + + Divider() + .padding(.horizontal, 40) + + // MARK: - Extension Status + Text("Extension Status") + .font(.headline) + + extensionStatusView + .padding(.horizontal, 20) + + Divider() + .padding(.horizontal, 40) + + // MARK: - Screensaver Activation + Text("Screensaver Activation") + .font(.headline) + + screensaverActivationView + .padding(.horizontal, 20) + + Divider() + .padding(.horizontal, 40) + + // MARK: - Actions + HStack(spacing: 12) { + Button("Open Preview") { + openWindow(id: "preview") + } + .buttonStyle(.borderedProminent) + + Button("Edit art") { + openWindow(id: "config") + } + .buttonStyle(.bordered) + + Button("Open Screen Saver Settings") { + openScreenSaverSettings() + } + .buttonStyle(.bordered) + } + + Text(statusMessage) + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 10) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + + Text("Effects by Terminal Text Effects (ChrisBuilds), Rust engine ttfx.") + .font(.caption2) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(40) + .fixedSize() + } + + @ViewBuilder + private var extensionStatusView: some View { + GroupBox { + VStack(alignment: .leading, spacing: 12) { + HStack { + Circle() + .fill(pluginManager.isInstalled ? Color.green : Color.gray) + .frame(width: 10, height: 10) + + if pluginManager.isInstalled { + Text("Installed") + .fontWeight(.medium) + if let version = pluginManager.installedVersion { + Text("(v\(version))") + .foregroundColor(.secondary) + } + } else { + Text("Not registered") + .fontWeight(.medium) + .foregroundColor(.secondary) + } + + Spacer() + + if pluginManager.isLoading { + ProgressView() + .scaleEffect(0.7) + } else { + Button { + pluginManager.checkInstallationStatus() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Refresh status") + } + } + + if pluginManager.isInstalled { + if pluginManager.isPluginMissing { + Text("The registered screensaver extension is missing. Re-register it from this app.") + .font(.caption) + .foregroundColor(.orange) + } + if let path = pluginManager.installedPath { + HStack(alignment: .top) { + Text("Path:") + .foregroundColor(.secondary) + Text(path) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + .font(.caption) + } + if pluginManager.hasConflictingRegistrations { + Text("Registered in more than one place. Unregister extras so only /Applications or DerivedData remains.") + .font(.caption) + .foregroundColor(.orange) + ForEach(pluginManager.registeredPaths, id: \.self) { path in + Text(path) + .font(.caption2) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + } else { + Text("Omacy is not in System Settings yet. Register the extension from this app — the preview still runs either way.") + .font(.caption) + .foregroundColor(.secondary) + if let embeddedVersion = pluginManager.embeddedVersion { + Text("Embedded version: \(embeddedVersion)") + .font(.caption) + .foregroundColor(.secondary) + } + } + + if let error = pluginManager.lastError { + Text(error) + .font(.caption) + .foregroundColor(.red) + } + + HStack { + Spacer() + if pluginManager.isInstalled && !pluginManager.isPluginMissing { + Button("Uninstall") { + uninstallExtension() + } + .buttonStyle(.bordered) + .disabled(pluginManager.isLoading) + } else { + Button(pluginManager.isPluginMissing ? "Re-register" : "Install") { + installExtension() + } + .buttonStyle(.borderedProminent) + .disabled(pluginManager.isLoading) + } + Spacer() + } + .padding(.top, 4) + } + .padding(8) + } + } + + @ViewBuilder + private var screensaverActivationView: some View { + GroupBox { + VStack(alignment: .leading, spacing: 12) { + HStack { + Circle() + .fill(pluginManager.isActiveScreensaver ? Color.green : Color.gray) + .frame(width: 10, height: 10) + + if pluginManager.isActiveScreensaver { + Text("Active") + .fontWeight(.medium) + } else { + Text("Not Active") + .fontWeight(.medium) + .foregroundColor(.secondary) + } + + Spacer() + + if pluginManager.isCheckingScreensaver { + ProgressView() + .scaleEffect(0.7) + } else { + Button { + pluginManager.checkScreensaverStatus() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Refresh status") + } + } + + if let error = pluginManager.screensaverError { + Text(error) + .font(.caption) + .foregroundColor(.red) + } + + if pluginManager.isInstalled && !pluginManager.isActiveScreensaver { + HStack { + Spacer() + Button("Enable as Screensaver") { + Task { + await pluginManager.enableAsScreensaver() + } + } + .buttonStyle(.borderedProminent) + .disabled(pluginManager.isCheckingScreensaver) + Spacer() + } + .padding(.top, 4) + } + } + .padding(8) + } + } + + private func installExtension() { + statusMessage = "Installing extension..." + do { + try pluginManager.install() + statusMessage = "Extension installed successfully" + } catch { + statusMessage = "Install failed: \(error.localizedDescription)" + logger.error("Install failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func uninstallExtension() { + statusMessage = "Uninstalling extension..." + do { + try pluginManager.uninstall() + statusMessage = "Unregistered. Move Omacy to Trash to finish removing it. You can also delete the App Group data if you want a clean slate." + } catch { + statusMessage = "Uninstall failed: \(error.localizedDescription)" + logger.error("Uninstall failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func openScreenSaverSettings() { + // Tahoe keeps Screen Saver as a sheet on Wallpaper. The old + // ScreenSaver-Settings.extension URL no longer opens that pane. + guard let url = URL(string: "x-apple.systempreferences:com.apple.Wallpaper-Settings.extension?ScreenSaver") else { + return + } + NSWorkspace.shared.open(url) + } +} + +#Preview { + ContentView() +} diff --git a/apps/Omacy/Omacy/Helpers/Logger.swift b/apps/Omacy/Omacy/Helpers/Logger.swift new file mode 100644 index 0000000..b427f2e --- /dev/null +++ b/apps/Omacy/Omacy/Helpers/Logger.swift @@ -0,0 +1,23 @@ +// +// Logger.swift +// Omacy +// +// Copyright © 2026 Guillaume Louel. Licensed under the MIT License. +// + +import Foundation +import os.log + +/// Logging configuration shared between the host app and the screensaver extension. +/// +/// Open Console.app and filter by: +/// subsystem == "be.zenjoy.omacy" +/// to see logs from the host app and the appex extension at the same time. Every +/// log entry's category and PID make it clear which process the line came from. +enum AppexLog { + static let subsystem = "be.zenjoy.omacy" + + static func logger(_ category: String) -> Logger { + Logger(subsystem: subsystem, category: category) + } +} diff --git a/apps/Omacy/Omacy/Info.plist b/apps/Omacy/Omacy/Info.plist new file mode 100644 index 0000000..2a58fe9 --- /dev/null +++ b/apps/Omacy/Omacy/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + Copyright 2026 Peter Dedene. Effects by Terminal Text Effects (ChrisBuilds), Rust engine ttfx. + NSPrincipalClass + NSApplication + + diff --git a/apps/Omacy/Omacy/Omacy-Bridging-Header.h b/apps/Omacy/Omacy/Omacy-Bridging-Header.h new file mode 100644 index 0000000..14f23ee --- /dev/null +++ b/apps/Omacy/Omacy/Omacy-Bridging-Header.h @@ -0,0 +1 @@ +#import "omacy.h" diff --git a/apps/Omacy/Omacy/Omacy.entitlements b/apps/Omacy/Omacy/Omacy.entitlements new file mode 100644 index 0000000..93243a2 --- /dev/null +++ b/apps/Omacy/Omacy/Omacy.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-groups + + group.be.zenjoy.omacy + + + diff --git a/apps/Omacy/Omacy/OmacyApp.swift b/apps/Omacy/Omacy/OmacyApp.swift new file mode 100644 index 0000000..7b96a78 --- /dev/null +++ b/apps/Omacy/Omacy/OmacyApp.swift @@ -0,0 +1,21 @@ +import SwiftUI + +@main +struct OmacyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + + Window("Preview", id: "preview") { + PreviewViewRepresentable() + .ignoresSafeArea() + } + .defaultSize(width: 960, height: 600) + + Window("Art", id: "config") { + ConfigView() + } + .defaultSize(width: 560, height: 640) + } +} diff --git a/apps/Omacy/Omacy/OmacyCanaryAnimator.swift b/apps/Omacy/Omacy/OmacyCanaryAnimator.swift new file mode 100644 index 0000000..8f1ef6d --- /dev/null +++ b/apps/Omacy/Omacy/OmacyCanaryAnimator.swift @@ -0,0 +1 @@ +// Moved to Shared/OmacyCanaryAnimator.swift diff --git a/apps/Omacy/Omacy/OmacyHostView.swift b/apps/Omacy/Omacy/OmacyHostView.swift new file mode 100644 index 0000000..d06f3c6 --- /dev/null +++ b/apps/Omacy/Omacy/OmacyHostView.swift @@ -0,0 +1,91 @@ +import AppKit + +final class OmacyHostView: NSView { + private let renderer = OmacyRenderer() + private let canary = OmacyCanaryAnimator() + private var usingCanary = false + private var observingConfig = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + renderer.onEngineUnavailable = { [weak self] in + self?.switchToCanary() + } + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + renderer.onEngineUnavailable = { [weak self] in + self?.switchToCanary() + } + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil { + start() + } else { + stop() + } + } + + override func layout() { + super.layout() + if usingCanary { + canary.updateBounds(bounds) + } else { + renderer.updateGeometry() + } + } + + func applyPendingConfig() { + renderer.applyPendingConfig() + } + + private func start() { + stop() + observeConfig() + switch renderer.attach(to: self, isPreview: false) { + case .engine: + usingCanary = false + renderer.start() + case .canary: + switchToCanary() + } + } + + private func stop() { + renderer.stop() + canary.stop() + if observingConfig { + NotificationCenter.default.removeObserver(self, name: .omacyConfigDidChange, object: nil) + observingConfig = false + } + } + + private func switchToCanary() { + usingCanary = true + if let layer { + canary.attach(to: layer) + canary.updateBounds(bounds) + } + canary.start() + } + + private func observeConfig() { + guard !observingConfig else { return } + NotificationCenter.default.addObserver( + self, + selector: #selector(configDidChange), + name: .omacyConfigDidChange, + object: nil + ) + observingConfig = true + } + + @objc private func configDidChange() { + applyPendingConfig() + } +} diff --git a/apps/Omacy/Omacy/PluginManager.swift b/apps/Omacy/Omacy/PluginManager.swift new file mode 100644 index 0000000..d482b35 --- /dev/null +++ b/apps/Omacy/Omacy/PluginManager.swift @@ -0,0 +1,272 @@ +// +// PluginManager.swift +// Omacy +// +// Copyright © 2026 Guillaume Louel. Licensed under the MIT License. +// +// Drives extension registration (via pluginkit) and screensaver activation +// (via PaperSaverKit) from the host app's UI. +// + +import Foundation +import PaperSaverKit + +private let logger = AppexLog.logger("PluginManager") + +@MainActor +class PluginManager: ObservableObject { + @Published var isInstalled: Bool = false + @Published var installedVersion: String? + @Published var installedPath: String? + @Published var isLoading: Bool = false + @Published var lastError: String? + @Published var registeredPaths: [String] = [] + + /// Registered with PlugInKit but the appex file is gone. + var isPluginMissing: Bool { + guard isInstalled else { return false } + guard let path = installedPath, !path.isEmpty else { return true } + return !FileManager.default.fileExists(atPath: path) + } + + /// PlugInKit is sticky if DerivedData and /Applications are both registered. + var hasConflictingRegistrations: Bool { + registeredPaths.count > 1 + } + + @Published var isActiveScreensaver: Bool = false + @Published var isCheckingScreensaver: Bool = false + @Published var screensaverError: String? + + private let bundleIdentifier = "be.zenjoy.omacy.screensaver" + private let paperSaver = PaperSaver() + private let screensaverDisplayName = "Omacy" + + /// Path to the embedded extension in the app bundle. + var embeddedExtensionPath: String? { + Bundle.main.builtInPlugInsURL?.appendingPathComponent("OmacyScreensaver.appex").path + } + + /// Version of the embedded extension. + var embeddedVersion: String? { + guard let path = embeddedExtensionPath, + let bundle = Bundle(path: path), + let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String else { + return nil + } + return version + } + + init() { + checkInstallationStatus() + checkScreensaverStatus() + } + + /// Check if the extension is registered with pluginkit. + func checkInstallationStatus() { + isLoading = true + lastError = nil + + Task { + do { + let matches = try await queryPluginKit() + await MainActor.run { + self.registeredPaths = matches.map(\.path).compactMap { $0 } + self.isInstalled = !matches.isEmpty + self.installedPath = matches.first?.path + self.installedVersion = matches.first?.version + self.isLoading = false + if matches.count > 1 { + self.lastError = "Registered in more than one place. Unregister extras so only /Applications or DerivedData remains." + } + } + } catch { + await MainActor.run { + self.isInstalled = false + self.installedPath = nil + self.installedVersion = nil + self.registeredPaths = [] + self.isLoading = false + self.lastError = error.localizedDescription + } + } + } + } + + private struct PluginMatch { + var path: String? + var version: String? + } + + /// Query pluginkit for our extension's registration status. + /// Line format we look for: + /// `+ be.zenjoy.omacy.screensaver(1.0) ` + private func queryPluginKit() async throws -> [PluginMatch] { + let output = try runProcess("/usr/bin/pluginkit", arguments: ["-m", "-v", "-p", "com.apple.screensaver"]) + + var matches: [PluginMatch] = [] + let lines = output.components(separatedBy: "\n") + for line in lines { + if line.contains(bundleIdentifier) { + logger.info("Found extension in pluginkit output: \(line, privacy: .public)") + + var version: String? + if let versionStart = line.firstIndex(of: "("), + let versionEnd = line.firstIndex(of: ")") { + let start = line.index(after: versionStart) + version = String(line[start.. Bool { + let path = Bundle.main.bundlePath + if path.hasPrefix("/Applications/") { return true } + if path.contains("/DerivedData/") { return true } + return false + } + + /// Run a subprocess and return its combined stdout/stderr. + private func runProcess(_ path: String, arguments: [String]) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = arguments + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + logger.debug("Process output: \(output, privacy: .public)") + + if process.terminationStatus != 0 { + // pluginkit returns non-zero when no matches are found, so don't treat it as fatal. + logger.warning("Process exited with status: \(process.terminationStatus)") + } + + return output + } +} + +enum PluginError: LocalizedError { + case embeddedExtensionNotFound + case extensionPathNotFound + case installationFailed(String) + case uninstallationFailed(String) + case notInApplications + + var errorDescription: String? { + switch self { + case .embeddedExtensionNotFound: + return "Embedded extension not found in app bundle" + case .extensionPathNotFound: + return "Extension path not found" + case .installationFailed(let message): + return "Installation failed: \(message)" + case .uninstallationFailed(let message): + return "Uninstallation failed: \(message)" + case .notInApplications: + return "Move Omacy.app to /Applications, then register. The host will not register from a random path." + } + } +} diff --git a/apps/Omacy/Omacy/PreviewView.swift b/apps/Omacy/Omacy/PreviewView.swift new file mode 100644 index 0000000..bc06b43 --- /dev/null +++ b/apps/Omacy/Omacy/PreviewView.swift @@ -0,0 +1,3 @@ +import AppKit + +typealias PreviewView = OmacyHostView diff --git a/apps/Omacy/Omacy/PreviewViewRepresentable.swift b/apps/Omacy/Omacy/PreviewViewRepresentable.swift new file mode 100644 index 0000000..fc7b822 --- /dev/null +++ b/apps/Omacy/Omacy/PreviewViewRepresentable.swift @@ -0,0 +1,6 @@ +import SwiftUI + +struct PreviewViewRepresentable: NSViewRepresentable { + func makeNSView(context: Context) -> PreviewView { PreviewView() } + func updateNSView(_ nsView: PreviewView, context: Context) { } +} diff --git a/apps/Omacy/OmacyScreensaver/Assets.xcassets/Contents.json b/apps/Omacy/OmacyScreensaver/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/apps/Omacy/OmacyScreensaver/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/Contents.json b/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/Contents.json new file mode 100644 index 0000000..0bbaa31 --- /dev/null +++ b/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "thumbnail.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "thumbnail@2x.png", + "idiom" : "universal", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/thumbnail.png b/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/thumbnail.png new file mode 100644 index 0000000..91b8863 Binary files /dev/null and b/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/thumbnail.png differ diff --git a/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/thumbnail@2x.png b/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/thumbnail@2x.png new file mode 100644 index 0000000..94dae9e Binary files /dev/null and b/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/thumbnail@2x.png differ diff --git a/apps/Omacy/OmacyScreensaver/Info.plist b/apps/Omacy/OmacyScreensaver/Info.plist new file mode 100644 index 0000000..394e2c9 --- /dev/null +++ b/apps/Omacy/OmacyScreensaver/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Omacy + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSExtension + + NSExtensionPointIdentifier + com.apple.screensaver + NSExtensionPointVersion + 1.0 + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).OmacyScreensaver + + SSEHasConfigureSheet + + SSENeedsAnimationTimer + + ScreenSaverConfigurationSheetViewControllerClass + $(PRODUCT_MODULE_NAME).OmacyConfigurationViewController + ScreenSaverViewControllerClass + $(PRODUCT_MODULE_NAME).OmacySaverViewController + + diff --git a/apps/Omacy/OmacyScreensaver/OmacyConfigurationViewController.swift b/apps/Omacy/OmacyScreensaver/OmacyConfigurationViewController.swift new file mode 100644 index 0000000..f4c6bbf --- /dev/null +++ b/apps/Omacy/OmacyScreensaver/OmacyConfigurationViewController.swift @@ -0,0 +1,82 @@ +import AppKit +import ScreenSaver + +private let logger = AppexLog.logger("Configuration") + +@objc(OmacyConfigurationViewController) +class OmacyConfigurationViewController: ScreenSaverConfigurationViewController { + private let effectField = NSTextField() + private let statusLabel = NSTextField(labelWithString: "") + + override func loadView() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 180)) + let title = NSTextField(labelWithString: "Omacy") + title.font = NSFont.systemFont(ofSize: 18, weight: .medium) + title.alignment = .center + title.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(title) + + let settings = OmacyStore.loadSettings() + effectField.stringValue = settings.effect + effectField.placeholderString = "random or a ttfx name" + effectField.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(effectField) + + let restore = NSButton(title: "Restore default art", target: self, action: #selector(restoreArt)) + restore.bezelStyle = .rounded + restore.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(restore) + + let ok = NSButton(title: "OK", target: self, action: #selector(saveAndDismiss)) + ok.bezelStyle = .rounded + ok.keyEquivalent = "\r" + ok.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(ok) + + statusLabel.font = NSFont.systemFont(ofSize: 11) + statusLabel.textColor = .secondaryLabelColor + statusLabel.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(statusLabel) + + NSLayoutConstraint.activate([ + title.centerXAnchor.constraint(equalTo: container.centerXAnchor), + title.topAnchor.constraint(equalTo: container.topAnchor, constant: 16), + effectField.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20), + effectField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20), + effectField.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 16), + restore.leadingAnchor.constraint(equalTo: effectField.leadingAnchor), + restore.topAnchor.constraint(equalTo: effectField.bottomAnchor, constant: 12), + ok.trailingAnchor.constraint(equalTo: effectField.trailingAnchor), + ok.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16), + statusLabel.leadingAnchor.constraint(equalTo: effectField.leadingAnchor), + statusLabel.bottomAnchor.constraint(equalTo: ok.topAnchor, constant: -8) + ]) + self.view = container + self.preferredContentSize = NSSize(width: 360, height: 180) + } + + @objc private func restoreArt() { + do { + try OmacyStore.restoreDefaultArt() + statusLabel.stringValue = "Default wordmark restored" + } catch { + statusLabel.stringValue = error.localizedDescription + } + } + + @objc private func saveAndDismiss(_ sender: Any?) { + var settings = OmacyStore.loadSettings() + settings.effect = effectField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + if settings.effect.isEmpty { settings.effect = "random" } + do { + try OmacyStore.save(settings: settings, art: OmacyStore.loadArt()) + } catch { + logger.error("save failed: \(error.localizedDescription, privacy: .public)") + } + if let window = self.view.window, let parent = window.sheetParent { + parent.endSheet(window) + } else { + self.dismiss(nil) + } + } +} diff --git a/apps/Omacy/OmacyScreensaver/OmacySaverView.swift b/apps/Omacy/OmacyScreensaver/OmacySaverView.swift new file mode 100644 index 0000000..23f3f40 --- /dev/null +++ b/apps/Omacy/OmacyScreensaver/OmacySaverView.swift @@ -0,0 +1,90 @@ +import ScreenSaver +import QuartzCore + +private let logger = AppexLog.logger("View") + +final class OmacySaverView: ScreenSaverView { + private let renderer = OmacyRenderer() + private let canary = OmacyCanaryAnimator() + private var usingCanary = true + + override init?(frame: NSRect, isPreview: Bool) { + logger.info("init(frame: \(frame.size.width, privacy: .public)x\(frame.size.height, privacy: .public), isPreview: \(isPreview))") + super.init(frame: frame, isPreview: isPreview) + wantsLayer = true + animationTimeInterval = 1.0 / 60.0 + renderer.onEngineUnavailable = { [weak self] in + self?.switchToCanary() + } + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + renderer.onEngineUnavailable = { [weak self] in + self?.switchToCanary() + } + } + + deinit { + // teardown() is @MainActor (OmacyRenderer). stopAnimation / window-nil already ran. + logger.info("deinit") + } + + override func startAnimation() { + logger.info("startAnimation()") + super.startAnimation() + start() + } + + override func stopAnimation() { + logger.info("stopAnimation()") + teardown() + super.stopAnimation() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + logger.info("viewDidMoveToWindow() hasWindow=\(self.window != nil)") + if window != nil { + start() + } else { + teardown() + } + } + + override func layout() { + super.layout() + if usingCanary { + canary.updateBounds(bounds) + } else { + renderer.updateGeometry() + } + } + + private func start() { + teardown() + let preview = isPreview || (bounds.width > 0 && bounds.width < 400) + switch renderer.attach(to: self, isPreview: preview) { + case .engine: + usingCanary = false + renderer.start() + case .canary: + switchToCanary() + } + } + + private func switchToCanary() { + usingCanary = true + if let layer { + canary.attach(to: layer) + canary.updateBounds(bounds) + } + canary.start() + } + + private func teardown() { + renderer.stop() + canary.stop() + } +} diff --git a/apps/Omacy/OmacyScreensaver/OmacySaverViewController.swift b/apps/Omacy/OmacyScreensaver/OmacySaverViewController.swift new file mode 100644 index 0000000..4154fc8 --- /dev/null +++ b/apps/Omacy/OmacyScreensaver/OmacySaverViewController.swift @@ -0,0 +1,57 @@ +// +// OmacySaverViewController.swift +// OmacyScreensaver +// +// Copyright © 2026 Guillaume Louel. Licensed under the MIT License. +// +// Main view controller for the screensaver. Specified as +// ScreenSaverViewControllerClass in Info.plist as +// `$(PRODUCT_MODULE_NAME).OmacySaverViewController`. +// +// Mirrors Apple's Arabesque.appex pattern. Prefer loadView(forFrame:isPreview:) +// so each display and the Settings preview get the real frame; loadView() is +// the fallback when the framework only calls the NSViewController path. +// + +import AppKit +import ScreenSaver + +private let logger = AppexLog.logger("ViewController") + +@objc(OmacySaverViewController) +class OmacySaverViewController: ScreenSaverViewController { + + /// Strong reference so the framework can't drop our view while we still own it. + private var saverView: OmacySaverView? + + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + logger.info("init(nibName:bundle:)") + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + required init?(coder: NSCoder) { + logger.info("init(coder:)") + super.init(coder: coder) + } + + deinit { + logger.info("deinit") + } + + override func loadView() { + logger.info("loadView()") + let frame = NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1920, height: 1080) + installView(frame: frame, isPreview: frame.width < 400) + } + + override func loadView(forFrame frame: NSRect, isPreview: Bool) { + logger.info("loadView(forFrame: \(frame.size.width, privacy: .public)x\(frame.size.height, privacy: .public), isPreview: \(isPreview))") + installView(frame: frame, isPreview: isPreview) + } + + private func installView(frame: NSRect, isPreview: Bool) { + let view = OmacySaverView(frame: frame, isPreview: isPreview) + saverView = view + self.view = view ?? NSView(frame: frame) + } +} diff --git a/apps/Omacy/OmacyScreensaver/OmacyScreensaver-Bridging-Header.h b/apps/Omacy/OmacyScreensaver/OmacyScreensaver-Bridging-Header.h new file mode 100644 index 0000000..9d6a025 --- /dev/null +++ b/apps/Omacy/OmacyScreensaver/OmacyScreensaver-Bridging-Header.h @@ -0,0 +1,10 @@ +// +// AppexSaverExtension-Bridging-Header.h +// AppexSaverExtension +// +// Bridging header to expose private ScreenSaver API to Swift. +// + +#import "PrivateHeaders/ScreenSaverPrivate.h" +#import "omacy.h" + diff --git a/apps/Omacy/OmacyScreensaver/OmacyScreensaver.entitlements b/apps/Omacy/OmacyScreensaver/OmacyScreensaver.entitlements new file mode 100644 index 0000000..54cca65 --- /dev/null +++ b/apps/Omacy/OmacyScreensaver/OmacyScreensaver.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.app-groups + + group.be.zenjoy.omacy + + com.apple.security.cs.disable-library-validation + + com.apple.security.temporary-exception.mach-lookup.global-name + + com.apple.CARenderServer + com.apple.CoreDisplay.master + com.apple.ViewBridgeAuxiliary + + + diff --git a/apps/Omacy/OmacyScreensaver/OmacyScreensaver.swift b/apps/Omacy/OmacyScreensaver/OmacyScreensaver.swift new file mode 100644 index 0000000..7619901 --- /dev/null +++ b/apps/Omacy/OmacyScreensaver/OmacyScreensaver.swift @@ -0,0 +1,31 @@ +// +// OmacyScreensaver.swift +// OmacyScreensaver +// +// Copyright © 2026 Guillaume Louel. Licensed under the MIT License. +// +// Principal class for the screensaver extension. Specified as +// NSExtensionPrincipalClass in Info.plist as +// `$(PRODUCT_MODULE_NAME).OmacyScreensaver`. +// +// Following Apple's own screensavers (e.g. Arabesque.appex) we keep this +// minimal — only implement init() and let the framework drive lifecycle. +// + +import Foundation +import ScreenSaver + +private let logger = AppexLog.logger("Extension") + +@objc(OmacyScreensaver) +class OmacyScreensaver: ScreenSaverExtension { + + @objc override init() { + logger.info("OmacyScreensaver.init() PID=\(ProcessInfo.processInfo.processIdentifier, privacy: .public)") + super.init() + } + + deinit { + logger.info("OmacyScreensaver.deinit") + } +} diff --git a/apps/Omacy/OmacyScreensaver/PrivateHeaders/ScreenSaverPrivate.h b/apps/Omacy/OmacyScreensaver/PrivateHeaders/ScreenSaverPrivate.h new file mode 100644 index 0000000..b08741e --- /dev/null +++ b/apps/Omacy/OmacyScreensaver/PrivateHeaders/ScreenSaverPrivate.h @@ -0,0 +1,51 @@ +// +// ScreenSaverPrivate.h +// AppexSaverExtension +// +// Private API declarations for macOS screensaver extensions. +// These classes exist in ScreenSaver.framework but are not publicly declared. +// Discovered via reverse engineering Apple's screensaver appex bundles. +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +#pragma mark - ScreenSaverExtension + +/// Principal class for modern screensaver app extensions. +/// Subclass this and specify it as NSExtensionPrincipalClass in Info.plist. +@interface ScreenSaverExtension : NSObject + +- (instancetype)init; + +@end + +#pragma mark - ScreenSaverViewController + +/// Main view controller for screensaver animation. +/// Specify your subclass name as ScreenSaverViewControllerClass in Info.plist. +@interface ScreenSaverViewController : NSViewController + +/// The ScreenSaverView that provides the animation. +@property (nonatomic, weak, nullable) ScreenSaverView *representedView; + +/// Whether the screensaver is currently animating. +@property (nonatomic, getter=isAnimating) BOOL animating; + +/// Called to create the view for the given frame. +/// Swift name: loadView(forFrame:isPreview:) +- (void)loadViewForFrame:(NSRect)frame isPreview:(BOOL)isPreview NS_SWIFT_NAME(loadView(forFrame:isPreview:)); + +@end + +#pragma mark - ScreenSaverConfigurationViewController + +/// View controller for the screensaver configuration sheet. +/// Subclass this if SSEHasConfigureSheet is true in Info.plist. +@interface ScreenSaverConfigurationViewController : NSViewController + +@end + +NS_ASSUME_NONNULL_END diff --git a/apps/Omacy/README.md b/apps/Omacy/README.md new file mode 100644 index 0000000..2dc89fc --- /dev/null +++ b/apps/Omacy/README.md @@ -0,0 +1,17 @@ +# Omacy Mac host + +Xcode project for the host app and `com.apple.screensaver` appex. + +Build on macOS 15+ (deployment target 15.0). PaperSaver 0.2.0 needs Swift 6.2, so resolving packages wants Xcode 26. The appex target’s “Build Rust engine” phase runs `scripts/build-engine.sh` once; the host depends on the appex and links the same `libomacy_engine.a`. + +## Run + +1. Team is `25TVW8MSGJ` (Zenjoy). Sync Developer ID profiles with `bundle exec fastlane mac generate_signing` (once) or `mac sync_signing` (readonly). +2. Build the Omacy scheme. +3. For iteration, stay in DerivedData **or** copy the app to `/Applications` — not both. +4. Use the host to register (`pluginkit`) and enable (PaperSaver). Registration from a random path is refused. +5. Preview in the host. Idle / System Settings listing is the canary gate: see `docs/macos-gates.md` in the repo root. + +Force the asymmetric fixture (no engine) with UserDefaults `omacy.forceCanary`. + +App Group: `group.be.zenjoy.omacy` (`screensaver.txt`, `settings.json`). diff --git a/apps/Omacy/Shared/OmacyAtlas.swift b/apps/Omacy/Shared/OmacyAtlas.swift new file mode 100644 index 0000000..84bf3e4 --- /dev/null +++ b/apps/Omacy/Shared/OmacyAtlas.swift @@ -0,0 +1,164 @@ +import AppKit +import CoreText +import Metal + +struct AtlasGlyph { + var uvOrigin: SIMD2 + var uvSize: SIMD2 +} + +final class OmacyAtlas { + private(set) var texture: MTLTexture? + private var map: [UInt32: AtlasGlyph] = [:] + private var extraCount = 0 + private let extraCap = 256 + private var cell: CGSize = .zero + private var pixels: [UInt8] = [] + private var texWidth = 1 + private var texHeight = 1 + private var glyphW = 1 + private var glyphH = 1 + private var cols = 1 + private var nextSlot = 1 + private var builtCell: CGSize = .zero + private var builtScale: CGFloat = 0 + private var builtFontName = "" + private var builtFontSize: CGFloat = 0 + + private let preload: [UInt32] = { + var codes: [UInt32] = Array(0x20...0x7E) + codes.append(contentsOf: 0x2800...0x28FF) + codes.append(contentsOf: [0x2580, 0x2584, 0x2588, 0x2591, 0x2592, 0x2593]) + return codes + }() + + func needsRebuild(font: NSFont, cell: CGSize, scale: CGFloat) -> Bool { + abs(cell.width - builtCell.width) >= 0.5 + || abs(cell.height - builtCell.height) >= 0.5 + || abs(scale - builtScale) >= 0.01 + || font.fontName != builtFontName + || abs(font.pointSize - builtFontSize) >= 0.01 + } + + func rebuild(device: MTLDevice, font: NSFont, cell: CGSize, scale: CGFloat) { + self.cell = cell + builtCell = cell + builtScale = scale + builtFontName = font.fontName + builtFontSize = font.pointSize + extraCount = 0 + map.removeAll() + nextSlot = 1 + let scale = max(scale, 1) + glyphW = max(Int(ceil(cell.width * scale)), 1) + glyphH = max(Int(ceil(cell.height * scale)), 1) + let count = preload.count + extraCap + 1 + cols = Int(ceil(sqrt(Double(count)))) + let rows = Int(ceil(Double(count) / Double(cols))) + texWidth = max(cols * glyphW, 1) + texHeight = max(rows * glyphH, 1) + pixels = Array(repeating: 0, count: texWidth * texHeight * 4) + writeWhitePixel(x: 0, y: 0) + map[0] = AtlasGlyph( + uvOrigin: SIMD2(0, 0), + uvSize: SIMD2(1 / Float(texWidth), 1 / Float(texHeight)) + ) + let ctFont = font as CTFont + for scalar in preload { + _ = stamp(font: ctFont, scalar: scalar) + } + upload(device: device) + } + + var whitePixel: AtlasGlyph { + map[0] ?? AtlasGlyph(uvOrigin: SIMD2(0, 0), uvSize: SIMD2(1 / Float(max(texWidth, 1)), 1 / Float(max(texHeight, 1)))) + } + + func glyph(for scalar: UInt32, font: NSFont, device: MTLDevice) -> AtlasGlyph? { + if let hit = map[scalar] { return hit } + if extraCount >= extraCap { return nil } + extraCount += 1 + let g = stamp(font: font as CTFont, scalar: scalar) + upload(device: device) + return g + } + + private func upload(device: MTLDevice) { + let desc = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .rgba8Unorm, + width: texWidth, + height: texHeight, + mipmapped: false + ) + desc.usage = [.shaderRead] + desc.storageMode = .shared + guard let texture = device.makeTexture(descriptor: desc) else { return } + pixels.withUnsafeBytes { raw in + texture.replace( + region: MTLRegionMake2D(0, 0, texWidth, texHeight), + mipmapLevel: 0, + withBytes: raw.baseAddress!, + bytesPerRow: texWidth * 4 + ) + } + self.texture = texture + } + + private func writeWhitePixel(x: Int, y: Int) { + let i = (y * texWidth + x) * 4 + if i + 3 < pixels.count { + pixels[i] = 255 + pixels[i + 1] = 255 + pixels[i + 2] = 255 + pixels[i + 3] = 255 + } + } + + private func stamp(font: CTFont, scalar: UInt32) -> AtlasGlyph? { + guard UnicodeScalar(scalar) != nil else { return nil } + let slot = nextSlot + nextSlot += 1 + let col = slot % cols + let row = slot / cols + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let ctx = CGContext( + data: nil, + width: glyphW, + height: glyphH, + bitsPerComponent: 8, + bytesPerRow: glyphW * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + ctx.clear(CGRect(x: 0, y: 0, width: glyphW, height: glyphH)) + ctx.setFillColor(CGColor(gray: 1, alpha: 1)) + var utf16 = Array(String(UnicodeScalar(scalar)!).utf16) + var glyph: CGGlyph = 0 + _ = utf16.withUnsafeMutableBufferPointer { buf in + CTFontGetGlyphsForCharacters(font, buf.baseAddress!, &glyph, 1) + } + var point = CGPoint(x: 0, y: CGFloat(glyphH) * 0.18) + CTFontDrawGlyphs(font, &glyph, &point, 1, ctx) + if let data = ctx.data { + let src = data.bindMemory(to: UInt8.self, capacity: glyphW * glyphH * 4) + let destX = col * glyphW + let destY = row * glyphH + for gy in 0.. NSFont { + if let url = OmacyStore.bundledFontURL, + let data = try? Data(contentsOf: url) as CFData, + let provider = CGDataProvider(data: data), + let cg = CGFont(provider), + let font = CTFontCreateWithGraphicsFont(cg, size, nil, nil) as NSFont? { + return font + } + return NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + } +} diff --git a/apps/Omacy/Shared/OmacyLayout.swift b/apps/Omacy/Shared/OmacyLayout.swift new file mode 100644 index 0000000..345a0ec --- /dev/null +++ b/apps/Omacy/Shared/OmacyLayout.swift @@ -0,0 +1,73 @@ +import AppKit + +enum OmacyLayout { + static let defaultFontSize: CGFloat = 18 + static let minCells = 1 + static let maxAxis = 512 + static let maxCells = 32_768 + + static func cellSize(font: NSFont) -> CGSize { + let advance = font.advancement(forGlyph: font.glyph(withName: "M")).width + let width = advance > 0 ? advance : font.maximumAdvancement.width + return CGSize(width: max(width, 1), height: max(font.pointSize, 1)) + } + + static func grid(view: NSView, font: NSFont) -> (cols: UInt32, rows: UInt32, cell: CGSize, origin: CGPoint) { + let scale = view.window?.backingScaleFactor ?? view.layer?.contentsScale ?? 2 + let backing = view.convertToBacking(view.bounds.size) + return grid(viewSize: view.bounds.size, backing: backing, scale: scale, font: font) + } + + static func grid(viewSize: CGSize, scale: CGFloat, font: NSFont) -> (cols: UInt32, rows: UInt32, cell: CGSize, origin: CGPoint) { + grid(viewSize: viewSize, backing: CGSize(width: viewSize.width * scale, height: viewSize.height * scale), scale: scale, font: font) + } + + static func grid( + viewSize: CGSize, + backing: CGSize, + scale: CGFloat, + font: NSFont + ) -> (cols: UInt32, rows: UInt32, cell: CGSize, origin: CGPoint) { + let points = treatedAsPoints(viewSize: viewSize, backing: backing, scale: scale) + let cell = cellSize(font: font) + var cols = max(Self.minCells, Int(floor(points.width / cell.width))) + var rows = max(Self.minCells, Int(floor(points.height / cell.height))) + cols = min(cols, Self.maxAxis) + rows = min(rows, Self.maxAxis) + if cols * rows > Self.maxCells { + let ratio = sqrt(Double(Self.maxCells) / Double(cols * rows)) + cols = max(Self.minCells, Int(Double(cols) * ratio)) + rows = max(Self.minCells, Int(Double(rows) * ratio)) + } + let gridWidth = CGFloat(cols) * cell.width + let gridHeight = CGFloat(rows) * cell.height + let origin = CGPoint( + x: (points.width - gridWidth) / 2, + y: (points.height - gridHeight) / 2 + ) + return (UInt32(cols), UInt32(rows), cell, origin) + } + + /// Tahoe may hand backing pixels as bounds. An exact 2× jump vs backingScale is scale, not a giant canvas. + static func treatedAsPoints(viewSize: CGSize, backing: CGSize, scale: CGFloat) -> CGSize { + guard scale > 1 else { return viewSize } + if abs(backing.width - viewSize.width * scale) < 1, abs(backing.height - viewSize.height * scale) < 1 { + return viewSize + } + if abs(backing.width - viewSize.width) < 1, abs(backing.height - viewSize.height) < 1 { + return CGSize(width: viewSize.width / scale, height: viewSize.height / scale) + } + return viewSize + } + + static func drawableSize(viewSize: CGSize, scale: CGFloat) -> CGSize { + CGSize( + width: max((viewSize.width * scale).rounded(), 1), + height: max((viewSize.height * scale).rounded(), 1) + ) + } + + static func drawableSizeChanged(current: CGSize, proposed: CGSize) -> Bool { + abs(current.width - proposed.width) >= 1 || abs(current.height - proposed.height) >= 1 + } +} diff --git a/apps/Omacy/Shared/OmacyRenderer.swift b/apps/Omacy/Shared/OmacyRenderer.swift new file mode 100644 index 0000000..5a89f7f --- /dev/null +++ b/apps/Omacy/Shared/OmacyRenderer.swift @@ -0,0 +1,461 @@ +import AppKit +import Metal +import QuartzCore + +private let log = AppexLog.logger("Renderer") +private let occupancyBackground = UInt8(OMACY_CELL_HAS_BACKGROUND) +private let occupancyGlyph = UInt8(OMACY_CELL_HAS_GLYPH) + +private struct QuadInstance { + var origin: SIMD2 + var size: SIMD2 + var uvOrigin: SIMD2 + var uvSize: SIMD2 + var color: SIMD4 +} + +enum OmacyAttachMode { + case engine + case canary +} + +@MainActor +final class OmacyRenderer: NSObject { + var onEngineUnavailable: (() -> Void)? + + override init() { + super.init() + } + + private weak var view: NSView? + private var session: OpaquePointer? + private var displayLink: CADisplayLink? + private var device: MTLDevice? + private var queue: MTLCommandQueue? + private var pipeline: MTLRenderPipelineState? + private var metalLayer: CAMetalLayer? + private let atlas = OmacyAtlas() + private var instanceBuffers: [MTLBuffer?] = [nil, nil, nil] + private var bufferBusy = [false, false, false] + private var bufferIndex = 0 + private var lastTimestamp: CFTimeInterval? + private var fontSize: CGFloat = OmacyLayout.defaultFontSize + private var cols: UInt32 = 1 + private var rows: UInt32 = 1 + private var pendingCols: UInt32? + private var pendingRows: UInt32? + private var debounce: TimeInterval = 0 + private var isPreview = false + private var stopped = true + private var copiedCells: [OmacyCell] = [] + private var lastSettings = OmacySettings() + private var lastArt = "" + + var usesEngine: Bool { session != nil } + + @discardableResult + func attach(to view: NSView, isPreview: Bool) -> OmacyAttachMode { + stopSessionAndLink() + self.view = view + self.isPreview = isPreview + view.wantsLayer = true + lastSettings = OmacyStore.loadSettings() + lastArt = OmacyStore.loadArt() + fontSize = CGFloat(lastSettings.fontSize) + let font = OmacyFont.makeFont(size: fontSize) + let layout = OmacyLayout.grid(view: view, font: font) + cols = layout.cols + rows = layout.rows + + if OmacyStore.forceCanary || !createSession(cols: cols, rows: rows, settings: lastSettings, art: lastArt) { + installPlainLayer(on: view) + return .canary + } + guard installMetal(on: view, font: font, cell: layout.cell) else { + destroySession() + installPlainLayer(on: view) + return .canary + } + return .engine + } + + func start() { + guard let view, session != nil else { return } + stopped = false + displayLink?.invalidate() + let link = view.displayLink(target: self, selector: #selector(tick(_:))) + link.add(to: .main, forMode: .common) + if isPreview { + link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 30, preferred: 30) + } + displayLink = link + NotificationCenter.default.addObserver( + self, + selector: #selector(willStop), + name: Notification.Name("com.apple.screensaver.willstop"), + object: nil + ) + } + + func stop() { + stopped = true + NotificationCenter.default.removeObserver(self) + stopSessionAndLink() + metalLayer = nil + pipeline = nil + queue = nil + device = nil + instanceBuffers = [nil, nil, nil] + bufferBusy = [false, false, false] + } + + deinit { + if session != nil { + assertionFailure("OmacyRenderer.deinit with live session") + if Thread.isMainThread { + omacy_session_destroy(session) + } + } + } + + func updateGeometry() { + guard let view, view.bounds.width > 1, view.bounds.height > 1 else { return } + let font = OmacyFont.makeFont(size: fontSize) + let layout = OmacyLayout.grid(view: view, font: font) + let scale = view.window?.backingScaleFactor ?? metalLayer?.contentsScale ?? view.layer?.contentsScale ?? 2 + let size = OmacyLayout.drawableSize(viewSize: view.bounds.size, scale: scale) + if metalLayer?.contentsScale != scale { + metalLayer?.contentsScale = scale + } + if let metalLayer, OmacyLayout.drawableSizeChanged(current: metalLayer.drawableSize, proposed: size) { + metalLayer.drawableSize = size + } + metalLayer?.frame = view.bounds + if layout.cols != cols || layout.rows != rows { + pendingCols = layout.cols + pendingRows = layout.rows + debounce = CACurrentMediaTime() + } + if let device, atlas.needsRebuild(font: font, cell: layout.cell, scale: scale) { + atlas.rebuild(device: device, font: font, cell: layout.cell, scale: scale) + } + } + + func applyPendingConfig() { + lastSettings = OmacyStore.loadSettings() + lastArt = OmacyStore.loadArt() + fontSize = CGFloat(lastSettings.fontSize) + guard let session else { return } + var cfg = OmacyPendingConfig() + lastArt.withCString { ptr in + cfg.ascii = UnsafeRawPointer(ptr).assumingMemoryBound(to: UInt8.self) + cfg.ascii_len = lastArt.utf8.count + lastSettings.effect.withCString { eptr in + cfg.effect = UnsafeRawPointer(eptr).assumingMemoryBound(to: UInt8.self) + cfg.effect_len = lastSettings.effect.utf8.count + let bg = lastSettings.backgroundRGBA + cfg.bg_r = bg.0 + cfg.bg_g = bg.1 + cfg.bg_b = bg.2 + cfg.bg_a = bg.3 + _ = omacy_session_set_pending_config(session, &cfg) + } + } + updateGeometry() + } + + @objc private func willStop() { + stop() + } + + @objc private func tick(_ link: CADisplayLink) { + guard !stopped else { return } + guard let session else { return } + let now = link.timestamp + let elapsed: Double + if let lastTimestamp { + elapsed = now - lastTimestamp + } else { + elapsed = 1.0 / 60.0 + } + lastTimestamp = now + + var result = OmacyStepResult() + let status = omacy_session_step(session, elapsed, &result) + if status == OMACY_ERR_DEAD || status == OMACY_ERR_PANIC { + recoverDeadSession() + return + } + guard status == OMACY_OK, let cells = result.frame.cells else { return } + + let count = Int(result.frame.cols) * Int(result.frame.rows) + copiedCells = Array(UnsafeBufferPointer(start: cells, count: count)) + present(frame: result.frame, cells: copiedCells) + applyWaitingWork(result) + } + + private func applyWaitingWork(_ result: OmacyStepResult) { + guard let session else { return } + if result.needs_begin_next != 0 { + let settings = OmacyStore.loadSettings() + if CGFloat(settings.fontSize) != fontSize { + fontSize = CGFloat(settings.fontSize) + lastSettings = settings + updateGeometry() + } + if CACurrentMediaTime() - debounce >= 0.05, let pc = pendingCols, let pr = pendingRows { + _ = omacy_session_resize(session, pc, pr) + cols = pc + rows = pr + pendingCols = nil + pendingRows = nil + } + _ = omacy_session_begin_next(session) + } else if let pc = pendingCols, let pr = pendingRows, CACurrentMediaTime() - debounce >= 0.05 { + _ = omacy_session_resize(session, pc, pr) + } + } + + private func present(frame: OmacyFrame, cells: [OmacyCell]) { + guard let view, let metalLayer, let queue, let pipeline else { return } + let font = OmacyFont.makeFont(size: fontSize) + let layout = OmacyLayout.grid(view: view, font: font) + var instances: [QuadInstance] = [] + instances.reserveCapacity(cells.count * 2) + let cell = layout.cell + let origin = layout.origin + let cols = Int(frame.cols) + let rows = Int(frame.rows) + let white = atlas.whitePixel + for r in 0..(Float(cell.width), Float(cell.height)) + if cellData.occupancy & occupancyBackground != 0 { + instances.append(QuadInstance( + origin: SIMD2(px, py), + size: size, + uvOrigin: white.uvOrigin, + uvSize: white.uvSize, + color: SIMD4( + Float(cellData.bg_r) / 255, + Float(cellData.bg_g) / 255, + Float(cellData.bg_b) / 255, + Float(cellData.bg_a) / 255 + ) + )) + } + if cellData.occupancy & occupancyGlyph != 0, cellData.glyph != 0, + let device, let g = atlas.glyph(for: cellData.glyph, font: font, device: device) { + instances.append(QuadInstance( + origin: SIMD2(px, py), + size: size, + uvOrigin: g.uvOrigin, + uvSize: g.uvSize, + color: SIMD4( + Float(cellData.fg_r) / 255, + Float(cellData.fg_g) / 255, + Float(cellData.fg_b) / 255, + Float(cellData.fg_a) / 255 + ) + )) + } + } + } + var upload: (index: Int, buffer: MTLBuffer)? + if !instances.isEmpty { + guard let chosen = freeInstanceBufferIndex(), let device else { return } + let bytes = instances.count * MemoryLayout.stride + if instanceBuffers[chosen] == nil || instanceBuffers[chosen]!.length < bytes { + instanceBuffers[chosen] = device.makeBuffer(length: max(bytes, 4096), options: .storageModeShared) + } + guard let buffer = instanceBuffers[chosen] else { return } + instances.withUnsafeBytes { raw in + buffer.contents().copyMemory(from: raw.baseAddress!, byteCount: bytes) + } + upload = (chosen, buffer) + } + + guard let drawable = metalLayer.nextDrawable(), + let cmd = queue.makeCommandBuffer() else { return } + let pass = MTLRenderPassDescriptor() + pass.colorAttachments[0].texture = drawable.texture + pass.colorAttachments[0].loadAction = .clear + pass.colorAttachments[0].clearColor = MTLClearColor( + red: Double(frame.clear_r) / 255, + green: Double(frame.clear_g) / 255, + blue: Double(frame.clear_b) / 255, + alpha: Double(max(frame.clear_a, 255)) / 255 + ) + pass.colorAttachments[0].storeAction = .store + guard let enc = cmd.makeRenderCommandEncoder(descriptor: pass) else { return } + enc.setRenderPipelineState(pipeline) + if let upload { + bufferBusy[upload.index] = true + bufferIndex = (upload.index + 1) % 3 + enc.setVertexBuffer(upload.buffer, offset: 0, index: 0) + var viewport = SIMD2(Float(view.bounds.width), Float(view.bounds.height)) + enc.setVertexBytes(&viewport, length: MemoryLayout>.size, index: 1) + enc.setFragmentTexture(atlas.texture, index: 0) + enc.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6, instanceCount: instances.count) + let chosen = upload.index + cmd.addCompletedHandler { [weak self] _ in + DispatchQueue.main.async { + self?.bufferBusy[chosen] = false + } + } + } + enc.endEncoding() + cmd.present(drawable) + cmd.commit() + } + + private func freeInstanceBufferIndex() -> Int? { + for offset in 0..<3 { + let idx = (bufferIndex + offset) % 3 + if !bufferBusy[idx] { + return idx + } + } + return nil + } + + @discardableResult + private func createSession(cols: UInt32, rows: UInt32, settings: OmacySettings, art: String) -> Bool { + destroySession() + let bg = settings.backgroundRGBA + var cfg = OmacySessionConfig() + let artBytes = Array(art.utf8) + let effectBytes = Array(settings.effect.utf8) + let dir = OmacyStore.containerURL?.path + let dirBytes = dir.map { Array($0.utf8) } + var created = false + artBytes.withUnsafeBufferPointer { artPtr in + effectBytes.withUnsafeBufferPointer { effectPtr in + cfg.ascii = artPtr.baseAddress + cfg.ascii_len = artBytes.count + cfg.effect = effectPtr.baseAddress + cfg.effect_len = effectBytes.count + cfg.bg_r = bg.0 + cfg.bg_g = bg.1 + cfg.bg_b = bg.2 + cfg.bg_a = bg.3 + cfg.has_seed = 0 + if let dirBytes { + dirBytes.withUnsafeBufferPointer { dirPtr in + cfg.config_dir = dirPtr.baseAddress + cfg.config_dir_len = dirBytes.count + created = finishCreate(&cfg, cols: cols, rows: rows) + } + } else { + created = finishCreate(&cfg, cols: cols, rows: rows) + } + } + } + return created + } + + private func finishCreate(_ cfg: inout OmacySessionConfig, cols: UInt32, rows: UInt32) -> Bool { + var out: OpaquePointer? + let status = omacy_session_create(&cfg, cols, rows, &out) + if status == OMACY_OK, let out { + session = out + return true + } + log.error("session create failed: \(String(describing: status), privacy: .public)") + return false + } + + private func recoverDeadSession() { + let geometryCols = pendingCols ?? cols + let geometryRows = pendingRows ?? rows + if createSession(cols: geometryCols, rows: geometryRows, settings: lastSettings, art: lastArt) { + cols = geometryCols + rows = geometryRows + pendingCols = nil + pendingRows = nil + return + } + failToCanary() + } + + private func failToCanary() { + stopSessionAndLink() + stopped = true + metalLayer = nil + if let view { + installPlainLayer(on: view) + } + onEngineUnavailable?() + } + + private func stopSessionAndLink() { + destroySession() + displayLink?.invalidate() + displayLink = nil + lastTimestamp = nil + } + + private func destroySession() { + if let session { + omacy_session_destroy(session) + self.session = nil + } + } + + private func installPlainLayer(on view: NSView) { + let layer = CALayer() + layer.backgroundColor = NSColor.black.cgColor + layer.isOpaque = true + view.layer = layer + metalLayer = nil + } + + private func installMetal(on view: NSView, font: NSFont, cell: CGSize) -> Bool { + guard let device = MTLCreateSystemDefaultDevice() else { + log.error("no Metal device") + return false + } + self.device = device + queue = device.makeCommandQueue() + let scale = view.window?.backingScaleFactor ?? view.layer?.contentsScale ?? 2 + let container = CALayer() + container.backgroundColor = NSColor.black.cgColor + container.isOpaque = true + let layer = CAMetalLayer() + layer.device = device + layer.pixelFormat = .bgra8Unorm + layer.framebufferOnly = true + layer.isOpaque = true + layer.contentsScale = scale + layer.drawableSize = OmacyLayout.drawableSize(viewSize: view.bounds.size, scale: scale) + layer.frame = view.bounds + layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + view.layer = container + container.addSublayer(layer) + metalLayer = layer + pipeline = makePipeline(device: device) + atlas.rebuild(device: device, font: font, cell: cell, scale: scale) + return pipeline != nil + } + + private func makePipeline(device: MTLDevice) -> MTLRenderPipelineState? { + guard let library = device.makeDefaultLibrary() else { return nil } + return build(device: device, library: library) + } + + private func build(device: MTLDevice, library: MTLLibrary) -> MTLRenderPipelineState? { + let desc = MTLRenderPipelineDescriptor() + desc.vertexFunction = library.makeFunction(name: "omacy_vertex") + desc.fragmentFunction = library.makeFunction(name: "omacy_fragment") + desc.colorAttachments[0].pixelFormat = .bgra8Unorm + desc.colorAttachments[0].isBlendingEnabled = true + desc.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + desc.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + desc.colorAttachments[0].sourceAlphaBlendFactor = .one + desc.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + return try? device.makeRenderPipelineState(descriptor: desc) + } +} diff --git a/apps/Omacy/Shared/OmacyShaders.metal b/apps/Omacy/Shared/OmacyShaders.metal new file mode 100644 index 0000000..8269bdf --- /dev/null +++ b/apps/Omacy/Shared/OmacyShaders.metal @@ -0,0 +1,45 @@ +#include +using namespace metal; + +struct Instance { + float2 origin; + float2 size; + float2 uvOrigin; + float2 uvSize; + float4 color; +}; + +struct VertexOut { + float4 position [[position]]; + float2 uv; + float4 color; +}; + +vertex VertexOut omacy_vertex(uint vid [[vertex_id]], + uint iid [[instance_id]], + constant Instance *instances [[buffer(0)]], + constant float2 &viewport [[buffer(1)]]) { + const float2 corners[6] = { + float2(0.0, 0.0), float2(1.0, 0.0), float2(0.0, 1.0), + float2(1.0, 0.0), float2(1.0, 1.0), float2(0.0, 1.0) + }; + Instance inst = instances[iid]; + float2 pixel = inst.origin + corners[vid] * inst.size; + VertexOut out; + out.position = float4( + pixel.x / viewport.x * 2.0 - 1.0, + 1.0 - pixel.y / viewport.y * 2.0, + 0.0, + 1.0 + ); + out.uv = inst.uvOrigin + corners[vid] * inst.uvSize; + out.color = inst.color; + return out; +} + +fragment float4 omacy_fragment(VertexOut in [[stage_in]], + texture2d atlas [[texture(0)]]) { + constexpr sampler s(address::clamp_to_edge, filter::linear); + float coverage = atlas.sample(s, in.uv).a; + return float4(in.color.rgb, in.color.a * coverage); +} diff --git a/apps/Omacy/Shared/OmacyStore.swift b/apps/Omacy/Shared/OmacyStore.swift new file mode 100644 index 0000000..efbefc9 --- /dev/null +++ b/apps/Omacy/Shared/OmacyStore.swift @@ -0,0 +1,136 @@ +import Foundation +import AppKit + +struct OmacySettings: Equatable { + var effect: String = "random" + var background: String = "#000000" + var fontSize: Double = 18 + var asciiMode: String = "braille" + var threshold: Int = 50 + var invert: Bool = false + + var backgroundRGBA: (UInt8, UInt8, UInt8, UInt8) { + let hex = background.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard hex.count == 6, let n = UInt32(hex, radix: 16) else { + return (0, 0, 0, 255) + } + return (UInt8((n >> 16) & 0xFF), UInt8((n >> 8) & 0xFF), UInt8(n & 0xFF), 255) + } + + var asciiModeCode: UInt32 { + asciiMode == "block" ? 1 : 0 + } +} + +extension Notification.Name { + static let omacyConfigDidChange = Notification.Name("omacy.configDidChange") +} + +enum OmacyStore { + static let appGroup = "group.be.zenjoy.omacy" + static let forceCanaryKey = "omacy.forceCanary" + + private static var lastGoodSettings = OmacySettings() + private static var lastGoodArt: String? + static private(set) var lastLoadError: String? + + static var containerURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) + } + + static var settingsURL: URL? { containerURL?.appendingPathComponent("settings.json") } + static var artURL: URL? { containerURL?.appendingPathComponent("screensaver.txt") } + + static var bundledArt: String { + if let url = Bundle.main.url(forResource: "screensaver", withExtension: "txt", subdirectory: nil) + ?? Bundle.main.url(forResource: "screensaver", withExtension: "txt"), + let text = try? String(contentsOf: url, encoding: .utf8) { + return text + } + return "OMACY" + } + + static var bundledFontURL: URL? { + Bundle.main.url(forResource: "FairfaxHD", withExtension: "ttf") + ?? Bundle.main.url(forResource: "Fairfax", withExtension: "ttf") + } + + static func loadSettings() -> OmacySettings { + guard let url = settingsURL else { return lastGoodSettings } + guard FileManager.default.fileExists(atPath: url.path) else { return lastGoodSettings } + do { + let data = try Data(contentsOf: url) + guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + lastLoadError = "settings.json is invalid; keeping last-known-good" + return lastGoodSettings + } + var s = OmacySettings() + if let effect = obj["effect"] as? String { s.effect = effect } + if let background = obj["background"] as? String { s.background = background } + if let fontSize = obj["fontSize"] as? Double { s.fontSize = fontSize } + if let asciiMode = obj["asciiMode"] as? String { s.asciiMode = asciiMode } + if let threshold = obj["threshold"] as? Int { s.threshold = threshold } + if let invert = obj["invert"] as? Bool { s.invert = invert } + lastGoodSettings = s + lastLoadError = nil + return s + } catch { + lastLoadError = "Could not read settings.json; keeping last-known-good" + return lastGoodSettings + } + } + + static func loadArt() -> String { + if let url = artURL { + if let text = try? String(contentsOf: url, encoding: .utf8), !text.isEmpty { + lastGoodArt = text + return text + } + } + if let lastGoodArt { return lastGoodArt } + return bundledArt + } + + static func save(settings: OmacySettings, art: String) throws { + guard let dir = containerURL else { + throw NSError(domain: "Omacy", code: 1, userInfo: [NSLocalizedDescriptionKey: "App Group container missing"]) + } + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let payload: [String: Any] = [ + "effect": settings.effect, + "background": settings.background, + "fontSize": settings.fontSize, + "asciiMode": settings.asciiMode, + "threshold": settings.threshold, + "invert": settings.invert + ] + let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) + try atomicWrite(data, to: settingsURL!) + try atomicWrite(Data(art.utf8), to: artURL!) + lastGoodSettings = settings + lastGoodArt = art + lastLoadError = nil + NotificationCenter.default.post(name: .omacyConfigDidChange, object: nil) + } + + static func restoreDefaultArt() throws { + try save(settings: loadSettings(), art: bundledArt) + } + + static var forceCanary: Bool { + UserDefaults.standard.bool(forKey: forceCanaryKey) + } + + private static func atomicWrite(_ data: Data, to url: URL) throws { + let tmp = url.deletingLastPathComponent().appendingPathComponent(".\(url.lastPathComponent).tmp") + if FileManager.default.fileExists(atPath: tmp.path) { + try FileManager.default.removeItem(at: tmp) + } + try data.write(to: tmp) + if FileManager.default.fileExists(atPath: url.path) { + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + } else { + try FileManager.default.moveItem(at: tmp, to: url) + } + } +} diff --git a/apps/Omacy/scripts/build-engine.sh b/apps/Omacy/scripts/build-engine.sh new file mode 100755 index 0000000..9eefa17 --- /dev/null +++ b/apps/Omacy/scripts/build-engine.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Xcode run script: build libomacy_engine.a for the active Mac architecture. +# Xcode script phases reset PATH to Apple's toolchain dirs, so rustup/cargo +# from the developer machine or GitHub Actions must be put back explicitly. +set -euo pipefail +CARGO_BIN="${CARGO_HOME:-$HOME/.cargo}/bin" +export PATH="${CARGO_BIN}:/opt/homebrew/bin:/usr/local/bin:${PATH}" +if [ -f "${HOME}/.cargo/env" ]; then + # shellcheck source=/dev/null + . "${HOME}/.cargo/env" +fi +if ! command -v cargo >/dev/null 2>&1; then + echo "error: cargo not found on PATH=${PATH}" >&2 + echo "error: install Rust 1.88.0 via rustup, then rebuild" >&2 + exit 1 +fi +ROOT="${SRCROOT}/../.." +cd "$ROOT" +ARCHS_EFFECTIVE="${ARCHS:-arm64}" +case "$ARCHS_EFFECTIVE" in + *arm64*) RUST_TARGET="aarch64-apple-darwin" ;; + *x86_64*) RUST_TARGET="x86_64-apple-darwin" ;; + *) RUST_TARGET="aarch64-apple-darwin" ;; +esac +export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}" +if command -v rustup >/dev/null 2>&1; then + rustup target add "$RUST_TARGET" >/dev/null 2>&1 || true +fi +cargo build --release --target "$RUST_TARGET" -p omacy-engine +mkdir -p "${BUILT_PRODUCTS_DIR}" +cp -f "target/${RUST_TARGET}/release/libomacy_engine.a" "${BUILT_PRODUCTS_DIR}/libomacy_engine.a" diff --git a/assets/branding/screensaver.txt b/assets/branding/screensaver.txt new file mode 100644 index 0000000..47433aa --- /dev/null +++ b/assets/branding/screensaver.txt @@ -0,0 +1,10 @@ + ▄▄▄ + ▄█████▄ ▄███████████▄ ▄███████ ▄███████ ▄███████ ▄█ █▄ ▄█ █▄ +███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ +███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▀ ███ ███ ███ ███ +███ ███ ███ ███ ███ ▄███▄▄▄███ ▄███▄▄▄██▀ ███ ▄███▄▄▄███▄ ███▄▄▄███ +███ ███ ███ ███ ███ ▀███▀▀▀███ ▀███▀▀▀▀ ███ ▀▀███▀▀▀███ ▀▀▀▀▀▀███ +███ ███ ███ ███ ███ ███ ███ ██████████ ███ █▄ ███ ███ ▄██ ███ +███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ + ▀█████▀ ▀█ ███ █▀ ███ █▀ ███ ███ ███████▀ ███ █▀ ▀█████▀ + ███ █▀ diff --git a/assets/fixtures/asymmetric.txt b/assets/fixtures/asymmetric.txt new file mode 100644 index 0000000..e14d4a9 --- /dev/null +++ b/assets/fixtures/asymmetric.txt @@ -0,0 +1,3 @@ +X + + Y \ No newline at end of file diff --git a/assets/fixtures/conversion/alpha-logo.braille.t50.inv0.trim1.txt b/assets/fixtures/conversion/alpha-logo.braille.t50.inv0.trim1.txt new file mode 100644 index 0000000..a768787 --- /dev/null +++ b/assets/fixtures/conversion/alpha-logo.braille.t50.inv0.trim1.txt @@ -0,0 +1,4 @@ +⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿ \ No newline at end of file diff --git a/assets/fixtures/conversion/alpha-logo.png b/assets/fixtures/conversion/alpha-logo.png new file mode 100644 index 0000000..a56f178 Binary files /dev/null and b/assets/fixtures/conversion/alpha-logo.png differ diff --git a/assets/fixtures/conversion/logo.svg b/assets/fixtures/conversion/logo.svg new file mode 100644 index 0000000..48d95ec --- /dev/null +++ b/assets/fixtures/conversion/logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/fixtures/conversion/logo.svg.braille.t50.inv0.trim1.txt b/assets/fixtures/conversion/logo.svg.braille.t50.inv0.trim1.txt new file mode 100644 index 0000000..a768787 --- /dev/null +++ b/assets/fixtures/conversion/logo.svg.braille.t50.inv0.trim1.txt @@ -0,0 +1,4 @@ +⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿ \ No newline at end of file diff --git a/assets/fixtures/conversion/manifest.md b/assets/fixtures/conversion/manifest.md new file mode 100644 index 0000000..4d79787 --- /dev/null +++ b/assets/fixtures/conversion/manifest.md @@ -0,0 +1,21 @@ +# Conversion goldens + +Each row is one accepted `(input, mode, threshold, invert, trim)` tuple. +Omacy’s ascii module must emit the committed `.txt` byte-for-byte. + +| Input | Mode | threshold | invert | trim | Expected | +|---|---|---|---|---|---| +| `solid-black-on-white.png` | braille | 50 | 0 | 1 | `solid-black-on-white.braille.t50.inv0.trim1.txt` | +| `solid-black-on-white.png` | block | 50 | 0 | 1 | `solid-black-on-white.block.t50.inv0.trim1.txt` | +| `solid-black-on-white.png` | braille | 50 | 1 | 1 | `solid-black-on-white.braille.t50.inv1.trim1.txt` | +| `solid-black-on-white.png` | block | 80 | 0 | 0 | `solid-black-on-white.block.t80.inv0.trim0.txt` | +| `alpha-logo.png` | braille | 50 | 0 | 1 | `alpha-logo.braille.t50.inv0.trim1.txt` | +| `logo.svg` | braille | 50 | 0 | 1 | `logo.svg.braille.t50.inv0.trim1.txt` | + +Regeneration (manual, reviewed): + +``` +cargo run -p omacy-engine --example write_conversion_fixtures +``` + +Do not replay live ImageMagick in CI. The Omarchy converter SHA is recorded in [parity.md](../../../docs/parity.md). diff --git a/assets/fixtures/conversion/solid-black-on-white.block.t50.inv0.trim1.txt b/assets/fixtures/conversion/solid-black-on-white.block.t50.inv0.trim1.txt new file mode 100644 index 0000000..a2ccd30 --- /dev/null +++ b/assets/fixtures/conversion/solid-black-on-white.block.t50.inv0.trim1.txt @@ -0,0 +1,4 @@ +████████ +████████ +████████ +████████ \ No newline at end of file diff --git a/assets/fixtures/conversion/solid-black-on-white.block.t80.inv0.trim0.txt b/assets/fixtures/conversion/solid-black-on-white.block.t80.inv0.trim0.txt new file mode 100644 index 0000000..ddd38d5 --- /dev/null +++ b/assets/fixtures/conversion/solid-black-on-white.block.t80.inv0.trim0.txt @@ -0,0 +1,4 @@ + ████████ + ████████ + ████████ + ████████ \ No newline at end of file diff --git a/assets/fixtures/conversion/solid-black-on-white.braille.t50.inv0.trim1.txt b/assets/fixtures/conversion/solid-black-on-white.braille.t50.inv0.trim1.txt new file mode 100644 index 0000000..a768787 --- /dev/null +++ b/assets/fixtures/conversion/solid-black-on-white.braille.t50.inv0.trim1.txt @@ -0,0 +1,4 @@ +⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿ \ No newline at end of file diff --git a/assets/fixtures/conversion/solid-black-on-white.braille.t50.inv1.trim1.txt b/assets/fixtures/conversion/solid-black-on-white.braille.t50.inv1.trim1.txt new file mode 100644 index 0000000..8199d25 --- /dev/null +++ b/assets/fixtures/conversion/solid-black-on-white.braille.t50.inv1.trim1.txt @@ -0,0 +1,4 @@ +⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿ ⣿⣿ +⣿⣿ ⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿ \ No newline at end of file diff --git a/assets/fixtures/conversion/solid-black-on-white.png b/assets/fixtures/conversion/solid-black-on-white.png new file mode 100644 index 0000000..3b16944 Binary files /dev/null and b/assets/fixtures/conversion/solid-black-on-white.png differ diff --git a/assets/fonts/FairfaxHD.ttf b/assets/fonts/FairfaxHD.ttf new file mode 100644 index 0000000..844fc92 Binary files /dev/null and b/assets/fonts/FairfaxHD.ttf differ diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 0000000..a694297 --- /dev/null +++ b/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2017-2020 Kreative Software. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/omacy-engine/Cargo.toml b/crates/omacy-engine/Cargo.toml new file mode 100644 index 0000000..1e3cb56 --- /dev/null +++ b/crates/omacy-engine/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "omacy-engine" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[lib] +name = "omacy_engine" +crate-type = ["rlib", "staticlib"] + +[dependencies] +libc = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +image = { version = "0.25", default-features = false, features = ["png"] } +resvg = "0.45" +tiny-skia = "0.11" +ttfx = { path = "../../vendor/ttfx" } + +[dev-dependencies] +image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/omacy-engine/examples/write_conversion_fixtures.rs b/crates/omacy-engine/examples/write_conversion_fixtures.rs new file mode 100644 index 0000000..047d3e3 --- /dev/null +++ b/crates/omacy-engine/examples/write_conversion_fixtures.rs @@ -0,0 +1,117 @@ +//! Generate committed conversion goldens. Run from repo root: +//! `cargo run -p omacy-engine --example write_conversion_fixtures` + +use omacy_engine::abi::{OmacyAsciiConfig, OMACY_ASCII_BLOCK, OMACY_ASCII_BRAILLE}; +use omacy_engine::ascii::ascii_from_bytes; +use std::fs; +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn solid_png(w: u32, h: u32, bg: [u8; 4], fg: [u8; 4]) -> Vec { + let mut img = image::RgbaImage::from_pixel(w, h, image::Rgba(bg)); + for y in h / 4..h * 3 / 4 { + for x in w / 4..w * 3 / 4 { + img.put_pixel(x, y, image::Rgba(fg)); + } + } + let mut buf = Vec::new(); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) + .unwrap(); + buf +} + +fn alpha_png(w: u32, h: u32) -> Vec { + let mut img = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 0])); + for y in h / 4..h * 3 / 4 { + for x in w / 4..w * 3 / 4 { + img.put_pixel(x, y, image::Rgba([255, 255, 255, 255])); + } + } + let mut buf = Vec::new(); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) + .unwrap(); + buf +} + +fn cfg(mode: u32, width: u32, height: u32, threshold: u8, invert: u8, trim: u8) -> OmacyAsciiConfig { + OmacyAsciiConfig { + mode, + width, + height, + threshold, + invert, + trim, + _pad: 0, + } +} + +fn write_pair(dir: &std::path::Path, stem: &str, bytes: &[u8], c: OmacyAsciiConfig, label: &str) { + let text = ascii_from_bytes(c, bytes).expect(label); + fs::write(dir.join(format!("{stem}.{label}.txt")), text).unwrap(); +} + +fn main() { + let dir = repo_root().join("assets/fixtures/conversion"); + fs::create_dir_all(&dir).unwrap(); + + let opaque = solid_png(32, 32, [255, 255, 255, 255], [0, 0, 0, 255]); + fs::write(dir.join("solid-black-on-white.png"), &opaque).unwrap(); + + let alpha = alpha_png(32, 32); + fs::write(dir.join("alpha-logo.png"), &alpha).unwrap(); + + let svg = r#" + +"#; + fs::write(dir.join("logo.svg"), svg).unwrap(); + + write_pair( + &dir, + "solid-black-on-white", + &opaque, + cfg(OMACY_ASCII_BRAILLE, 8, 4, 50, 0, 1), + "braille.t50.inv0.trim1", + ); + write_pair( + &dir, + "solid-black-on-white", + &opaque, + cfg(OMACY_ASCII_BLOCK, 8, 4, 50, 0, 1), + "block.t50.inv0.trim1", + ); + write_pair( + &dir, + "solid-black-on-white", + &opaque, + cfg(OMACY_ASCII_BRAILLE, 8, 4, 50, 1, 1), + "braille.t50.inv1.trim1", + ); + write_pair( + &dir, + "solid-black-on-white", + &opaque, + cfg(OMACY_ASCII_BLOCK, 16, 8, 80, 0, 0), + "block.t80.inv0.trim0", + ); + write_pair( + &dir, + "alpha-logo", + &alpha, + cfg(OMACY_ASCII_BRAILLE, 8, 4, 50, 0, 1), + "braille.t50.inv0.trim1", + ); + write_pair( + &dir, + "logo.svg", + svg.as_bytes(), + cfg(OMACY_ASCII_BRAILLE, 8, 4, 50, 0, 1), + "braille.t50.inv0.trim1", + ); + + println!("wrote {}", dir.display()); +} diff --git a/crates/omacy-engine/include/omacy.h b/crates/omacy-engine/include/omacy.h new file mode 100644 index 0000000..c43d121 --- /dev/null +++ b/crates/omacy-engine/include/omacy.h @@ -0,0 +1,111 @@ +#ifndef OMACY_H +#define OMACY_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + OMACY_OK = 0, + OMACY_ERR_NULL = 1, + OMACY_ERR_INVALID_ARG = 2, + OMACY_ERR_LIMIT = 3, + OMACY_ERR_ENGINE = 4, + OMACY_ERR_PANIC = 5, + OMACY_ERR_DEAD = 6, + OMACY_ERR_WRONG_THREAD = 7 +} omacy_status; + +#define OMACY_CELL_HAS_BACKGROUND 1 +#define OMACY_CELL_HAS_GLYPH 2 + +typedef struct { + uint32_t glyph; + uint8_t fg_r, fg_g, fg_b, fg_a; + uint8_t bg_r, bg_g, bg_b, bg_a; + uint8_t flags; + uint8_t occupancy; + uint8_t _pad[2]; +} OmacyCell; + +typedef struct { + uint32_t cols; + uint32_t rows; + uint8_t clear_r, clear_g, clear_b, clear_a; + uint32_t _pad; + const OmacyCell *cells; +} OmacyFrame; + +typedef struct { + OmacyFrame frame; + uint8_t needs_begin_next; + uint8_t _pad[3]; +} OmacyStepResult; + +typedef struct { + const uint8_t *config_dir; + size_t config_dir_len; + const uint8_t *ascii; + size_t ascii_len; + const uint8_t *effect; + size_t effect_len; + uint8_t bg_r, bg_g, bg_b, bg_a; + uint8_t has_seed; + uint8_t _pad[3]; + uint64_t seed; +} OmacySessionConfig; + +typedef struct { + const uint8_t *ascii; + size_t ascii_len; + const uint8_t *effect; + size_t effect_len; + uint8_t bg_r, bg_g, bg_b, bg_a; + uint8_t _pad[3]; +} OmacyPendingConfig; + +#define OMACY_ASCII_BRAILLE 0u +#define OMACY_ASCII_BLOCK 1u + +typedef struct { + uint32_t mode; + uint32_t width; + uint32_t height; + uint8_t threshold; + uint8_t invert; + uint8_t trim; + uint8_t _pad; +} OmacyAsciiConfig; + +typedef struct OmacySession OmacySession; +typedef struct OmacyText OmacyText; + +omacy_status omacy_session_create(const OmacySessionConfig *cfg, + uint32_t cols, uint32_t rows, + OmacySession **out); +omacy_status omacy_session_resize(OmacySession *s, uint32_t cols, uint32_t rows); +omacy_status omacy_session_step(OmacySession *s, double elapsed_seconds, + OmacyStepResult *out); +omacy_status omacy_session_set_pending_config(OmacySession *s, + const OmacyPendingConfig *cfg); +omacy_status omacy_session_begin_next(OmacySession *s); +omacy_status omacy_session_generation(const OmacySession *s, uint64_t *out); +omacy_status omacy_session_error_message(const OmacySession *s, char *buf, size_t buf_len); +void omacy_session_destroy(OmacySession *s); +const char *omacy_status_string(omacy_status status); + +omacy_status omacy_ascii_from_bytes(const OmacyAsciiConfig *cfg, + const uint8_t *bytes, size_t len, + OmacyText **out); +void omacy_text_free(OmacyText *t); +const char *omacy_text_utf8(const OmacyText *t); +size_t omacy_text_len(const OmacyText *t); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/crates/omacy-engine/src/abi.rs b/crates/omacy-engine/src/abi.rs new file mode 100644 index 0000000..f79cb3f --- /dev/null +++ b/crates/omacy-engine/src/abi.rs @@ -0,0 +1,155 @@ +use std::os::raw::c_char; + +use ttfx::engine::PackedCell; + +pub type OmacyCell = PackedCell; + +pub const OMACY_CELL_HAS_BACKGROUND: u8 = ttfx::engine::CELL_HAS_BACKGROUND; +pub const OMACY_CELL_HAS_GLYPH: u8 = ttfx::engine::CELL_HAS_GLYPH; + +pub const OMACY_ASCII_BRAILLE: u32 = 0; +pub const OMACY_ASCII_BLOCK: u32 = 1; + +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct OmacyFrame { + pub cols: u32, + pub rows: u32, + pub clear_r: u8, + pub clear_g: u8, + pub clear_b: u8, + pub clear_a: u8, + pub _pad: u32, + pub cells: *const OmacyCell, +} + +impl OmacyFrame { + pub fn zeroed() -> Self { + Self { + cols: 0, + rows: 0, + clear_r: 0, + clear_g: 0, + clear_b: 0, + clear_a: 0, + _pad: 0, + cells: std::ptr::null(), + } + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct OmacyStepResult { + pub frame: OmacyFrame, + pub needs_begin_next: u8, + pub _pad: [u8; 3], +} + +impl OmacyStepResult { + pub fn zeroed() -> Self { + Self { + frame: OmacyFrame::zeroed(), + needs_begin_next: 0, + _pad: [0; 3], + } + } +} + +#[repr(C)] +pub struct OmacySessionConfig { + pub config_dir: *const u8, + pub config_dir_len: usize, + pub ascii: *const u8, + pub ascii_len: usize, + pub effect: *const u8, + pub effect_len: usize, + pub bg_r: u8, + pub bg_g: u8, + pub bg_b: u8, + pub bg_a: u8, + pub has_seed: u8, + pub _pad: [u8; 3], + pub seed: u64, +} + +#[repr(C)] +pub struct OmacyPendingConfig { + pub ascii: *const u8, + pub ascii_len: usize, + pub effect: *const u8, + pub effect_len: usize, + pub bg_r: u8, + pub bg_g: u8, + pub bg_b: u8, + pub bg_a: u8, + pub _pad: [u8; 3], +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct OmacyAsciiConfig { + pub mode: u32, + pub width: u32, + pub height: u32, + pub threshold: u8, + pub invert: u8, + pub trim: u8, + pub _pad: u8, +} + +#[repr(C)] +pub struct OmacyText { + pub bytes: Vec, +} + +pub unsafe fn slice_ptr_len<'a>( + ptr: *const u8, + len: usize, +) -> Result, super::status::EngineError> { + if ptr.is_null() { + if len == 0 { + Ok(None) + } else { + Err(super::status::EngineError::InvalidArg( + "null pointer with nonzero length".into(), + )) + } + } else { + Ok(Some(std::slice::from_raw_parts(ptr, len))) + } +} + +pub fn c_str_static(s: &'static str) -> *const c_char { + s.as_ptr() as *const c_char +} + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::{align_of, offset_of, size_of}; + + #[test] + fn cell_layout() { + assert_eq!(size_of::(), 16); + assert_eq!(align_of::(), 4); + assert_eq!(offset_of!(OmacyCell, glyph), 0); + assert_eq!(offset_of!(OmacyCell, occupancy), 13); + } + + #[test] + fn ascii_config_layout() { + assert_eq!(size_of::(), 16); + assert_eq!(align_of::(), 4); + assert_eq!(offset_of!(OmacyAsciiConfig, mode), 0); + assert_eq!(offset_of!(OmacyAsciiConfig, threshold), 12); + } + + #[test] + fn occupancy_constants_match_ttfx() { + assert_eq!(OMACY_CELL_HAS_BACKGROUND, 1); + assert_eq!(OMACY_CELL_HAS_GLYPH, 2); + assert_eq!(OMACY_CELL_HAS_BACKGROUND, ttfx::engine::CELL_HAS_BACKGROUND); + assert_eq!(OMACY_CELL_HAS_GLYPH, ttfx::engine::CELL_HAS_GLYPH); + } +} diff --git a/crates/omacy-engine/src/ascii.rs b/crates/omacy-engine/src/ascii.rs new file mode 100644 index 0000000..b33b70c --- /dev/null +++ b/crates/omacy-engine/src/ascii.rs @@ -0,0 +1,306 @@ +use std::io::Cursor; + +use image::{DynamicImage, GenericImageView, ImageFormat, RgbaImage}; +use resvg::{tiny_skia, usvg}; + +use crate::abi::{OmacyAsciiConfig, OMACY_ASCII_BLOCK, OMACY_ASCII_BRAILLE}; +use crate::limits; +use crate::status::EngineError; + +pub fn validate_ascii_config(cfg: OmacyAsciiConfig) -> Result { + if cfg.mode != OMACY_ASCII_BRAILLE && cfg.mode != OMACY_ASCII_BLOCK { + return Err(EngineError::InvalidArg("ascii mode must be braille or block".into())); + } + if cfg.width < 1 || cfg.width > limits::CONV_COLUMNS { + return Err(EngineError::Limit("conversion width out of range".into())); + } + if cfg.height < 1 || cfg.height > limits::CONV_ROWS { + return Err(EngineError::Limit("conversion height out of range".into())); + } + let cells = cfg + .width + .checked_mul(cfg.height) + .ok_or_else(|| EngineError::Limit("conversion cell count overflow".into()))?; + if cells > limits::CONV_CELLS { + return Err(EngineError::Limit("conversion cell count exceeds cap".into())); + } + if cfg.threshold > 100 { + return Err(EngineError::InvalidArg("threshold must be 0..=100".into())); + } + if cfg.invert > 1 || cfg.trim > 1 { + return Err(EngineError::InvalidArg("invert and trim must be 0 or 1".into())); + } + Ok(cfg) +} + +pub fn ascii_from_bytes(cfg: OmacyAsciiConfig, bytes: &[u8]) -> Result { + let cfg = validate_ascii_config(cfg)?; + if bytes.len() > limits::PNG_BYTES && looks_like_png(bytes) { + return Err(EngineError::Limit("PNG exceeds 8 MiB".into())); + } + if !looks_like_png(bytes) && bytes.len() > limits::SVG_BYTES { + return Err(EngineError::Limit("SVG exceeds 2 MiB".into())); + } + let image = if looks_like_png(bytes) { + decode_png(bytes)? + } else { + decode_svg(bytes)? + }; + raster_to_text(&image, cfg) +} + +fn looks_like_png(bytes: &[u8]) -> bool { + bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) +} + +fn decode_png(bytes: &[u8]) -> Result { + if bytes.len() > limits::PNG_BYTES { + return Err(EngineError::Limit("PNG exceeds 8 MiB".into())); + } + let img = image::load(Cursor::new(bytes), ImageFormat::Png) + .map_err(|e| EngineError::Engine(e.to_string()))?; + let (w, h) = img.dimensions(); + let pixels = w + .checked_mul(h) + .ok_or_else(|| EngineError::Limit("decoded pixel overflow".into()))?; + if pixels > limits::DECODED_PIXELS { + return Err(EngineError::Limit("decoded pixels exceed cap".into())); + } + Ok(img.to_rgba8()) +} + +fn decode_svg(bytes: &[u8]) -> Result { + if bytes.len() > limits::SVG_BYTES { + return Err(EngineError::Limit("SVG exceeds 2 MiB".into())); + } + let mut options = usvg::Options { + resources_dir: None, + image_href_resolver: usvg::ImageHrefResolver { + resolve_data: Box::new(|_, _, _| None), + resolve_string: Box::new(|_, _| None), + }, + ..usvg::Options::default() + }; + options.fontdb_mut().set_serif_family(""); + options.fontdb_mut().set_sans_serif_family(""); + options.fontdb_mut().set_cursive_family(""); + options.fontdb_mut().set_fantasy_family(""); + options.fontdb_mut().set_monospace_family(""); + let tree = usvg::Tree::from_data(bytes, &options) + .map_err(|e| EngineError::Engine(e.to_string()))?; + if count_svg_nodes(&tree.root()) > limits::SVG_ELEMENTS { + return Err(EngineError::Limit("SVG element count exceeds cap".into())); + } + let size = tree.size(); + let width = size.width().ceil().max(1.0) as u32; + let height = size.height().ceil().max(1.0) as u32; + let pixels = width + .checked_mul(height) + .ok_or_else(|| EngineError::Limit("decoded pixel overflow".into()))?; + if pixels > limits::DECODED_PIXELS { + return Err(EngineError::Limit("decoded pixels exceed cap".into())); + } + let mut pixmap = tiny_skia::Pixmap::new(width, height) + .ok_or_else(|| EngineError::Engine("failed to allocate SVG pixmap".into()))?; + resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); + let mut img = RgbaImage::new(width, height); + for (i, px) in pixmap.pixels().iter().enumerate() { + let x = (i as u32) % width; + let y = (i as u32) / width; + let rgba = px.demultiply(); + img.put_pixel(x, y, image::Rgba([rgba.red(), rgba.green(), rgba.blue(), rgba.alpha()])); + } + Ok(img) +} + +fn count_svg_nodes(root: &usvg::Group) -> u32 { + let mut n = 1u32; + for child in root.children() { + n = n.saturating_add(1); + if let usvg::Node::Group(g) = child { + n = n.saturating_add(count_svg_nodes(g).saturating_sub(1)); + } + } + n +} + +fn raster_to_text(img: &RgbaImage, cfg: OmacyAsciiConfig) -> Result { + let (pixel_w, pixel_h) = if cfg.mode == OMACY_ASCII_BRAILLE { + (cfg.width.saturating_mul(2), cfg.height.saturating_mul(4)) + } else { + (cfg.width, cfg.height.saturating_mul(2)) + }; + + let mut luma = to_luma(img, cfg.invert != 0); + if cfg.trim != 0 { + luma = trim_black(&add_black_border(&luma, 1)); + } + let resized = resize_nearest_then_lanczos(&luma, pixel_w.max(1), pixel_h.max(1)); + let threshold = (cfg.threshold as f32 / 100.0 * 255.0).round() as u8; + let bits = threshold_bits(&resized, threshold); + let packed = if cfg.mode == OMACY_ASCII_BRAILLE { + pack_braille(&bits) + } else { + pack_block(&bits) + }; + Ok(trim_output_lines(&packed)) +} + +fn to_luma(img: &RgbaImage, invert: bool) -> image::GrayImage { + let (w, h) = img.dimensions(); + let mut min_a = 255u8; + let mut max_a = 0u8; + for p in img.pixels() { + min_a = min_a.min(p.0[3]); + max_a = max_a.max(p.0[3]); + } + let use_alpha = (min_a as f32 / 255.0) < 0.999 && max_a > min_a; + let mut out = image::GrayImage::new(w, h); + for (x, y, p) in img.enumerate_pixels() { + let v = if use_alpha { + p.0[3] + } else { + let a = p.0[3] as u16; + let r = p.0[0] as u16; + let g = p.0[1] as u16; + let b = p.0[2] as u16; + let gray = (r * 30 + g * 59 + b * 11) / 100; + ((gray * a) / 255) as u8 + }; + let mut v = v; + if use_alpha { + if invert { + v = 255 - v; + } + } else if !invert { + v = 255 - v; + } + out.put_pixel(x, y, image::Luma([v])); + } + out +} + +fn trim_black(img: &image::GrayImage) -> image::GrayImage { + let (w, h) = img.dimensions(); + if w == 0 || h == 0 { + return img.clone(); + } + let mut min_x = w; + let mut min_y = h; + let mut max_x = 0u32; + let mut max_y = 0u32; + for (x, y, p) in img.enumerate_pixels() { + if p.0[0] > 0 { + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + } + if min_x > max_x { + return image::GrayImage::new(1, 1); + } + DynamicImage::ImageLuma8(img.clone()) + .crop_imm(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1) + .to_luma8() +} + +fn resize_nearest_then_lanczos(img: &image::GrayImage, w: u32, h: u32) -> image::GrayImage { + if img.width() == w && img.height() == h { + return img.clone(); + } + DynamicImage::ImageLuma8(img.clone()) + .resize(w, h, image::imageops::FilterType::Lanczos3) + .to_luma8() +} + +fn add_black_border(img: &image::GrayImage, px: u32) -> image::GrayImage { + let w = img.width() + px * 2; + let h = img.height() + px * 2; + let mut out = image::GrayImage::from_pixel(w, h, image::Luma([0])); + for (x, y, p) in img.enumerate_pixels() { + out.put_pixel(x + px, y + px, *p); + } + out +} + +fn threshold_bits(img: &image::GrayImage, threshold: u8) -> image::GrayImage { + let mut out = img.clone(); + for p in out.pixels_mut() { + // magick -threshold then -negate then PBM (black=1) is: above threshold → on. + p.0[0] = if p.0[0] > threshold { 255 } else { 0 }; + } + out +} + +fn pack_braille(img: &image::GrayImage) -> String { + let w = img.width(); + let h = img.height(); + let mut lines = Vec::new(); + let mut y = 0; + while y < h { + let mut line = String::new(); + let mut x = 0; + while x < w { + let mut code = 0u32; + const DOTS: [[u32; 4]; 2] = [[1, 2, 4, 64], [8, 16, 32, 128]]; + for dx in 0..2 { + for dy in 0..4 { + if y + dy < h && x + dx < w && img.get_pixel(x + dx, y + dy).0[0] > 0 { + code += DOTS[dx as usize][dy as usize]; + } + } + } + if code == 0 { + line.push(' '); + } else { + line.push(char::from_u32(0x2800 + code).unwrap_or(' ')); + } + x += 2; + } + lines.push(line); + y += 4; + } + lines.join("\n") +} + +fn pack_block(img: &image::GrayImage) -> String { + let w = img.width(); + let h = img.height(); + let mut lines = Vec::new(); + let mut y = 0; + while y < h { + let mut line = String::new(); + for x in 0..w { + let top = y < h && img.get_pixel(x, y).0[0] > 0; + let bot = y + 1 < h && img.get_pixel(x, y + 1).0[0] > 0; + line.push(match (top, bot) { + (true, true) => '█', + (true, false) => '▀', + (false, true) => '▄', + (false, false) => ' ', + }); + } + lines.push(line); + y += 2; + } + lines.join("\n") +} + +fn trim_output_lines(text: &str) -> String { + let mut lines: Vec = text + .lines() + .map(|l| l.trim_end().to_string()) + .collect(); + while lines.first().is_some_and(|l| l.is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|l| l.is_empty()) { + lines.pop(); + } + if lines.is_empty() { + "No logo pixels found".into() + } else { + lines.join("\n") + } +} diff --git a/crates/omacy-engine/src/content.rs b/crates/omacy-engine/src/content.rs new file mode 100644 index 0000000..84c36e0 --- /dev/null +++ b/crates/omacy-engine/src/content.rs @@ -0,0 +1,87 @@ +use crate::limits; +use crate::status::EngineError; +use ttfx::effects::EffectCommand; + +#[derive(Clone, Debug)] +pub struct Content { + pub art: String, + pub effect: String, + pub bg: [u8; 4], +} + +pub fn is_known_effect(name: &str) -> bool { + name == "random" || EffectCommand::NAMES.contains(&name) +} + +pub fn parse_utf8(bytes: &[u8], what: &str) -> Result { + std::str::from_utf8(bytes) + .map(|s| s.to_owned()) + .map_err(|_| EngineError::InvalidArg(format!("{what} is not UTF-8"))) +} + +pub fn validate_art(art: &str) -> Result<(), EngineError> { + if art.len() > limits::ASCII_BYTES { + return Err(EngineError::Limit("ASCII input exceeds 64 KiB".into())); + } + if art.bytes().any(|b| b == 0x1B) { + return Err(EngineError::InvalidArg("ASCII art must not contain ESC".into())); + } + let mut lines = 0usize; + let mut longest = 0usize; + for line in art.split('\n') { + lines += 1; + longest = longest.max(line.chars().count()); + if lines > limits::ASCII_LINES { + return Err(EngineError::Limit("ASCII line count exceeds 128".into())); + } + if longest > limits::ASCII_COLUMNS { + return Err(EngineError::Limit("ASCII column count exceeds 256".into())); + } + } + Ok(()) +} + +pub fn validate_effect(name: &str) -> Result<(), EngineError> { + if is_known_effect(name) { + Ok(()) + } else { + Err(EngineError::InvalidArg(format!("unknown effect '{name}'"))) + } +} + +pub fn parse_hex_color(s: &str) -> Result<[u8; 4], EngineError> { + let t = s.trim().trim_start_matches('#'); + let err = || EngineError::InvalidArg(format!("invalid background color '{s}'")); + match t.len() { + 6 => { + let n = u32::from_str_radix(t, 16).map_err(|_| err())?; + Ok([ + ((n >> 16) & 0xFF) as u8, + ((n >> 8) & 0xFF) as u8, + (n & 0xFF) as u8, + 255, + ]) + } + 8 => { + let n = u32::from_str_radix(t, 16).map_err(|_| err())?; + Ok([ + ((n >> 24) & 0xFF) as u8, + ((n >> 16) & 0xFF) as u8, + ((n >> 8) & 0xFF) as u8, + (n & 0xFF) as u8, + ]) + } + _ => Err(err()), + } +} + +impl Content { + pub fn from_parts(art: String, effect: String, bg: [u8; 4]) -> Result { + validate_art(&art)?; + validate_effect(&effect)?; + if art.is_empty() { + return Err(EngineError::InvalidArg("ASCII art is empty".into())); + } + Ok(Content { art, effect, bg }) + } +} diff --git a/crates/omacy-engine/src/ffi.rs b/crates/omacy-engine/src/ffi.rs new file mode 100644 index 0000000..cb408b6 --- /dev/null +++ b/crates/omacy-engine/src/ffi.rs @@ -0,0 +1,534 @@ +use std::cell::RefCell; +use std::os::raw::c_char; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::PathBuf; +use std::ptr; +use std::slice; + +use crate::abi::{ + slice_ptr_len, OmacyAsciiConfig, OmacyPendingConfig, OmacySessionConfig, OmacyStepResult, + OmacyText, +}; +use crate::ascii; +use crate::session::{self, ClockKind, Session}; +use crate::status::{EngineError, OmacyStatus}; + +thread_local! { + static LAST_ERROR: RefCell = const { RefCell::new(String::new()) }; +} + +fn set_tls_error(msg: impl Into) { + LAST_ERROR.with(|slot| *slot.borrow_mut() = msg.into()); +} + +fn write_error_buf(src: &str, buf: *mut c_char, buf_len: usize) -> OmacyStatus { + if buf.is_null() { + return OmacyStatus::Null; + } + if buf_len < 1 { + return OmacyStatus::InvalidArg; + } + let bytes = src.as_bytes(); + let n = bytes.len().min(buf_len - 1); + unsafe { + ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, n); + *buf.add(n) = 0; + } + OmacyStatus::Ok +} + +pub fn is_main_thread() -> bool { + #[cfg(target_os = "macos")] + unsafe { + libc::pthread_main_np() != 0 + } + #[cfg(target_os = "linux")] + unsafe { + libc::getpid() as i64 == libc::syscall(libc::SYS_gettid) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + true + } +} + +fn require_main() -> Result<(), EngineError> { + if is_main_thread() { + Ok(()) + } else { + Err(EngineError::WrongThread) + } +} + +fn zero_step(out: *mut OmacyStepResult) { + if !out.is_null() { + unsafe { *out = OmacyStepResult::zeroed() }; + } +} + +#[no_mangle] +pub unsafe extern "C" fn omacy_session_create( + cfg: *const OmacySessionConfig, + cols: u32, + rows: u32, + out: *mut *mut Session, +) -> OmacyStatus { + let result = catch_unwind(AssertUnwindSafe(|| { + if out.is_null() { + return OmacyStatus::Null; + } + unsafe { *out = ptr::null_mut() }; + if cfg.is_null() { + set_tls_error("null config"); + return OmacyStatus::Null; + } + if let Err(e) = require_main() { + set_tls_error(e.message()); + return e.status(); + } + let cfg = unsafe { &*cfg }; + if cfg.has_seed > 1 { + set_tls_error("has_seed must be 0 or 1"); + return OmacyStatus::InvalidArg; + } + let config_dir = match unsafe { slice_ptr_len(cfg.config_dir, cfg.config_dir_len) } { + Ok(Some(b)) => match std::str::from_utf8(b) { + Ok(s) if !s.is_empty() => Some(PathBuf::from(s)), + Ok(_) => None, + Err(_) => { + set_tls_error("config_dir is not UTF-8"); + return OmacyStatus::InvalidArg; + } + }, + Ok(None) => None, + Err(e) => { + set_tls_error(e.message()); + return e.status(); + } + }; + let ascii = match unsafe { slice_ptr_len(cfg.ascii, cfg.ascii_len) } { + Ok(v) => v, + Err(e) => { + set_tls_error(e.message()); + return e.status(); + } + }; + let effect = match unsafe { slice_ptr_len(cfg.effect, cfg.effect_len) } { + Ok(v) => v, + Err(e) => { + set_tls_error(e.message()); + return e.status(); + } + }; + if ascii.is_none() && config_dir.is_none() { + set_tls_error("ascii or config_dir is required"); + return OmacyStatus::InvalidArg; + } + let effect = match session::parse_c_string(effect, true, "effect") { + Ok(s) => s, + Err(e) => { + set_tls_error(e.message()); + return e.status(); + } + }; + let art = if let Some(bytes) = ascii { + match crate::content::parse_utf8(bytes, "ascii") { + Ok(s) => s, + Err(e) => { + set_tls_error(e.message()); + return e.status(); + } + } + } else if let Some(dir) = &config_dir { + match std::fs::read_to_string(dir.join("screensaver.txt")) { + Ok(s) => s, + Err(_) => { + set_tls_error("screensaver.txt missing from config_dir"); + return OmacyStatus::InvalidArg; + } + } + } else { + String::new() + }; + let seed = if cfg.has_seed == 1 { + Some(cfg.seed) + } else { + None + }; + match Session::create( + art, + effect, + [cfg.bg_r, cfg.bg_g, cfg.bg_b, cfg.bg_a], + config_dir, + seed, + cols, + rows, + ClockKind::Real, + ) { + Ok(session) => { + unsafe { *out = Box::into_raw(Box::new(session)) }; + OmacyStatus::Ok + } + Err(e) => { + set_tls_error(e.message()); + e.status() + } + } + })); + match result { + Ok(status) => status, + Err(_) => { + set_tls_error("panic"); + if !out.is_null() { + unsafe { *out = ptr::null_mut() }; + } + OmacyStatus::Panic + } + } +} + +unsafe fn session_mut<'a>(s: *mut Session) -> Result<&'a mut Session, EngineError> { + if s.is_null() { + Err(EngineError::Null) + } else { + Ok(unsafe { &mut *s }) + } +} + +unsafe fn session_ref<'a>(s: *const Session) -> Result<&'a Session, EngineError> { + if s.is_null() { + Err(EngineError::Null) + } else { + Ok(unsafe { &*s }) + } +} + +fn with_live_session( + s: *mut Session, + f: impl FnOnce(&mut Session) -> Result<(), EngineError>, +) -> OmacyStatus { + let result = catch_unwind(AssertUnwindSafe(|| { + if let Err(e) = require_main() { + set_tls_error(e.message()); + return e.status(); + } + let session = match unsafe { session_mut(s) } { + Ok(s) => s, + Err(e) => { + set_tls_error(e.message()); + return e.status(); + } + }; + if session.is_dead() { + set_tls_error("session is dead"); + return OmacyStatus::Dead; + } + match f(session) { + Ok(()) => OmacyStatus::Ok, + Err(e) => { + set_tls_error(e.message()); + session.set_last_error(e.message()); + e.status() + } + } + })); + match result { + Ok(status) => { + if status == OmacyStatus::Panic { + if let Ok(session) = unsafe { session_mut(s) } { + session.mark_dead("panic".into()); + } + } + status + } + Err(_) => { + set_tls_error("panic"); + if let Ok(session) = unsafe { session_mut(s) } { + session.mark_dead("panic".into()); + } + OmacyStatus::Panic + } + } +} + +/// Induces a panic inside the same `catch_unwind` wrapper as session FFI. +/// Not part of the C ABI; engine tests use it to prove `cells` is invalidated. +pub unsafe fn debug_induce_panic(s: *mut Session) -> OmacyStatus { + with_live_session(s, |_| panic!("omacy induced panic")) +} + +#[no_mangle] +pub unsafe extern "C" fn omacy_session_resize(s: *mut Session, cols: u32, rows: u32) -> OmacyStatus { + with_live_session(s, |session| session.resize(cols, rows)) +} + +#[no_mangle] +pub unsafe extern "C" fn omacy_session_step( + s: *mut Session, + elapsed_seconds: f64, + out: *mut OmacyStepResult, +) -> OmacyStatus { + let result = catch_unwind(AssertUnwindSafe(|| { + if out.is_null() { + set_tls_error("null out"); + return OmacyStatus::Null; + } + if let Err(e) = require_main() { + set_tls_error(e.message()); + zero_step(out); + return e.status(); + } + let session = match unsafe { session_mut(s) } { + Ok(s) => s, + Err(e) => { + set_tls_error(e.message()); + zero_step(out); + return e.status(); + } + }; + if session.is_dead() { + set_tls_error("session is dead"); + zero_step(out); + return OmacyStatus::Dead; + } + match session.step(elapsed_seconds) { + Ok((frame, waiting)) => { + unsafe { + *out = OmacyStepResult { + frame, + needs_begin_next: u8::from(waiting), + _pad: [0; 3], + }; + } + OmacyStatus::Ok + } + Err(e) => { + set_tls_error(e.message()); + session.set_last_error(e.message()); + zero_step(out); + e.status() + } + } + })); + match result { + Ok(status) => status, + Err(_) => { + set_tls_error("panic"); + zero_step(out); + if let Ok(session) = unsafe { session_mut(s) } { + session.mark_dead("panic".into()); + } + OmacyStatus::Panic + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn omacy_session_set_pending_config( + s: *mut Session, + cfg: *const OmacyPendingConfig, +) -> OmacyStatus { + with_live_session(s, |session| { + if cfg.is_null() { + return Err(EngineError::Null); + } + let cfg = unsafe { &*cfg }; + let ascii = unsafe { slice_ptr_len(cfg.ascii, cfg.ascii_len)? }; + let effect = unsafe { slice_ptr_len(cfg.effect, cfg.effect_len)? }; + let packet = session::parse_pending(ascii, effect, [cfg.bg_r, cfg.bg_g, cfg.bg_b, cfg.bg_a])?; + session.set_pending(packet) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn omacy_session_begin_next(s: *mut Session) -> OmacyStatus { + with_live_session(s, |session| session.begin_next()) +} + +#[no_mangle] +pub unsafe extern "C" fn omacy_session_generation( + s: *const Session, + out: *mut u64, +) -> OmacyStatus { + let result = catch_unwind(AssertUnwindSafe(|| { + if out.is_null() { + set_tls_error("null out"); + return OmacyStatus::Null; + } + if let Err(e) = require_main() { + set_tls_error(e.message()); + return e.status(); + } + let session = match unsafe { session_ref(s) } { + Ok(s) => s, + Err(e) => { + set_tls_error(e.message()); + return e.status(); + } + }; + unsafe { *out = session.generation() }; + OmacyStatus::Ok + })); + match result { + Ok(status) => status, + Err(_) => { + set_tls_error("panic"); + OmacyStatus::Panic + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn omacy_session_error_message( + s: *const Session, + buf: *mut c_char, + buf_len: usize, +) -> OmacyStatus { + let result = catch_unwind(AssertUnwindSafe(|| { + if s.is_null() { + let msg = LAST_ERROR.with(|slot| slot.borrow().clone()); + return write_error_buf(&msg, buf, buf_len); + } + if let Err(e) = require_main() { + set_tls_error(e.message()); + return e.status(); + } + let session = match unsafe { session_ref(s) } { + Ok(s) => s, + Err(e) => { + set_tls_error(e.message()); + return e.status(); + } + }; + write_error_buf(session.error_message(), buf, buf_len) + })); + match result { + Ok(status) => status, + Err(_) => { + set_tls_error("panic"); + OmacyStatus::Panic + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn omacy_session_destroy(s: *mut Session) { + let _ = catch_unwind(AssertUnwindSafe(|| { + if s.is_null() { + return; + } + if !is_main_thread() { + set_tls_error("wrong thread"); + return; + } + drop(unsafe { Box::from_raw(s) }); + })); +} + +#[no_mangle] +pub extern "C" fn omacy_status_string(status: i32) -> *const c_char { + let text = match status { + 0 => OmacyStatus::Ok.as_c_str(), + 1 => OmacyStatus::Null.as_c_str(), + 2 => OmacyStatus::InvalidArg.as_c_str(), + 3 => OmacyStatus::Limit.as_c_str(), + 4 => OmacyStatus::Engine.as_c_str(), + 5 => OmacyStatus::Panic.as_c_str(), + 6 => OmacyStatus::Dead.as_c_str(), + 7 => OmacyStatus::WrongThread.as_c_str(), + _ => return ptr::null(), + }; + text.as_ptr() +} + +#[no_mangle] +pub unsafe extern "C" fn omacy_ascii_from_bytes( + cfg: *const OmacyAsciiConfig, + bytes: *const u8, + len: usize, + out: *mut *mut OmacyText, +) -> OmacyStatus { + let result = catch_unwind(AssertUnwindSafe(|| { + if out.is_null() { + return OmacyStatus::Null; + } + unsafe { *out = ptr::null_mut() }; + if cfg.is_null() { + set_tls_error("null config"); + return OmacyStatus::Null; + } + let cfg = unsafe { *cfg }; + let input = if bytes.is_null() { + if len == 0 { + &[][..] + } else { + set_tls_error("null bytes with nonzero length"); + return OmacyStatus::InvalidArg; + } + } else { + unsafe { slice::from_raw_parts(bytes, len) } + }; + match ascii::ascii_from_bytes(cfg, input) { + Ok(text) => { + unsafe { + *out = Box::into_raw(Box::new(OmacyText { + bytes: text.into_bytes(), + })); + } + OmacyStatus::Ok + } + Err(e) => { + set_tls_error(e.message()); + e.status() + } + } + })); + match result { + Ok(status) => status, + Err(_) => { + set_tls_error("panic"); + if !out.is_null() { + unsafe { *out = ptr::null_mut() }; + } + OmacyStatus::Panic + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn omacy_text_free(t: *mut OmacyText) { + let _ = catch_unwind(AssertUnwindSafe(|| { + if t.is_null() { + return; + } + drop(unsafe { Box::from_raw(t) }); + })); +} + +#[no_mangle] +pub unsafe extern "C" fn omacy_text_utf8(t: *const OmacyText) -> *const c_char { + match catch_unwind(AssertUnwindSafe(|| { + if t.is_null() { + ptr::null() + } else { + unsafe { (*t).bytes.as_ptr() as *const c_char } + } + })) { + Ok(p) => p, + Err(_) => ptr::null(), + } +} + +#[no_mangle] +pub unsafe extern "C" fn omacy_text_len(t: *const OmacyText) -> usize { + match catch_unwind(AssertUnwindSafe(|| { + if t.is_null() { + 0 + } else { + unsafe { (*t).bytes.len() } + } + })) { + Ok(n) => n, + Err(_) => 0, + } +} diff --git a/crates/omacy-engine/src/lib.rs b/crates/omacy-engine/src/lib.rs new file mode 100644 index 0000000..0f61f6e --- /dev/null +++ b/crates/omacy-engine/src/lib.rs @@ -0,0 +1,18 @@ +//! Omacy engine: ttfx session, packed cell grid, C ABI. + +pub mod abi; +pub mod ascii; +mod content; +pub mod ffi; +mod limits; +pub mod session; +mod settings; +pub mod status; + +pub use abi::{ + OmacyAsciiConfig, OmacyCell, OmacyFrame, OmacyStepResult, OMACY_CELL_HAS_BACKGROUND, + OMACY_CELL_HAS_GLYPH, +}; +pub use session::{ClockKind, Session}; +pub use settings::write_atomic; +pub use status::{EngineError, OmacyStatus}; diff --git a/crates/omacy-engine/src/limits.rs b/crates/omacy-engine/src/limits.rs new file mode 100644 index 0000000..2d18e6b --- /dev/null +++ b/crates/omacy-engine/src/limits.rs @@ -0,0 +1,32 @@ +pub const ASCII_BYTES: usize = 64 * 1024; +pub const ASCII_LINES: usize = 128; +pub const ASCII_COLUMNS: usize = 256; +pub const GRID_AXIS: u32 = 512; +pub const GRID_CELLS: u32 = 32_768; +pub const CONV_COLUMNS: u32 = 256; +pub const CONV_ROWS: u32 = 128; +pub const CONV_CELLS: u32 = 32_768; +pub const PNG_BYTES: usize = 8 * 1024 * 1024; +pub const SVG_BYTES: usize = 2 * 1024 * 1024; +pub const DECODED_PIXELS: u32 = 4_194_304; +pub const SVG_ELEMENTS: u32 = 8_192; +pub const STEP_HZ: f64 = 60.0; +pub const STEP_DT: f64 = 1.0 / STEP_HZ; +pub const MAX_STEPS_PER_CALL: u32 = 4; + +pub fn check_geometry(cols: u32, rows: u32) -> Result<(u32, u32), super::status::EngineError> { + if cols < 1 || rows < 1 || cols > GRID_AXIS || rows > GRID_AXIS { + return Err(super::status::EngineError::Limit( + "grid width or height exceeds cap".into(), + )); + } + let cells = cols + .checked_mul(rows) + .ok_or_else(|| super::status::EngineError::Limit("grid cell count overflow".into()))?; + if cells > GRID_CELLS { + return Err(super::status::EngineError::Limit( + "grid cell count exceeds 32768".into(), + )); + } + Ok((cols, rows)) +} diff --git a/crates/omacy-engine/src/session.rs b/crates/omacy-engine/src/session.rs new file mode 100644 index 0000000..c99b297 --- /dev/null +++ b/crates/omacy-engine/src/session.rs @@ -0,0 +1,417 @@ +use std::path::PathBuf; + +use ttfx::effects::EffectCommand; +use ttfx::engine::ctx::{Clock, EngineCtx}; +use ttfx::engine::effect::Effect; +use ttfx::engine::terminal::{PackedCell, TerminalConfig}; +use ttfx::utils::graphics::Color; +use ttfx::utils::rng::Rng; + +use crate::abi::OmacyFrame; +use crate::content::{is_known_effect, parse_utf8, validate_art, validate_effect, Content}; +use crate::limits::{self, check_geometry}; +use crate::settings; +use crate::status::EngineError; + +#[derive(Clone, Copy, Debug)] +pub enum ClockKind { + Real, + Virtual60, +} + +enum LiveState { + Running { + effect: Box, + ctx: EngineCtx, + }, + Waiting, + Dead, +} + +pub struct Session { + state: LiveState, + selected: Content, + selected_next: Content, + pending: Option, + pending_geometry: Option<(u32, u32)>, + cols: u32, + rows: u32, + cache: Vec, + clear: [u8; 4], + accumulator: f64, + generation: u64, + config_dir: Option, + pick_rng: Rng, + seed: Option, + clock_kind: ClockKind, + stepping: bool, + last_error: String, +} + +#[derive(Clone, Debug)] +pub struct ContentPacket { + pub art: Option, + pub effect: String, + pub bg: [u8; 4], +} + +impl Session { + pub fn create( + art: String, + effect: String, + bg: [u8; 4], + config_dir: Option, + seed: Option, + cols: u32, + rows: u32, + clock_kind: ClockKind, + ) -> Result { + let (cols, rows) = check_geometry(cols, rows)?; + let selected = Content::from_parts(art, effect, bg)?; + let pick_rng = match seed { + Some(s) => Rng::seeded(s), + None => Rng::from_entropy(), + }; + let clear = bg_or_opaque(selected.bg); + let mut session = Session { + state: LiveState::Dead, + selected: selected.clone(), + selected_next: selected, + pending: None, + pending_geometry: None, + cols, + rows, + cache: vec![PackedCell::default(); (cols * rows) as usize], + clear, + accumulator: 0.0, + generation: 0, + config_dir, + pick_rng, + seed, + clock_kind, + stepping: false, + last_error: String::new(), + }; + session.install_effect()?; + Ok(session) + } + + fn install_effect(&mut self) -> Result<(), EngineError> { + let (effect, ctx) = construct_effect( + &self.selected_next, + self.cols, + self.rows, + self.seed, + self.generation, + &mut self.pick_rng, + self.clock_kind, + )?; + self.selected = self.selected_next.clone(); + self.clear = bg_or_opaque(self.selected.bg); + self.cache + .resize((self.cols * self.rows) as usize, PackedCell::default()); + self.accumulator = 0.0; + self.state = LiveState::Running { effect, ctx }; + Ok(()) + } + + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn error_message(&self) -> &str { + &self.last_error + } + + pub fn set_last_error(&mut self, msg: String) { + self.last_error = msg; + } + + pub fn is_dead(&self) -> bool { + matches!(self.state, LiveState::Dead) + } + + pub fn is_waiting(&self) -> bool { + matches!(self.state, LiveState::Waiting) + } + + pub fn force_reentrant_step(&mut self) -> Result<(OmacyFrame, bool), EngineError> { + self.stepping = true; + let result = self.step(1.0 / 60.0); + self.stepping = false; + result + } + + pub fn mark_dead(&mut self, message: String) { + self.state = LiveState::Dead; + self.last_error = message; + self.cache.clear(); + } + + pub fn set_pending(&mut self, packet: ContentPacket) -> Result<(), EngineError> { + self.require_live()?; + validate_effect(&packet.effect)?; + if let Some(art) = &packet.art { + if !art.is_empty() { + validate_art(art)?; + } + } + self.pending = Some(packet); + Ok(()) + } + + pub fn resize(&mut self, cols: u32, rows: u32) -> Result<(), EngineError> { + self.require_live()?; + let (cols, rows) = check_geometry(cols, rows)?; + match &self.state { + LiveState::Running { .. } => { + if (cols, rows) == (self.cols, self.rows) { + self.pending_geometry = None; + } else { + self.pending_geometry = Some((cols, rows)); + } + Ok(()) + } + LiveState::Waiting => { + self.apply_geometry(cols, rows); + Ok(()) + } + LiveState::Dead => Err(EngineError::Dead), + } + } + + fn apply_geometry(&mut self, cols: u32, rows: u32) { + self.cols = cols; + self.rows = rows; + self.pending_geometry = None; + self.cache = vec![PackedCell::default(); (cols * rows) as usize]; + } + + pub fn begin_next(&mut self) -> Result<(), EngineError> { + if !matches!(self.state, LiveState::Waiting) { + return Err(EngineError::InvalidArg( + "begin_next is only legal while waiting".into(), + )); + } + let previous_geom = (self.cols, self.rows); + let previous_pending = self.pending_geometry; + let previous_cache = self.cache.clone(); + let previous_clear = self.clear; + if let Some((cols, rows)) = self.pending_geometry { + self.apply_geometry(cols, rows); + } + match self.install_effect() { + Ok(()) => { + self.generation = self.generation.saturating_add(1); + Ok(()) + } + Err(e) => { + self.cols = previous_geom.0; + self.rows = previous_geom.1; + self.pending_geometry = previous_pending; + self.cache = previous_cache; + self.clear = previous_clear; + self.state = LiveState::Waiting; + Err(e) + } + } + } + + pub fn step(&mut self, elapsed: f64) -> Result<(OmacyFrame, bool), EngineError> { + if self.stepping { + return Err(EngineError::InvalidArg("re-entrant step".into())); + } + if !elapsed.is_finite() || elapsed < 0.0 { + return Err(EngineError::InvalidArg( + "elapsed must be finite and non-negative".into(), + )); + } + self.require_live()?; + self.stepping = true; + let inner = self.step_inner(elapsed); + self.stepping = false; + let waiting = inner?; + Ok((self.published_c_frame(), waiting)) + } + + fn step_inner(&mut self, elapsed: f64) -> Result { + if matches!(self.state, LiveState::Waiting) { + return Ok(true); + } + if matches!(self.state, LiveState::Dead) { + return Err(EngineError::Dead); + } + + self.accumulator += elapsed; + let mut steps = 0u32; + let mut ended = false; + while self.accumulator >= limits::STEP_DT && steps < limits::MAX_STEPS_PER_CALL { + let going = match &mut self.state { + LiveState::Running { effect, ctx } => effect.advance(ctx), + _ => false, + }; + if !going { + ended = true; + break; + } + self.accumulator -= limits::STEP_DT; + steps += 1; + } + if steps == limits::MAX_STEPS_PER_CALL { + self.accumulator = 0.0; + } + + self.fill_cache()?; + + if ended { + self.accumulator = 0.0; + self.apply_boundary_content(); + self.state = LiveState::Waiting; + return Ok(true); + } + + Ok(false) + } + + fn fill_cache(&mut self) -> Result<(), EngineError> { + let term_bg = [self.clear[0], self.clear[1], self.clear[2]]; + match &mut self.state { + LiveState::Running { ctx, .. } => { + let needed = (self.cols as usize) + .checked_mul(self.rows as usize) + .ok_or_else(|| EngineError::Limit("grid overflow".into()))?; + if self.cache.len() != needed { + self.cache.resize(needed, PackedCell::default()); + } + ctx.terminal.fill_grid(&mut self.cache, term_bg); + Ok(()) + } + LiveState::Waiting => Ok(()), + LiveState::Dead => Err(EngineError::Dead), + } + } + + fn apply_boundary_content(&mut self) { + if let Some(packet) = self.pending.take() { + let mut next = self.selected.clone(); + if let Some(art) = packet.art { + if !art.is_empty() { + next.art = art; + } + } + next.effect = packet.effect; + next.bg = packet.bg; + self.selected_next = next; + return; + } + if let Some(dir) = &self.config_dir { + self.selected_next = settings::load_from_dir(dir, &self.selected); + return; + } + self.selected_next = self.selected.clone(); + } + + fn require_live(&self) -> Result<(), EngineError> { + match self.state { + LiveState::Dead => Err(EngineError::Dead), + _ => Ok(()), + } + } + + pub fn published_c_frame(&self) -> OmacyFrame { + let cells = if self.cache.is_empty() || matches!(self.state, LiveState::Dead) { + std::ptr::null() + } else { + self.cache.as_ptr() + }; + OmacyFrame { + cols: self.cols, + rows: self.rows, + clear_r: self.clear[0], + clear_g: self.clear[1], + clear_b: self.clear[2], + clear_a: self.clear[3], + _pad: 0, + cells, + } + } +} + +fn bg_or_opaque(bg: [u8; 4]) -> [u8; 4] { + if bg[3] == 0 { + [bg[0], bg[1], bg[2], 255] + } else { + bg + } +} + +fn construct_effect( + content: &Content, + cols: u32, + rows: u32, + seed: Option, + generation: u64, + pick_rng: &mut Rng, + clock_kind: ClockKind, +) -> Result<(Box, EngineCtx), EngineError> { + let name = if content.effect == "random" { + EffectCommand::NAMES[pick_rng.choice_index(EffectCommand::NAMES.len())] + } else { + content.effect.as_str() + }; + let mut effect = EffectCommand::from_name(name) + .ok_or_else(|| EngineError::InvalidArg(format!("unknown effect '{name}'")))?; + let color = Color::from_hex(&format!( + "{:02x}{:02x}{:02x}", + content.bg[0], content.bg[1], content.bg[2] + )) + .map_err(EngineError::Engine)?; + let config = TerminalConfig::gui(cols as i64, rows as i64, color); + let rng = match seed { + Some(s) => Rng::seeded(s.wrapping_add(generation)), + None => Rng::from_entropy(), + }; + let clock = match clock_kind { + ClockKind::Real => Clock::real(), + ClockKind::Virtual60 => Clock::virtual_with_frame_rate(60), + }; + let mut ctx = EngineCtx::new(&content.art, config, rng, clock) + .map_err(|e| EngineError::Engine(e.to_string()))?; + ctx.suppress_ansi = true; + effect + .build(&mut ctx) + .map_err(|e| EngineError::Engine(e.to_string()))?; + Ok((effect, ctx)) +} + +pub fn parse_c_string(bytes: Option<&[u8]>, required: bool, what: &str) -> Result { + match bytes { + None if required => Err(EngineError::InvalidArg(format!("{what} is required"))), + None => Ok(String::new()), + Some(b) => parse_utf8(b, what), + } +} + +pub fn parse_pending( + ascii: Option<&[u8]>, + effect: Option<&[u8]>, + bg: [u8; 4], +) -> Result { + let effect = parse_c_string(effect, true, "effect")?; + if effect.is_empty() { + return Err(EngineError::InvalidArg("effect is required".into())); + } + validate_effect(&effect)?; + let art = match ascii { + None | Some([]) => None, + Some(b) => { + let s = parse_utf8(b, "ascii")?; + validate_art(&s)?; + Some(s) + } + }; + if !is_known_effect(&effect) { + return Err(EngineError::InvalidArg("unknown effect".into())); + } + Ok(ContentPacket { art, effect, bg }) +} diff --git a/crates/omacy-engine/src/settings.rs b/crates/omacy-engine/src/settings.rs new file mode 100644 index 0000000..7556ac0 --- /dev/null +++ b/crates/omacy-engine/src/settings.rs @@ -0,0 +1,91 @@ +use std::fs; +use std::path::Path; + +use serde::Deserialize; + +use crate::content::{parse_hex_color, validate_art, validate_effect, Content}; +use crate::status::EngineError; + +#[derive(Debug, Deserialize)] +struct SettingsFile { + effect: Option, + background: Option, +} + +pub fn load_from_dir(dir: &Path, fallback: &Content) -> Content { + let art_path = dir.join("screensaver.txt"); + let settings_path = dir.join("settings.json"); + + let mut next = fallback.clone(); + + match fs::read_to_string(&art_path) { + Ok(art) => match validate_art(&art) { + Ok(()) if !art.is_empty() => next.art = art, + _ => {} + }, + Err(_) => {} + } + + match fs::read_to_string(&settings_path) { + Ok(text) => { + if let Ok(parsed) = serde_json::from_str::(&text) { + if let Some(effect) = parsed.effect { + if validate_effect(&effect).is_ok() { + next.effect = effect; + } + } + if let Some(bg) = parsed.background { + if let Ok(rgba) = parse_hex_color(&bg) { + next.bg = rgba; + } + } + } + } + Err(_) => {} + } + + next +} + +pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), EngineError> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let tmp = parent.join(format!( + ".{}.tmp", + path.file_name().and_then(|s| s.to_str()).unwrap_or("omacy") + )); + fs::write(&tmp, bytes).map_err(|e| EngineError::Engine(e.to_string()))?; + fs::rename(&tmp, path).map_err(|e| EngineError::Engine(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::Content; + use std::env; + + #[test] + fn atomic_write_replaces_file() { + let dir = env::temp_dir().join(format!("omacy-atomic-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("settings.json"); + write_atomic(&path, b"{\"effect\":\"random\"}").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"{\"effect\":\"random\"}"); + write_atomic(&path, b"{\"effect\":\"beams\"}").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"{\"effect\":\"beams\"}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn invalid_json_keeps_last_known_good() { + let dir = env::temp_dir().join(format!("omacy-badjson-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("settings.json"), b"{not json").unwrap(); + fs::write(dir.join("screensaver.txt"), b"HELLO").unwrap(); + let fallback = Content::from_parts("FALLBACK".into(), "beams".into(), [1, 2, 3, 255]).unwrap(); + let next = load_from_dir(&dir, &fallback); + assert_eq!(next.effect, "beams"); + assert_eq!(next.bg, [1, 2, 3, 255]); + assert_eq!(next.art, "HELLO"); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/crates/omacy-engine/src/status.rs b/crates/omacy-engine/src/status.rs new file mode 100644 index 0000000..0f34f6b --- /dev/null +++ b/crates/omacy-engine/src/status.rs @@ -0,0 +1,78 @@ +use std::ffi::c_int; + +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OmacyStatus { + Ok = 0, + Null = 1, + InvalidArg = 2, + Limit = 3, + Engine = 4, + Panic = 5, + Dead = 6, + WrongThread = 7, +} + +impl OmacyStatus { + pub fn as_c_int(self) -> c_int { + self as c_int + } + + pub fn as_str(self) -> &'static str { + match self { + OmacyStatus::Ok => "OMACY_OK", + OmacyStatus::Null => "OMACY_ERR_NULL", + OmacyStatus::InvalidArg => "OMACY_ERR_INVALID_ARG", + OmacyStatus::Limit => "OMACY_ERR_LIMIT", + OmacyStatus::Engine => "OMACY_ERR_ENGINE", + OmacyStatus::Panic => "OMACY_ERR_PANIC", + OmacyStatus::Dead => "OMACY_ERR_DEAD", + OmacyStatus::WrongThread => "OMACY_ERR_WRONG_THREAD", + } + } + + pub fn as_c_str(self) -> &'static std::ffi::CStr { + match self { + OmacyStatus::Ok => c"OMACY_OK", + OmacyStatus::Null => c"OMACY_ERR_NULL", + OmacyStatus::InvalidArg => c"OMACY_ERR_INVALID_ARG", + OmacyStatus::Limit => c"OMACY_ERR_LIMIT", + OmacyStatus::Engine => c"OMACY_ERR_ENGINE", + OmacyStatus::Panic => c"OMACY_ERR_PANIC", + OmacyStatus::Dead => c"OMACY_ERR_DEAD", + OmacyStatus::WrongThread => c"OMACY_ERR_WRONG_THREAD", + } + } +} + +#[derive(Debug)] +pub enum EngineError { + Null, + InvalidArg(String), + Limit(String), + Engine(String), + Dead, + WrongThread, +} + +impl EngineError { + pub fn status(&self) -> OmacyStatus { + match self { + EngineError::Null => OmacyStatus::Null, + EngineError::InvalidArg(_) => OmacyStatus::InvalidArg, + EngineError::Limit(_) => OmacyStatus::Limit, + EngineError::Engine(_) => OmacyStatus::Engine, + EngineError::Dead => OmacyStatus::Dead, + EngineError::WrongThread => OmacyStatus::WrongThread, + } + } + + pub fn message(&self) -> String { + match self { + EngineError::Null => "null pointer".into(), + EngineError::InvalidArg(m) | EngineError::Limit(m) | EngineError::Engine(m) => m.clone(), + EngineError::Dead => "session is dead".into(), + EngineError::WrongThread => "wrong thread".into(), + } + } +} diff --git a/crates/omacy-engine/tests/ascii.rs b/crates/omacy-engine/tests/ascii.rs new file mode 100644 index 0000000..83f8930 --- /dev/null +++ b/crates/omacy-engine/tests/ascii.rs @@ -0,0 +1,87 @@ +use omacy_engine::abi::{OmacyAsciiConfig, OMACY_ASCII_BLOCK, OMACY_ASCII_BRAILLE}; +use omacy_engine::ascii::ascii_from_bytes; + +fn solid_png(w: u32, h: u32, rgba: [u8; 4]) -> Vec { + let mut img = image::RgbaImage::from_pixel(w, h, image::Rgba(rgba)); + // stamp an opaque black logo block in the middle so both modes emit glyphs + for y in h / 4..h * 3 / 4 { + for x in w / 4..w * 3 / 4 { + img.put_pixel(x, y, image::Rgba([0, 0, 0, 255])); + } + } + let mut buf = Vec::new(); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) + .unwrap(); + buf +} + +#[test] +fn png_braille_emits_cells() { + let png = solid_png(16, 16, [255, 255, 255, 255]); + let cfg = OmacyAsciiConfig { + mode: OMACY_ASCII_BRAILLE, + width: 8, + height: 4, + threshold: 50, + invert: 0, + trim: 1, + _pad: 0, + }; + let text = ascii_from_bytes(cfg, &png).expect("convert"); + assert!(!text.is_empty()); + assert_ne!(text, "No logo pixels found"); +} + +#[test] +fn png_block_emits_cells() { + let png = solid_png(16, 16, [255, 255, 255, 255]); + let cfg = OmacyAsciiConfig { + mode: OMACY_ASCII_BLOCK, + width: 8, + height: 4, + threshold: 50, + invert: 0, + trim: 1, + _pad: 0, + }; + let text = ascii_from_bytes(cfg, &png).expect("convert"); + assert!(text.chars().any(|c| c == '█' || c == '▀' || c == '▄')); +} + +#[test] +fn oversized_svg_is_limit() { + let huge = vec![b'x'; 2 * 1024 * 1024 + 1]; + let cfg = OmacyAsciiConfig { + mode: OMACY_ASCII_BRAILLE, + width: 8, + height: 4, + threshold: 50, + invert: 0, + trim: 1, + _pad: 0, + }; + match ascii_from_bytes(cfg, &huge) { + Err(omacy_engine::EngineError::Limit(_)) => {} + other => panic!("expected limit, got {other:?}"), + } +} + +#[test] +fn svg_file_href_is_ignored() { + let svg = br#" + + +"#; + let cfg = OmacyAsciiConfig { + mode: OMACY_ASCII_BLOCK, + width: 8, + height: 4, + threshold: 50, + invert: 0, + trim: 1, + _pad: 0, + }; + let text = ascii_from_bytes(cfg, svg).expect("svg with file href must not load the file"); + assert!(!text.is_empty()); +} diff --git a/crates/omacy-engine/tests/budget.rs b/crates/omacy-engine/tests/budget.rs new file mode 100644 index 0000000..5424116 --- /dev/null +++ b/crates/omacy-engine/tests/budget.rs @@ -0,0 +1,50 @@ +use std::time::Instant; + +use omacy_engine::session::{ClockKind, Session}; + +fn wordmark() -> String { + std::fs::read_to_string(format!( + "{}/../../assets/branding/screensaver.txt", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + +fn max_grid_session() -> Session { + Session::create( + wordmark(), + "wipe".into(), + [0, 0, 0, 255], + None, + Some(1), + 256, + 128, + ClockKind::Virtual60, + ) + .expect("max-grid session") +} + +#[test] +fn three_max_grid_sessions_step_under_budget() { + let mut sessions = vec![max_grid_session(), max_grid_session(), max_grid_session()]; + for session in &mut sessions { + session.step(1.0 / 60.0).expect("warmup step"); + } + + let started = Instant::now(); + for session in &mut sessions { + let (frame, _) = session.step(1.0 / 60.0).expect("budget step"); + assert_eq!(frame.cols * frame.rows, 32_768); + assert!(!frame.cells.is_null()); + } + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; + + // Architecture phase-2 gate is 8.3 ms for step + Metal upload + encode. + // This Linux job can only time the engine slice. Debug builds are slower; + // `cargo test --release` is the production number. + let budget_ms = if cfg!(debug_assertions) { 50.0 } else { 8.3 }; + assert!( + elapsed_ms < budget_ms, + "step of 3×32768-cell sessions took {elapsed_ms:.3} ms (budget {budget_ms} ms)" + ); +} diff --git a/crates/omacy-engine/tests/conversion.rs b/crates/omacy-engine/tests/conversion.rs new file mode 100644 index 0000000..7ff5871 --- /dev/null +++ b/crates/omacy-engine/tests/conversion.rs @@ -0,0 +1,103 @@ +//! Identity vs committed conversion goldens. + +use omacy_engine::abi::{OmacyAsciiConfig, OMACY_ASCII_BLOCK, OMACY_ASCII_BRAILLE}; +use omacy_engine::ascii::ascii_from_bytes; + +struct Case { + input: &'static str, + mode: u32, + width: u32, + height: u32, + threshold: u8, + invert: u8, + trim: u8, + expected: &'static str, +} + +fn load(name: &str) -> Vec { + std::fs::read(format!( + "{}/../../assets/fixtures/conversion/{name}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap_or_else(|e| panic!("{name}: {e}")) +} + +#[test] +fn conversion_matches_committed_fixtures() { + let cases = [ + Case { + input: "solid-black-on-white.png", + mode: OMACY_ASCII_BRAILLE, + width: 8, + height: 4, + threshold: 50, + invert: 0, + trim: 1, + expected: "solid-black-on-white.braille.t50.inv0.trim1.txt", + }, + Case { + input: "solid-black-on-white.png", + mode: OMACY_ASCII_BLOCK, + width: 8, + height: 4, + threshold: 50, + invert: 0, + trim: 1, + expected: "solid-black-on-white.block.t50.inv0.trim1.txt", + }, + Case { + input: "solid-black-on-white.png", + mode: OMACY_ASCII_BRAILLE, + width: 8, + height: 4, + threshold: 50, + invert: 1, + trim: 1, + expected: "solid-black-on-white.braille.t50.inv1.trim1.txt", + }, + Case { + input: "solid-black-on-white.png", + mode: OMACY_ASCII_BLOCK, + width: 16, + height: 8, + threshold: 80, + invert: 0, + trim: 0, + expected: "solid-black-on-white.block.t80.inv0.trim0.txt", + }, + Case { + input: "alpha-logo.png", + mode: OMACY_ASCII_BRAILLE, + width: 8, + height: 4, + threshold: 50, + invert: 0, + trim: 1, + expected: "alpha-logo.braille.t50.inv0.trim1.txt", + }, + Case { + input: "logo.svg", + mode: OMACY_ASCII_BRAILLE, + width: 8, + height: 4, + threshold: 50, + invert: 0, + trim: 1, + expected: "logo.svg.braille.t50.inv0.trim1.txt", + }, + ]; + for case in cases { + let cfg = OmacyAsciiConfig { + mode: case.mode, + width: case.width, + height: case.height, + threshold: case.threshold, + invert: case.invert, + trim: case.trim, + _pad: 0, + }; + let got = ascii_from_bytes(cfg, &load(case.input)).expect(case.expected); + let want = String::from_utf8(load(case.expected)).unwrap(); + assert_eq!(got, want, "{}", case.expected); + } +} diff --git a/crates/omacy-engine/tests/ffi.rs b/crates/omacy-engine/tests/ffi.rs new file mode 100644 index 0000000..42e4662 --- /dev/null +++ b/crates/omacy-engine/tests/ffi.rs @@ -0,0 +1,138 @@ +use omacy_engine::abi::{OmacySessionConfig, OmacyStepResult}; +use omacy_engine::ffi::{ + debug_induce_panic, omacy_session_create, omacy_session_destroy, omacy_session_error_message, + omacy_session_step, +}; +use omacy_engine::session::Session; +use omacy_engine::OmacyStatus; +use std::os::raw::c_char; +use std::ptr; + +fn wordmark() -> Vec { + std::fs::read(format!( + "{}/../../assets/branding/screensaver.txt", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + +#[test] +fn step_null_out_does_not_crash() { + if !omacy_engine::ffi::is_main_thread() { + let status = unsafe { omacy_session_step(ptr::null_mut(), 1.0 / 60.0, ptr::null_mut()) }; + assert_eq!(status, OmacyStatus::Null); + return; + } + let art = wordmark(); + let effect = b"wipe"; + let cfg = OmacySessionConfig { + config_dir: ptr::null(), + config_dir_len: 0, + ascii: art.as_ptr(), + ascii_len: art.len(), + effect: effect.as_ptr(), + effect_len: effect.len(), + bg_r: 0, + bg_g: 0, + bg_b: 0, + bg_a: 255, + has_seed: 1, + _pad: [0; 3], + seed: 1, + }; + let mut session: *mut Session = ptr::null_mut(); + let status = unsafe { omacy_session_create(&cfg, 20, 8, &mut session) }; + if status == OmacyStatus::WrongThread { + return; + } + assert_eq!(status, OmacyStatus::Ok); + let status = unsafe { omacy_session_step(session, 1.0 / 60.0, ptr::null_mut()) }; + assert_eq!(status, OmacyStatus::Null); + unsafe { omacy_session_destroy(session) }; +} + +#[test] +fn error_message_null_session_any_thread() { + let mut buf = [0 as c_char; 64]; + let status = unsafe { omacy_session_error_message(ptr::null(), buf.as_mut_ptr(), buf.len()) }; + assert_eq!(status, OmacyStatus::Ok); +} + +#[test] +fn step_zeroes_out_on_invalid_elapsed() { + if !omacy_engine::ffi::is_main_thread() { + return; + } + let art = wordmark(); + let effect = b"wipe"; + let cfg = OmacySessionConfig { + config_dir: ptr::null(), + config_dir_len: 0, + ascii: art.as_ptr(), + ascii_len: art.len(), + effect: effect.as_ptr(), + effect_len: effect.len(), + bg_r: 0, + bg_g: 0, + bg_b: 0, + bg_a: 255, + has_seed: 1, + _pad: [0; 3], + seed: 1, + }; + let mut session: *mut Session = ptr::null_mut(); + let status = unsafe { omacy_session_create(&cfg, 20, 8, &mut session) }; + if status != OmacyStatus::Ok { + return; + } + let mut out = OmacyStepResult::zeroed(); + let status = unsafe { omacy_session_step(session, f64::NAN, &mut out) }; + assert_eq!(status, OmacyStatus::InvalidArg); + assert!(out.frame.cells.is_null()); + assert_eq!(out.needs_begin_next, 0); + unsafe { omacy_session_destroy(session) }; +} + +#[test] +fn panic_invalidates_cells() { + if !omacy_engine::ffi::is_main_thread() { + return; + } + let art = wordmark(); + let effect = b"wipe"; + let cfg = OmacySessionConfig { + config_dir: ptr::null(), + config_dir_len: 0, + ascii: art.as_ptr(), + ascii_len: art.len(), + effect: effect.as_ptr(), + effect_len: effect.len(), + bg_r: 0, + bg_g: 0, + bg_b: 0, + bg_a: 255, + has_seed: 1, + _pad: [0; 3], + seed: 1, + }; + let mut session: *mut Session = ptr::null_mut(); + let status = unsafe { omacy_session_create(&cfg, 20, 8, &mut session) }; + if status != OmacyStatus::Ok { + return; + } + let mut out = OmacyStepResult::zeroed(); + let status = unsafe { omacy_session_step(session, 1.0 / 60.0, &mut out) }; + assert_eq!(status, OmacyStatus::Ok); + assert!(!out.frame.cells.is_null()); + + let status = unsafe { debug_induce_panic(session) }; + assert_eq!(status, OmacyStatus::Panic); + + let mut after = OmacyStepResult::zeroed(); + after.frame.cells = art.as_ptr() as *const _; + let status = unsafe { omacy_session_step(session, 1.0 / 60.0, &mut after) }; + assert_eq!(status, OmacyStatus::Dead); + assert!(after.frame.cells.is_null()); + assert_eq!(after.needs_begin_next, 0); + unsafe { omacy_session_destroy(session) }; +} diff --git a/crates/omacy-engine/tests/oracle.rs b/crates/omacy-engine/tests/oracle.rs new file mode 100644 index 0000000..a61910b --- /dev/null +++ b/crates/omacy-engine/tests/oracle.rs @@ -0,0 +1,94 @@ +#[path = "sgr_decode.rs"] +mod sgr_decode; + +use omacy_engine::abi::OmacyCell; +use sgr_decode::{decode_ansi, occupancy}; + +fn fixture(name: &str) -> String { + let path = format!( + "{}/../../assets/fixtures/ansi-oracle/{name}", + env!("CARGO_MANIFEST_DIR") + ); + String::from_utf8(std::fs::read(path).expect("fixture")).expect("utf8") +} + +fn cell_at(cells: &[OmacyCell], cols: u32, row: u32, col: u32) -> OmacyCell { + cells[(row * cols + col) as usize] +} + +#[test] +fn origin_asymmetric() { + let cells = decode_ansi(&fixture("origin-asymmetric.ans"), 2, 2, [0, 0, 0]); + let tr = cell_at(&cells, 2, 0, 1); + let bl = cell_at(&cells, 2, 1, 0); + let tl = cell_at(&cells, 2, 0, 0); + let br = cell_at(&cells, 2, 1, 1); + assert_eq!(occupancy(&tl), (false, false)); + assert_eq!(occupancy(&br), (false, false)); + assert_eq!(occupancy(&tr), (false, true)); + assert_eq!(tr.glyph, u32::from('X')); + assert_eq!(occupancy(&bl), (true, false)); + assert_ne!([bl.bg_r, bl.bg_g, bl.bg_b], [0, 0, 0]); + assert_eq!(tr.flags, 0); + assert_eq!(bl.flags, 0); +} + +#[test] +fn occupancy_four() { + let cells = decode_ansi(&fixture("occupancy-four.ans"), 2, 2, [0, 0, 0]); + assert_eq!(occupancy(&cell_at(&cells, 2, 0, 0)), (false, false)); + assert_eq!(occupancy(&cell_at(&cells, 2, 0, 1)), (false, true)); + assert_eq!(occupancy(&cell_at(&cells, 2, 1, 0)), (true, false)); + assert_eq!(occupancy(&cell_at(&cells, 2, 1, 1)), (true, true)); + assert_eq!(cell_at(&cells, 2, 0, 1).glyph, u32::from('A')); + assert_eq!(cell_at(&cells, 2, 1, 1).glyph, u32::from('B')); +} + +#[test] +fn sgr_reset_and_reverse() { + let reset = decode_ansi(&fixture("sgr-reset.ans"), 2, 1, [0, 0, 0]); + assert_eq!(occupancy(&reset[0]), (false, true)); + assert_eq!(reset[0].glyph, u32::from('A')); + assert_eq!(occupancy(&reset[1]), (false, true)); + assert_eq!(reset[1].glyph, u32::from('B')); + assert_eq!(reset[0].flags, 0); + + let rev = decode_ansi(&fixture("sgr-reverse.ans"), 3, 1, [0, 0, 0]); + assert_eq!(occupancy(&rev[0]), (false, true)); + assert_eq!(occupancy(&rev[1]), (true, true)); + assert_eq!(occupancy(&rev[2]), (false, true)); + assert_eq!(rev[1].bg_r, rev[0].fg_r); + assert_eq!([rev[1].fg_r, rev[1].fg_g, rev[1].fg_b], [0, 0, 0]); +} + +#[test] +fn sgr_truecolor_and_xterm() { + let tc = decode_ansi(&fixture("sgr-truecolor.ans"), 1, 1, [0, 0, 0]); + assert_eq!([tc[0].fg_r, tc[0].fg_g, tc[0].fg_b], [255, 128, 0]); + assert_eq!([tc[0].bg_r, tc[0].bg_g, tc[0].bg_b], [0, 0, 64]); + assert_eq!(occupancy(&tc[0]), (true, true)); + + let x = decode_ansi(&fixture("sgr-xterm256.ans"), 1, 1, [0, 0, 0]); + assert_eq!(occupancy(&x[0]), (true, true)); + assert_eq!(x[0].glyph, u32::from('X')); +} + +#[test] +fn sgr_style_does_not_set_flags() { + let cells = decode_ansi(&fixture("sgr-style-ignored.ans"), 2, 1, [0, 0, 0]); + assert_eq!(cells[0].flags, 0); + assert_eq!(cells[1].flags, 0); + assert_eq!(cells[0].glyph, u32::from('X')); + assert_eq!(cells[1].glyph, u32::from('Y')); +} + +#[test] +fn sgr_ansi16_lengths() { + let fg = decode_ansi(&fixture("sgr-ansi16-fg.ans"), 16, 1, [0, 0, 0]); + assert_eq!(fg[0].glyph, u32::from('A')); + assert_eq!(fg[15].glyph, u32::from('P')); + assert!(fg.iter().all(|c| occupancy(c) == (false, true) && c.flags == 0)); + + let bg = decode_ansi(&fixture("sgr-ansi16-bg.ans"), 16, 1, [0, 0, 0]); + assert!(bg.iter().all(|c| occupancy(c) == (true, false) && c.flags == 0)); +} diff --git a/crates/omacy-engine/tests/parity.rs b/crates/omacy-engine/tests/parity.rs new file mode 100644 index 0000000..837fb30 --- /dev/null +++ b/crates/omacy-engine/tests/parity.rs @@ -0,0 +1,88 @@ +//! fill_grid vs ANSI oracle for the 37-effect matrix. + +use omacy_engine::abi::OmacyCell; +use ttfx::effects::EffectCommand; +use ttfx::engine::ctx::{Clock, EngineCtx}; +use ttfx::engine::terminal::TerminalConfig; +use ttfx::utils::graphics::Color; +use ttfx::utils::rng::Rng; + +#[path = "sgr_decode.rs"] +mod sgr_decode; + +fn wordmark() -> String { + std::fs::read_to_string(format!( + "{}/../../assets/branding/screensaver.txt", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + +fn asymmetric() -> String { + std::fs::read_to_string(format!( + "{}/../../assets/fixtures/asymmetric.txt", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + +fn compare_frame(name: &str, packed: &[OmacyCell], ansi: &str, cols: u32, rows: u32) { + let oracle = sgr_decode::decode_ansi(ansi, cols, rows, [0, 0, 0]); + assert_eq!(packed.len(), oracle.len(), "{name} length"); + for (i, (got, want)) in packed.iter().zip(oracle.iter()).enumerate() { + assert_eq!(got.occupancy, want.occupancy, "{name} occupancy {i}"); + assert_eq!(got.glyph, want.glyph, "{name} glyph {i}"); + assert_eq!(got.flags, 0, "{name} flags {i}"); + assert_eq!( + [got.fg_r, got.fg_g, got.fg_b, got.fg_a], + [want.fg_r, want.fg_g, want.fg_b, want.fg_a], + "{name} fg {i}" + ); + assert_eq!( + [got.bg_r, got.bg_g, got.bg_b, got.bg_a], + [want.bg_r, want.bg_g, want.bg_b, want.bg_a], + "{name} bg {i}" + ); + } +} + +fn run_effect(name: &str, input: &str, cols: i64, rows: i64, frames: u32) { + let mut effect = EffectCommand::from_name(name).unwrap_or_else(|| panic!("{name}")); + let config = TerminalConfig::gui(cols, rows, Color::from_hex("000000").unwrap()); + let mut ctx = + EngineCtx::new(input, config, Rng::seeded(1), Clock::virtual_with_frame_rate(60)) + .expect("ctx"); + ctx.suppress_ansi = false; + effect.build(&mut ctx).expect("build"); + let mut packed = vec![OmacyCell::default(); (cols * rows) as usize]; + let mut last_ansi = String::new(); + for _ in 0..frames { + match effect.next_frame(&mut ctx) { + Some(ansi) => { + ctx.terminal.fill_grid(&mut packed, [0, 0, 0]); + compare_frame(name, &packed, &ansi, cols as u32, rows as u32); + last_ansi = ansi; + } + None => { + if !last_ansi.is_empty() { + ctx.terminal.fill_grid(&mut packed, [0, 0, 0]); + compare_frame(name, &packed, &last_ansi, cols as u32, rows as u32); + } + return; + } + } + } +} + +#[test] +fn fill_grid_matches_oracle_for_all_effects() { + let inputs = [wordmark(), asymmetric()]; + let canvases = [(80, 24), (160, 48)]; + for name in EffectCommand::NAMES { + for input in &inputs { + for &(cols, rows) in &canvases { + run_effect(name, input, cols, rows, 180); + } + } + } +} diff --git a/crates/omacy-engine/tests/parity_debug.rs b/crates/omacy-engine/tests/parity_debug.rs new file mode 100644 index 0000000..dcad178 --- /dev/null +++ b/crates/omacy-engine/tests/parity_debug.rs @@ -0,0 +1,48 @@ +//! Regression: effects that restore coordinates after ctx.frame() (unstable rumble) +//! must still publish the displayed grid. + +use omacy_engine::abi::OmacyCell; +use ttfx::effects::EffectCommand; +use ttfx::engine::ctx::{Clock, EngineCtx}; +use ttfx::engine::effect::Effect; +use ttfx::engine::terminal::TerminalConfig; +use ttfx::utils::graphics::Color; +use ttfx::utils::rng::Rng; + +#[path = "sgr_decode.rs"] +mod sgr_decode; + +fn run(name: &str, input: &str, cols: i64, rows: i64, frames: u32) { + let mut effect = EffectCommand::from_name(name).unwrap(); + let config = TerminalConfig::gui(cols, rows, Color::from_hex("000000").unwrap()); + let mut ctx = + EngineCtx::new(input, config, Rng::seeded(1), Clock::virtual_with_frame_rate(60)).unwrap(); + effect.build(&mut ctx).unwrap(); + let mut packed = vec![OmacyCell::default(); (cols * rows) as usize]; + for frame in 0..frames { + let Some(ansi) = effect.next_frame(&mut ctx) else { + break; + }; + ctx.terminal.fill_grid(&mut packed, [0, 0, 0]); + let oracle = sgr_decode::decode_ansi(&ansi, cols as u32, rows as u32, [0, 0, 0]); + for (i, (got, want)) in packed.iter().zip(oracle.iter()).enumerate() { + assert_eq!( + (got.occupancy, got.glyph, got.fg_r, got.bg_r), + (want.occupancy, want.glyph, want.fg_r, want.bg_r), + "{name} frame={frame} cell={i} row={} col={}", + i / cols as usize, + i % cols as usize + ); + } + } +} + +#[test] +fn unstable_rumble_fill_grid_matches_ansi() { + let input = std::fs::read_to_string(format!( + "{}/../../assets/branding/screensaver.txt", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(); + run("unstable", &input, 80, 24, 180); +} diff --git a/crates/omacy-engine/tests/session.rs b/crates/omacy-engine/tests/session.rs new file mode 100644 index 0000000..8f69da0 --- /dev/null +++ b/crates/omacy-engine/tests/session.rs @@ -0,0 +1,320 @@ +use omacy_engine::session::{ClockKind, Session}; +use omacy_engine::status::EngineError; + +fn wordmark() -> String { + std::fs::read_to_string(format!( + "{}/../../assets/branding/screensaver.txt", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + +fn session(effect: &str, cols: u32, rows: u32) -> Session { + Session::create( + wordmark(), + effect.into(), + [0, 0, 0, 255], + None, + Some(1), + cols, + rows, + ClockKind::Virtual60, + ) + .expect("create") +} + +#[test] +fn create_and_step_publishes_grid() { + let mut s = session("beams", 80, 24); + let (frame, waiting) = s.step(1.0 / 60.0).unwrap(); + assert!(!waiting); + assert_eq!(frame.cols, 80); + assert_eq!(frame.rows, 24); + assert!(!frame.cells.is_null()); + assert_eq!(frame.clear_a, 255); +} + +#[test] +fn running_resize_is_pending() { + let mut s = session("beams", 80, 24); + let (before, _) = s.step(1.0 / 60.0).unwrap(); + let ptr = before.cells; + s.resize(100, 30).unwrap(); + let (after, waiting) = s.step(1.0 / 60.0).unwrap(); + assert!(!waiting); + assert_eq!(after.cols, 80); + assert_eq!(after.rows, 24); + assert_eq!(after.cells, ptr); +} + +#[test] +fn invalid_elapsed_is_rejected() { + let mut s = session("wipe", 40, 12); + match s.step(f64::NAN) { + Err(EngineError::InvalidArg(_)) => {} + _ => panic!("expected invalid arg"), + } + match s.step(-0.1) { + Err(EngineError::InvalidArg(_)) => {} + _ => panic!("expected invalid arg"), + } +} + +#[test] +fn begin_next_rejected_while_running() { + let mut s = session("wipe", 40, 12); + match s.begin_next() { + Err(EngineError::InvalidArg(_)) => {} + _ => panic!("expected invalid arg"), + } +} + +#[test] +fn unknown_effect_is_invalid() { + let err = match Session::create( + wordmark(), + "not-an-effect".into(), + [0, 0, 0, 255], + None, + Some(1), + 40, + 12, + ClockKind::Virtual60, + ) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + assert!(matches!(err, EngineError::InvalidArg(_))); +} + +#[test] +fn geometry_cap() { + let err = match Session::create( + wordmark(), + "wipe".into(), + [0, 0, 0, 255], + None, + Some(1), + 513, + 1, + ClockKind::Virtual60, + ) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + assert!(matches!(err, EngineError::Limit(_))); +} + +#[test] +fn waiting_resize_applies_and_clears_cache() { + let mut s = session("wipe", 20, 8); + let mut waiting = false; + for _ in 0..10_000 { + let (_, w) = s.step(1.0 / 60.0).unwrap(); + if w { + waiting = true; + break; + } + } + assert!(waiting, "wipe should complete on a tiny canvas"); + s.resize(24, 10).unwrap(); + let (frame, still) = s.step(1.0 / 60.0).unwrap(); + assert!(still); + assert_eq!(frame.cols, 24); + assert_eq!(frame.rows, 10); + s.begin_next().unwrap(); + let (frame, waiting) = s.step(1.0 / 60.0).unwrap(); + assert!(!waiting); + assert_eq!(frame.cols, 24); + assert_eq!(frame.rows, 10); + assert_eq!(s.generation(), 1); +} + +#[test] +fn ffi_create_off_main_fails() { + use omacy_engine::ffi::is_main_thread; + if is_main_thread() { + return; + } + let cfg = omacy_engine::abi::OmacySessionConfig { + config_dir: std::ptr::null(), + config_dir_len: 0, + ascii: b"A".as_ptr(), + ascii_len: 1, + effect: b"wipe".as_ptr(), + effect_len: 4, + bg_r: 0, + bg_g: 0, + bg_b: 0, + bg_a: 255, + has_seed: 1, + _pad: [0; 3], + seed: 1, + }; + let mut out: *mut omacy_engine::session::Session = std::ptr::null_mut(); + let status = unsafe { + omacy_engine::ffi::omacy_session_create(&cfg, 20, 8, &mut out) + }; + assert_eq!(status, omacy_engine::OmacyStatus::WrongThread); + assert!(out.is_null()); +} + +#[test] +fn pending_background_is_not_published_clear() { + let mut s = session("wipe", 20, 8); + s.set_pending(omacy_engine::session::ContentPacket { + art: None, + effect: "wipe".into(), + bg: [255, 0, 0, 255], + }) + .unwrap(); + let mut waiting = false; + let mut last_clear = [0u8; 4]; + for _ in 0..10_000 { + let (frame, w) = s.step(1.0 / 60.0).unwrap(); + last_clear = [frame.clear_r, frame.clear_g, frame.clear_b, frame.clear_a]; + if w { + waiting = true; + break; + } + } + assert!(waiting); + assert_eq!(last_clear, [0, 0, 0, 255]); + s.begin_next().unwrap(); + let (frame, waiting) = s.step(1.0 / 60.0).unwrap(); + assert!(!waiting); + assert_eq!( + [frame.clear_r, frame.clear_g, frame.clear_b, frame.clear_a], + [255, 0, 0, 255] + ); +} + +#[test] +fn status_strings_are_nul_terminated() { + unsafe { + let p = omacy_engine::ffi::omacy_status_string(0); + assert!(!p.is_null()); + let s = std::ffi::CStr::from_ptr(p).to_str().unwrap(); + assert_eq!(s, "OMACY_OK"); + } +} + +#[test] +fn mark_dead_clears_published_cells() { + let mut s = session("beams", 20, 8); + let (frame, _) = s.step(1.0 / 60.0).unwrap(); + assert!(!frame.cells.is_null()); + s.mark_dead("test".into()); + assert!(s.is_dead()); + let frame = s.published_c_frame(); + assert!(frame.cells.is_null()); +} + +fn wait_for_end(s: &mut Session) { + for _ in 0..20_000 { + let (_, waiting) = s.step(1.0 / 60.0).unwrap(); + if waiting { + return; + } + } + panic!("effect did not complete"); +} + +#[test] +fn pending_queued_while_waiting_applies_at_following_boundary() { + let mut s = session("wipe", 20, 8); + wait_for_end(&mut s); + s.set_pending(omacy_engine::session::ContentPacket { + art: None, + effect: "beams".into(), + bg: [255, 0, 0, 255], + }) + .unwrap(); + s.begin_next().unwrap(); + let (frame, waiting) = s.step(1.0 / 60.0).unwrap(); + assert!(!waiting); + assert_eq!( + [frame.clear_r, frame.clear_g, frame.clear_b, frame.clear_a], + [0, 0, 0, 255], + "packet queued in wait must not promote until the next boundary" + ); + wait_for_end(&mut s); + s.begin_next().unwrap(); + let (frame, waiting) = s.step(1.0 / 60.0).unwrap(); + assert!(!waiting); + assert_eq!( + [frame.clear_r, frame.clear_g, frame.clear_b, frame.clear_a], + [255, 0, 0, 255] + ); +} + +#[test] +fn begin_next_consumes_running_pending_geometry() { + let mut s = session("wipe", 20, 8); + s.resize(30, 12).unwrap(); + let (frame, _) = s.step(1.0 / 60.0).unwrap(); + assert_eq!(frame.cols, 20); + assert_eq!(frame.rows, 8); + wait_for_end(&mut s); + let (frame, waiting) = s.step(1.0 / 60.0).unwrap(); + assert!(waiting); + assert_eq!(frame.cols, 20); + assert_eq!(frame.rows, 8); + s.begin_next().unwrap(); + let (frame, waiting) = s.step(1.0 / 60.0).unwrap(); + assert!(!waiting); + assert_eq!(frame.cols, 30); + assert_eq!(frame.rows, 12); +} + +#[test] +fn reentrant_step_is_invalid() { + let mut s = session("wipe", 20, 8); + match s.force_reentrant_step() { + Err(EngineError::InvalidArg(_)) => {} + other => panic!("expected invalid arg, got {other:?}"), + } + s.step(1.0 / 60.0).expect("session still live after re-entrant reject"); +} + +#[test] +fn esc_in_art_is_invalid() { + let err = match Session::create( + "\u{1b}[31mX".into(), + "wipe".into(), + [0, 0, 0, 255], + None, + Some(1), + 20, + 8, + ClockKind::Virtual60, + ) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + assert!(matches!(err, EngineError::InvalidArg(_))); +} + +#[test] +fn ascii_line_cap() { + let art = (0..129).map(|_| "X").collect::>().join("\n"); + let err = match Session::create( + art, + "wipe".into(), + [0, 0, 0, 255], + None, + Some(1), + 20, + 8, + ClockKind::Virtual60, + ) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + assert!(matches!(err, EngineError::Limit(_))); +} + +#[test] +fn effect_pool_is_thirty_seven() { + assert_eq!(ttfx::effects::EffectCommand::NAMES.len(), 37); +} diff --git a/crates/omacy-engine/tests/sgr_decode.rs b/crates/omacy-engine/tests/sgr_decode.rs new file mode 100644 index 0000000..0330f9a --- /dev/null +++ b/crates/omacy-engine/tests/sgr_decode.rs @@ -0,0 +1,188 @@ +//! Independent SGR decoder. fill_grid must not call this. + +use omacy_engine::abi::OmacyCell; +use ttfx::engine::terminal::{CELL_HAS_BACKGROUND, CELL_HAS_GLYPH}; +use ttfx::utils::hexterm; + +#[derive(Clone, Copy)] +struct Pen { + fg: Option<[u8; 3]>, + bg: Option<[u8; 3]>, + reverse: bool, +} + +impl Default for Pen { + fn default() -> Self { + Self { + fg: None, + bg: None, + reverse: false, + } + } +} + +pub fn decode_ansi(text: &str, cols: u32, rows: u32, term_bg: [u8; 3]) -> Vec { + let mut cells = vec![OmacyCell::default(); (cols * rows) as usize]; + let mut pen = Pen::default(); + let mut row = 0u32; + let mut col = 0u32; + let bytes = text.as_bytes(); + let chars = text.char_indices().collect::>(); + let mut ci = 0; + while ci < chars.len() { + let (byte_index, ch) = chars[ci]; + if ch == '\u{1b}' { + if byte_index + 1 < bytes.len() && bytes[byte_index + 1] == b'[' { + if let Some((end, params)) = parse_sgr(&bytes[byte_index..]) { + apply_sgr(&mut pen, ¶ms); + let consumed = end; + while ci < chars.len() && chars[ci].0 < byte_index + consumed { + ci += 1; + } + continue; + } + } + } + if ch == '\n' { + row += 1; + col = 0; + ci += 1; + continue; + } + if row < rows && col < cols { + cells[(row * cols + col) as usize] = snapshot(ch, pen, term_bg); + } + col += 1; + ci += 1; + } + cells +} + +fn parse_sgr(bytes: &[u8]) -> Option<(usize, Vec)> { + if bytes.len() < 3 || bytes[0] != 0x1B || bytes[1] != b'[' { + return None; + } + let mut params = Vec::new(); + let mut n: Option = None; + for (idx, b) in bytes[2..].iter().enumerate() { + match *b { + b'0'..=b'9' => { + let d = (*b - b'0') as u16; + n = Some(n.unwrap_or(0).saturating_mul(10).saturating_add(d)); + } + b';' => { + params.push(n.unwrap_or(0)); + n = None; + } + b'm' => { + params.push(n.unwrap_or(0)); + return Some((idx + 3, params)); + } + _ => return None, + } + } + None +} + +fn apply_sgr(pen: &mut Pen, params: &[u16]) { + let mut i = 0; + while i < params.len() { + match params[i] { + 0 => *pen = Pen::default(), + 1 | 22 | 3 | 23 | 4 | 24 => {} + 7 => pen.reverse = true, + 27 => pen.reverse = false, + 30..=37 => pen.fg = Some(ansi16((params[i] - 30) as u8)), + 90..=97 => pen.fg = Some(ansi16((params[i] - 90 + 8) as u8)), + 40..=47 => pen.bg = Some(ansi16((params[i] - 40) as u8)), + 100..=107 => pen.bg = Some(ansi16((params[i] - 100 + 8) as u8)), + 39 => pen.fg = None, + 49 => pen.bg = None, + 38 | 48 => { + let is_fg = params[i] == 38; + if i + 1 < params.len() && params[i + 1] == 5 && i + 2 < params.len() { + let rgb = xterm256(params[i + 2] as u8); + if is_fg { + pen.fg = Some(rgb); + } else { + pen.bg = Some(rgb); + } + i += 2; + } else if i + 1 < params.len() && params[i + 1] == 2 && i + 4 < params.len() { + let rgb = [ + params[i + 2] as u8, + params[i + 3] as u8, + params[i + 4] as u8, + ]; + if is_fg { + pen.fg = Some(rgb); + } else { + pen.bg = Some(rgb); + } + i += 4; + } + } + _ => {} + } + i += 1; + } +} + +fn ansi16(code: u8) -> [u8; 3] { + xterm256(code) +} + +fn xterm256(code: u8) -> [u8; 3] { + let hex = hexterm::xterm_to_hex(code); + let s = hex.trim_matches('#'); + [ + u8::from_str_radix(&s[0..2], 16).unwrap(), + u8::from_str_radix(&s[2..4], 16).unwrap(), + u8::from_str_radix(&s[4..6], 16).unwrap(), + ] +} + +fn snapshot(ch: char, pen: Pen, term_bg: [u8; 3]) -> OmacyCell { + let mut cell = OmacyCell::default(); + let ink = pen.fg.unwrap_or([255, 255, 255]); + let (fg, bg, has_background) = if pen.reverse { + (pen.bg.unwrap_or(term_bg), Some(ink), true) + } else { + (ink, pen.bg, pen.bg.is_some()) + }; + if has_background { + let [r, g, b] = bg.unwrap(); + cell.occupancy |= CELL_HAS_BACKGROUND; + cell.bg_r = r; + cell.bg_g = g; + cell.bg_b = b; + cell.bg_a = 255; + } + if ch != ' ' && ch != '\0' { + cell.occupancy |= CELL_HAS_GLYPH; + cell.glyph = ch as u32; + cell.fg_r = fg[0]; + cell.fg_g = fg[1]; + cell.fg_b = fg[2]; + cell.fg_a = 255; + } + cell +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decoder_compiles() { + let cells = decode_ansi("A", 1, 1, [0, 0, 0]); + assert_eq!(cells[0].glyph, u32::from('A')); + } +} + +pub fn occupancy(cell: &OmacyCell) -> (bool, bool) { + ( + cell.occupancy & CELL_HAS_BACKGROUND != 0, + cell.occupancy & CELL_HAS_GLYPH != 0, + ) +} diff --git a/crates/omacy-engine/tests/thumbnails.rs b/crates/omacy-engine/tests/thumbnails.rs new file mode 100644 index 0000000..7c3ad4a --- /dev/null +++ b/crates/omacy-engine/tests/thumbnails.rs @@ -0,0 +1,28 @@ +use std::collections::HashSet; + +fn imageset() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset") +} + +#[test] +fn screensaver_thumbnails_are_landscape_canary() { + let dir = imageset(); + let one_x = image::open(dir.join("thumbnail.png")).expect("thumbnail.png"); + let two_x = image::open(dir.join("thumbnail@2x.png")).expect("thumbnail@2x.png"); + assert_eq!(one_x.width(), 107); + assert_eq!(one_x.height(), 65); + assert_eq!(two_x.width(), 214); + assert_eq!(two_x.height(), 65 * 2); + + let rgb = one_x.to_rgb8(); + let colors: HashSet<_> = rgb.pixels().map(|p| p.0).collect(); + assert!( + colors.len() >= 3, + "thumbnail must show the canary (field + blank + glyph), got {} colors", + colors.len() + ); + assert!(colors.contains(&[0, 0, 0]), "field is black"); + assert!(colors.iter().any(|c| c[2] > 200 && c[0] < 80), "bottom-left blank is blue"); + assert!(colors.contains(&[255, 255, 255]), "top-right glyph is white"); +} diff --git a/docs/ffi.md b/docs/ffi.md index 06a6d53..56b1a30 100644 --- a/docs/ffi.md +++ b/docs/ffi.md @@ -129,6 +129,9 @@ Layout is fixed-width, not a C `enum` type. The crate asserts `size_of` / `align 16 bytes, `#[repr(C)]`, little-endian. ```c +#define OMACY_CELL_HAS_BACKGROUND 1 /* occupancy bit0 */ +#define OMACY_CELL_HAS_GLYPH 2 /* occupancy bit1 */ + typedef struct { uint32_t glyph; /* Unicode scalar. 0 if !has_glyph. Spaces never set has_glyph. */ uint8_t fg_r, fg_g, fg_b, fg_a; diff --git a/docs/macos-gates.md b/docs/macos-gates.md new file mode 100644 index 0000000..319659a --- /dev/null +++ b/docs/macos-gates.md @@ -0,0 +1,23 @@ +# macOS canary and idle gates + +The engine is proven with `cargo test`. Host `.app` + screensaver `.appex` **compile** on Xcode 26.6 (this Mac, unsigned arm64 Release) and via GitHub Actions `macos.yml` on `macos-26`. Idle / System Settings listing remains a **signed-install Mac gate** and is not claimed by this change. + +Host sources include last-known-good App Group writes, stop-before-start lifecycle, Metal→CALayer canary fallback, occupancy constants, atlas white-pixel UVs, dead-session recreate, pending-config on save, uninstall “move Omacy to Trash” copy, About credit, and missing/unelected appex recovery. Those still need a signed run to execute in ScreenSaverEngine. + +| Check | Status here | +|---|---| +| Host + appex compile (Xcode, ad-hoc) | **Pass** — local Xcode 26.6 `xcodebuild` Release arm64 `BUILD SUCCEEDED`; nested `OmacyScreensaver.appex` (`be.zenjoy.omacy.screensaver`, `com.apple.screensaver`) | +| Install: DMG → `/Applications` or Xcode DerivedData | Unverified | +| Discover: listed with first-party savers in System Settings | Unverified | +| Thumbnail 107×65 / 214×130 | Landscape canary fixture PNGs present; System Settings listing unverified | +| Enable via PaperSaver `setScreensaverEverywhere` and Settings | Sources present; unverified | +| Idle activation on macOS 26 | Unverified | +| Multi-display paints the fixture / engine | Unverified | +| Teardown: `willstop` / window-nil; no leak after Settings open/close | Unverified | +| Uninstall: host unregisters; does not delete the bundle | Unverified | +| Recovery: missing/unelected appex | Unverified | +| macOS 15 repeat | Deferred until a box exists | + +If the canary does not appear in System Settings, activate on idle, and paint every display, stop putting the engine in the appex. The host preview remains the fallback product. + +Force the fixture grid (no engine) with UserDefaults key `omacy.forceCanary`. diff --git a/fastlane/.env.example b/fastlane/.env.example new file mode 100644 index 0000000..a7d2ea5 --- /dev/null +++ b/fastlane/.env.example @@ -0,0 +1,12 @@ +# Optional: same 1Password item Zentty uses. +OP_RELEASE_ENV_VAULT=Engineering +OP_RELEASE_ENV_ITEM=Zentty Release Environment + +# App Store Connect API Key for match + notarization +# Generate at https://appstoreconnect.apple.com/access/integrations/api +ASC_KEY_ID= +ASC_ISSUER_ID= +ASC_KEY_FILEPATH= + +# Match passphrase (same as Zentty — shared cert repo) +MATCH_PASSWORD= diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..ca9d9e7 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,2 @@ +app_identifier "be.zenjoy.omacy" +team_id "25TVW8MSGJ" diff --git a/fastlane/ExportOptions.plist b/fastlane/ExportOptions.plist new file mode 100644 index 0000000..3f89231 --- /dev/null +++ b/fastlane/ExportOptions.plist @@ -0,0 +1,21 @@ + + + + + method + developer-id + teamID + 25TVW8MSGJ + signingStyle + manual + signingCertificate + Developer ID Application + provisioningProfiles + + be.zenjoy.omacy + match Direct be.zenjoy.omacy macos + be.zenjoy.omacy.screensaver + match Direct be.zenjoy.omacy.screensaver macos + + + diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..ceb7901 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,129 @@ +require "json" +require "open3" + +default_platform(:mac) + +APP_NAME = "Omacy" +SCHEME = "Omacy" +PROJECT = "apps/Omacy/Omacy.xcodeproj" +BUILD_DIR = "build/release" +APP_PATH = "#{BUILD_DIR}/#{APP_NAME}.app" +PROJECT_ROOT = File.expand_path("..", __dir__) +BUNDLE_IDS = [ + "be.zenjoy.omacy", + "be.zenjoy.omacy.screensaver" +].freeze + +def capture_command!(*args, chdir: PROJECT_ROOT, error_message: nil) + stdout, stderr, status = Open3.capture3(*args, chdir: chdir) + return stdout if status.success? + + message = error_message || "Command failed: #{args.join(" ")}" + message += "\n#{stderr.strip}" unless stderr.strip.empty? + UI.user_error!(message) +end + +def hydrate_release_env_from_one_password! + return if defined?(@release_env_loaded) && @release_env_loaded + + @release_env_loaded = true + ENV["OP_RELEASE_ENV_ITEM"] = "Zentty Release Environment" if ENV["OP_RELEASE_ENV_ITEM"].to_s.strip.empty? + ENV["OP_RELEASE_ENV_VAULT"] = "Engineering" if ENV["OP_RELEASE_ENV_VAULT"].to_s.strip.empty? + item = ENV["OP_RELEASE_ENV_ITEM"].to_s.strip + return if item.empty? + + args = ["op", "item", "get", item, "--format", "json", "--reveal"] + vault = ENV["OP_RELEASE_ENV_VAULT"].to_s.strip + args += ["--vault", vault] unless vault.empty? + payload = JSON.parse(capture_command!(*args, error_message: "Failed to read release env from 1Password")) + + Array(payload["fields"]).each do |field| + next if field["purpose"] == "PASSWORD" || field["purpose"] == "NOTES" + + key = field["label"] || field["id"] + env_key = key.to_s.strip.upcase.gsub(/[^A-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "") + next if env_key.empty? + + value = field["value"].to_s + next if value.empty? + + ENV[env_key] = value if ENV[env_key].to_s.empty? + end +end + +def required_env!(name) + hydrate_release_env_from_one_password! + value = ENV[name].to_s.strip + UI.user_error!("#{name} must be set") if value.empty? + value +end + +def resolve_asc_key_filepath + path = required_env!("ASC_KEY_FILEPATH") + candidates = [ + path, + File.expand_path(path, PROJECT_ROOT), + File.expand_path(path, File.join(PROJECT_ROOT, "fastlane")), + File.expand_path(File.join("..", "zentty", path), PROJECT_ROOT), + File.expand_path(File.join("..", "zentty", "fastlane", File.basename(path)), PROJECT_ROOT) + ] + hit = candidates.find { |candidate| File.file?(candidate) } + UI.user_error!("ASC_KEY_FILEPATH not found: #{path}") unless hit + hit +end + +def load_api_key + app_store_connect_api_key( + key_id: required_env!("ASC_KEY_ID"), + issuer_id: required_env!("ASC_ISSUER_ID"), + key_filepath: resolve_asc_key_filepath + ) +end + +def sync_signing!(readonly:) + required_env!("MATCH_PASSWORD") + match( + type: "developer_id", + platform: "macos", + app_identifier: BUNDLE_IDS, + readonly: readonly, + api_key: load_api_key + ) +end + +platform :mac do + desc "Create or update Developer ID profiles in the shared match repo" + lane :generate_signing do + sync_signing!(readonly: false) + end + + desc "Install Developer ID cert + profiles (readonly)" + lane :sync_signing do + sync_signing!(readonly: true) + end + + desc "Archive and export with Developer ID signing" + lane :archive do + sync_signing!(readonly: true) + build_mac_app( + project: PROJECT, + scheme: SCHEME, + configuration: "Release", + clean: true, + output_directory: BUILD_DIR, + output_name: APP_NAME, + export_method: "developer-id", + export_options: "fastlane/ExportOptions.plist", + xcargs: "ARCHS=arm64 ONLY_ACTIVE_ARCH=YES EXCLUDED_ARCHS=x86_64" + ) + end + + desc "Notarize the exported .app" + lane :notarize_app do + notarize( + package: APP_PATH, + api_key: load_api_key, + print_log: true + ) + end +end diff --git a/fastlane/Matchfile b/fastlane/Matchfile new file mode 100644 index 0000000..c0972c0 --- /dev/null +++ b/fastlane/Matchfile @@ -0,0 +1,11 @@ +git_url "git@github.com:dedene/zentty-certificates.git" + +type "developer_id" +platform "macos" +app_identifier [ + "be.zenjoy.omacy", + "be.zenjoy.omacy.screensaver" +] +team_id "25TVW8MSGJ" + +storage_mode "git" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..7855e6d --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.88.0" +components = ["rustfmt", "clippy"] diff --git a/vendor/ttfx/.gitattributes b/vendor/ttfx/.gitattributes new file mode 100644 index 0000000..f2b947a --- /dev/null +++ b/vendor/ttfx/.gitattributes @@ -0,0 +1,2 @@ +tools/parity/cases.txt merge=union +docs/ordering-inventory.md merge=union diff --git a/vendor/ttfx/.github/workflows/ci.yml b/vendor/ttfx/.github/workflows/ci.yml new file mode 100644 index 0000000..009a312 --- /dev/null +++ b/vendor/ttfx/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + name: Tests + parity suite + # Parity CI is pinned to one platform/toolchain pair (plan.md §5.20): + # cross-platform builds are release targets, not parity targets. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: ./bin/test + + test-macos: + name: Tests (macOS) + # bin/test skips the parity suites off Linux, so what's left here proves the + # port builds, tests green (with the boundary-tolerant easing assertion for + # Apple's libm), and honors the CLI and signal contracts on macOS. + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: ./bin/test + - uses: actions/upload-artifact@v4 + with: + name: ttfx-aarch64-apple-darwin + path: target/release/ttfx + + release-binary: + name: Static musl binary + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl + - run: sudo apt-get update && sudo apt-get install -y musl-tools + - run: cargo build --release --target x86_64-unknown-linux-musl + - uses: actions/upload-artifact@v4 + with: + name: ttfx-x86_64-unknown-linux-musl + path: target/x86_64-unknown-linux-musl/release/ttfx diff --git a/vendor/ttfx/.gitignore b/vendor/ttfx/.gitignore new file mode 100644 index 0000000..deba4cc --- /dev/null +++ b/vendor/ttfx/.gitignore @@ -0,0 +1,5 @@ +/target +/tools/parity/inputs/ +__pycache__/ +.claude/ +/reference/tte/ diff --git a/vendor/ttfx/Cargo.lock b/vendor/ttfx/Cargo.lock new file mode 100644 index 0000000..aecf7ae --- /dev/null +++ b/vendor/ttfx/Cargo.lock @@ -0,0 +1,248 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys", +] + +[[package]] +name = "ttfx" +version = "0.3.2" +dependencies = [ + "clap", + "clap_complete", + "terminal_size", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/vendor/ttfx/Cargo.toml b/vendor/ttfx/Cargo.toml new file mode 100644 index 0000000..ab1b599 --- /dev/null +++ b/vendor/ttfx/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ttfx" +version = "0.3.2" +edition = "2021" +description = "Terminal text effects — a Rust port of terminaltexteffects (TTE)" +license = "MIT" + +[dependencies] +clap = { version = "4", features = ["derive"] } +clap_complete = "4.6.9" +terminal_size = "0.4" + +[profile.release] +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/vendor/ttfx/LICENSE b/vendor/ttfx/LICENSE new file mode 100644 index 0000000..3de7372 --- /dev/null +++ b/vendor/ttfx/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 37signals / omacom-io +Copyright (c) 2023 ChrisBuilds (TerminalTextEffects, from which this is ported) + +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/vendor/ttfx/NOTICE b/vendor/ttfx/NOTICE new file mode 100644 index 0000000..01f6cb3 --- /dev/null +++ b/vendor/ttfx/NOTICE @@ -0,0 +1,16 @@ +ttfx is a port of TerminalTextEffects (TTE). + + TerminalTextEffects + https://github.com/ChrisBuilds/terminaltexteffects + MIT License, Copyright (c) 2023 ChrisBuilds + +Every effect in this program, the animation engine's architecture, and the +command-line interface are that project's design. This port translates the +work to Rust and reproduces its rendering exactly — frame for frame, byte for +byte — but originates none of the art. + +The parity test suite compares against upstream at commit +7a91dd9ca6ee0c4f4b1484efee0ecac1bb84104e (v0.15.0). That source is not +vendored here; tools/parity/fetch_reference.sh clones it on demand. + +If you enjoy these effects, the credit belongs upstream. diff --git a/vendor/ttfx/PIN b/vendor/ttfx/PIN new file mode 100644 index 0000000..ac489c5 --- /dev/null +++ b/vendor/ttfx/PIN @@ -0,0 +1,9 @@ +7203e354498462064b7c0a89375051f65cf2ce99 + +Patches (engine seams only; no effect-file forks): + +- Terminal::fill_grid + present-grid snapshot at frame()/ANSI emit +- TerminalConfig::gui() +- EngineCtx.suppress_ansi +- Effect::advance default method +- EffectCommand::from_name diff --git a/vendor/ttfx/README.md b/vendor/ttfx/README.md new file mode 100644 index 0000000..1472a73 --- /dev/null +++ b/vendor/ttfx/README.md @@ -0,0 +1,155 @@ +# ttfx + +Terminal text effects as a single static binary. Pipe text in, pick an effect: + +```sh +ls -la | ttfx decrypt +cat banner.txt | ttfx beams +fortune | ttfx --random-effect +git log --oneline -10 | ttfx matrix +``` + +the decrypt effect resolving the Omarchy logo + +## Credit where it's due + +**This is a port of [TerminalTextEffects](https://github.com/ChrisBuilds/terminaltexteffects) +(TTE) by [ChrisBuilds](https://github.com/ChrisBuilds).** Every effect, the animation engine, +and the command-line interface are their design — this project translates that work to Rust +and adds nothing of its own to the art. If you like what you see here, star the original. + +TTE is MIT licensed and so is this port; the original copyright is preserved in +[LICENSE](LICENSE) and [NOTICE](NOTICE). Please file *effect* ideas upstream, where they belong. + +## Why a port + +TTE is a Python package. That's the right call for a library, but for a shell toy that lives in +your prompt pipeline it means an interpreter, an install step, and ~65 ms of import before the +first frame. ttfx is one dependency-free binary that starts in half a millisecond. + +That difference is the whole reason this exists. On a fullscreen canvas the heavier effects run +out of headroom under Python. Time to render a whole animation, pacing disabled so this measures +throughput rather than `sleep()`: + +| At 200×50 cells | frames | ttfx | Python TTE | ttfx fps | +|---|---|---|---|---| +| slide | 375 | 76 ms | 2,203 ms | 4,930 | +| beams | 732 | 181 ms | 5,564 ms | 4,050 | +| rings | 1,566 | 521 ms | 10,439 ms | 3,004 | +| waves | 633 | 374 ms | 8,745 ms | 1,693 | +| startup | — | 0.5 ms | 64 ms | — | + +Across the 35 effects that aren't gated on wall-clock time, the median speedup is **27.5×** +(range 17.1×–47.4×). The two that are gated — `matrix` and `thunderstorm` — spend most of their +runtime in a fixed animation duration that no implementation can shorten, so they come in at +1.9× and 1.3×; what ttfx buys there is a far higher frame rate inside that window, not a shorter +one. + +Reproduce it with `python3 tools/tests/bench_full.py`, or set `TTFX_BENCH_COLS`, `TTFX_BENCH_LINES` +and `TTFX_BENCH_FILL=1` for the fullscreen numbers above. Both sides run their real user-facing +command, best of five. + +## The effects + +All 37, each animating the Omarchy logo. Every frame below came out of the Rust binary — and is +byte-identical to what the Python original produces from the same input and seed. + +| | | +|:---:|:---:| +| beams
beams
Create beams which travel over the canvas illuminating the characters behind them | binarypath
binarypath
Binary representations of each character move towards the home coordinate of the character | +| blackhole
blackhole
Characters are consumed by a black hole and explode outwards | bouncyballs
bouncyballs
Characters are bouncy balls falling from the top of the canvas | +| bubbles
bubbles
Characters are formed into bubbles that float down and pop | burn
burn
Burns vertically in the canvas | +| colorshift
colorshift
Display a gradient that shifts colors across the terminal | crumble
crumble
Characters lose color and crumble into dust, vacuumed up, and reformed | +| decrypt
decrypt
Display a movie style decryption effect | errorcorrect
errorcorrect
Some characters start in the wrong position and are corrected in sequence | +| expand
expand
Expands the text from a single point | fireworks
fireworks
Characters launch and explode like fireworks and fall into place | +| highlight
highlight
Run a specular highlight across the text | laseretch
laseretch
A laser etches characters onto the terminal | +| matrix
matrix
Matrix digital rain effect | middleout
middleout
Text expands in a single row or column in the middle of the canvas then out | +| orbittingvolley
orbittingvolley
Four launchers orbit the canvas firing volleys of characters inward to build the input text from the center out | overflow
overflow
Input text overflows and scrolls the terminal in a random order until eventually appearing ordered | +| pour
pour
Pours the characters into position from the given direction | print
print
Lines are printed one at a time following a print head. Print head performs line feed, carriage return | +| rain
rain
Rain characters from the top of the canvas | randomsequence
randomsequence
Prints the input data in a random sequence | +| rings
rings
Characters are dispersed and form into spinning rings | scattered
scattered
Text is scattered across the canvas and moves into position | +| slice
slice
Slices the input in half and slides it into place from opposite directions | slide
slide
Slide characters into view from outside the terminal | +| smoke
smoke
Smoke floods the canvas colorizing any characters it crosses | spotlights
spotlights
Spotlights search the text area, illuminating characters, before converging in the center and expanding | +| spray
spray
Draws the characters spawning at varying rates from a single point | swarm
swarm
Characters are grouped into swarms and move around the terminal before settling into position | +| sweep
sweep
Sweep across the canvas to reveal uncolored text, reverse sweep to color the text | synthgrid
synthgrid
Create a grid which fills with characters dissolving into the final text | +| thunderstorm
thunderstorm
Create a thunderstorm in the terminal | unstable
unstable
Spawn characters jumbled, explode them to the edge of the canvas, then reassemble them in the correct layout | +| vhstape
vhstape
Lines of characters glitch left and right and lose detail like an old VHS tape | waves
waves
Waves travel across the terminal leaving behind the characters | +| wipe
wipe
Wipes the text across the terminal to reveal characters | | + +Every effect takes its own options — `ttfx --help`. A few of the GIFs above shorten a +timed phase so the loop stays watchable (`matrix --rain-time 3`, `thunderstorm --storm-time 3`, +`vhstape --total-glitch-time 250`, `spotlights --search-duration 80`, `errorcorrect +--error-pairs 0.5`); everything else is stock. + +## Fidelity + +This is a *parity port*, not a reimplementation-in-spirit. Given the same input, config, and +random draws, ttfx produces **byte-identical frames** to the Python original — verified +mechanically in CI against a pinned upstream checkout (v0.15.0), not by eyeballing. + +| Suite | Checks | What it proves | +|---|---|---| +| `tools/parity/run_suite.sh` | 354 | every effect's frame stream, byte for byte, across configs and seeds | +| `tools/parity/tty_compare.sh` | 41 | the full terminal byte stream — canvas prep, cursor moves, teardown | +| `tools/tests/cli_corpus.sh` | 19 | exit codes and stdout/stderr routing | +| `tools/tests/*_behavior.py` | pty | what only a real terminal shows: resize restarts, signal teardown | +| `cargo test` | goldens + traces | easing/geometry/gradient values and engine state machines | + +`./bin/test` runs the lot, which is all CI does. + +Making that possible meant reproducing upstream's quirks deliberately, not "fixing" them: +Python's banker's rounding, gradients built from integer floor division rather than float +interpolation, a bezier arc-length approximation that drops its final segment, and looping +scenes that report themselves complete on every tick. They're catalogued in +[`plan.md`](plan.md); the places where Python's unordered iteration had to be pinned down are +in [`docs/ordering-inventory.md`](docs/ordering-inventory.md). + +**Two deliberate differences.** Random number generation is not bit-compatible with CPython — +ttfx uses xoshiro256++, so `--seed` is reproducible within ttfx but won't match Python's +Mersenne Twister. (The parity harness swaps a shared PRNG into both sides, which is what makes +frame comparison possible at all.) And Python plugin effects aren't supported, since there's +no interpreter to load them. + +## Usage + +``` + | ttfx [terminal options] [effect options] + +ttfx --help # all 37 effects and the terminal options +ttfx --help # options for one effect +ttfx --random-effect # surprise me (--include-effects / --exclude-effects to filter) +ttfx --print-completion bash|zsh +``` + +Terminal options (canvas size and anchoring, color handling, frame rate, text wrapping) go +before the effect name; effect options after it. Option names and defaults match `tte`, so +existing invocations work with the binary name swapped. + +## Building + +```sh +cargo build --release +cargo build --release --target x86_64-unknown-linux-musl # static, ~3.3 MB +``` + +`./bin/test` runs every suite. It needs python3, and the parity half needs a copy of +upstream, which it clones at the pinned commit on first run: + +```sh +./tools/parity/fetch_reference.sh # what bin/test calls; safe to run by hand +``` + +Upstream is not vendored here — the harness fetches it, because it's their code. + +## Scope + +Linux and macOS. Built for [Omarchy](https://omarchy.org) originally; nothing targets a +specific libc, and CI runs the tests and CLI corpus on both platforms. The byte-exact +parity suites stay pinned to Linux/glibc — Apple's libm rounds a few transcendentals a +last-ulp differently, which quantization hides in real frames but a bit-exact comparison +would surface. + +## License + +MIT — see [LICENSE](LICENSE), which carries both this project's copyright and the original +TerminalTextEffects copyright, and [NOTICE](NOTICE) for the attribution in full. diff --git a/vendor/ttfx/bin/build b/vendor/ttfx/bin/build new file mode 100755 index 0000000..1f699b3 --- /dev/null +++ b/vendor/ttfx/bin/build @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" + +if ! command -v cargo >/dev/null 2>&1; then + echo "cargo is required to build ttfx." >&2 + exit 1 +fi + +cd "$ROOT" +cargo build --release "$@" + +echo "Built $ROOT/target/release/ttfx" diff --git a/vendor/ttfx/bin/release b/vendor/ttfx/bin/release new file mode 100755 index 0000000..10c563d --- /dev/null +++ b/vendor/ttfx/bin/release @@ -0,0 +1,194 @@ +#!/usr/bin/env sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +PKGS="${OMARCHY_PKGS:-$HOME/Work/omacom/omarchy-pkgs}" +SKIP_PKGS=0 +VERSION="" + +usage() { + cat <<'EOF' +Usage: bin/release [version] [--skip-pkgs] + +Cuts a ttfx release: runs the tests, bumps Cargo.toml, tags and pushes, creates +the GitHub release with generated notes, then points the omarchy-pkgs PKGBUILD +at the new tarball. + + version 0.4.0 or v0.4.0. Defaults to bumping the last tag's patch number. + --skip-pkgs Leave omarchy-pkgs alone. Implied when it isn't checked out. + +OMARCHY_PKGS overrides where omarchy-pkgs lives +(default: $HOME/Work/omacom/omarchy-pkgs). +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --skip-pkgs) SKIP_PKGS=1 ;; + -h | --help) + usage + exit 0 + ;; + -*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + if [ -n "$VERSION" ]; then + echo "Unexpected argument: $1" >&2 + exit 1 + fi + VERSION="$1" + ;; + esac + shift +done + +for tool in cargo git gh curl sha256sum tar awk; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "$tool is required to release ttfx." >&2 + exit 1 + fi +done + +cd "$ROOT" + +if [ -n "$(git status --porcelain)" ]; then + echo "Working tree is dirty; commit or stash before releasing." >&2 + exit 1 +fi + +PREV="$(git describe --tags --abbrev=0 2>/dev/null || true)" + +if [ -z "$VERSION" ]; then + if [ -z "$PREV" ]; then + echo "No v* tag to bump from; pass a version explicitly." >&2 + exit 1 + fi + VERSION="$(echo "${PREV#v}" | awk -F. -v OFS=. '{ $NF += 1; print }')" +fi + +VERSION="${VERSION#v}" +TAG="v$VERSION" + +case "$VERSION" in +[0-9]*.[0-9]*.[0-9]*) ;; +*) + echo "Version must look like 0.4.0; got '$VERSION'." >&2 + exit 1 + ;; +esac + +if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "Tag $TAG already exists." >&2 + exit 1 +fi + +if [ -n "$(git ls-remote --tags origin "refs/tags/$TAG")" ]; then + echo "Tag $TAG already exists on origin." >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "gh is not authenticated; run: gh auth login" >&2 + exit 1 +fi + +echo "Releasing ttfx $TAG (previous: ${PREV:-none})" + +"$ROOT/bin/test" + +# From here until the commit lands, put Cargo.toml back if anything fails. +trap 'git -C "$ROOT" checkout -- Cargo.toml Cargo.lock 2>/dev/null || true' EXIT + +awk -v v="$VERSION" ' + !bumped && /^version = "/ { sub(/"[^"]*"/, "\"" v "\""); bumped = 1 } + { print } +' Cargo.toml >Cargo.toml.new && mv Cargo.toml.new Cargo.toml + +if ! grep -q "^version = \"$VERSION\"$" Cargo.toml; then + echo "Failed to set version in Cargo.toml." >&2 + exit 1 +fi + +# Also refreshes Cargo.lock, so both go into the bump commit. +"$ROOT/bin/build" + +BUILT="$("$ROOT/target/release/ttfx" --version)" +if [ "$BUILT" != "ttfx $VERSION" ]; then + echo "Built binary reports '$BUILT', expected 'ttfx $VERSION'." >&2 + exit 1 +fi + +git add Cargo.toml Cargo.lock +git commit -m "Bump to $VERSION" +trap - EXIT + +git push +git tag "$TAG" +git push origin "$TAG" + +RELEASE_URL="$(gh release create "$TAG" --title "$TAG" --generate-notes)" + +PKGBUILD="$PKGS/pkgbuilds/ttfx/PKGBUILD" +SHA="" + +if [ "$SKIP_PKGS" -eq 1 ]; then + echo "Skipping omarchy-pkgs (--skip-pkgs)." +elif [ ! -f "$PKGBUILD" ]; then + echo "Skipping omarchy-pkgs: no PKGBUILD at $PKGBUILD." +else + TMP="$(mktemp -d)" + trap 'rm -rf "$TMP"' EXIT + + TARBALL="$TMP/ttfx-$VERSION.tar.gz" + URL="https://github.com/omacom-io/ttfx/archive/refs/tags/$TAG.tar.gz" + + # GitHub can lag a few seconds behind the tag push. + attempt=1 + while ! curl -fsSL -o "$TARBALL" "$URL"; do + if [ "$attempt" -ge 5 ]; then + echo "Could not download $URL" >&2 + exit 1 + fi + echo " tarball not ready yet, retrying" + sleep 3 + attempt=$((attempt + 1)) + done + + if ! tar tzf "$TARBALL" | grep -q "^ttfx-$VERSION/"; then + echo "Tarball doesn't contain ttfx-$VERSION/." >&2 + exit 1 + fi + + SHA="$(sha256sum "$TARBALL" | cut -d' ' -f1)" + + # Only the first sha256sums entry is the tarball; later ones are local files. + awk -v v="$VERSION" -v sha="$SHA" -v q="'" ' + /^pkgver=/ { print "pkgver=" v; next } + /^pkgrel=/ { print "pkgrel=1"; next } + !hashed && /^sha256sums=/ { sub(q "[^" q "]*" q, q sha q); hashed = 1 } + { print } + ' "$PKGBUILD" >"$PKGBUILD.new" && mv "$PKGBUILD.new" "$PKGBUILD" + + if ! grep -q "^pkgver=$VERSION$" "$PKGBUILD" || ! grep -q "$SHA" "$PKGBUILD"; then + echo "PKGBUILD was not updated as expected; leaving it for you to inspect." >&2 + exit 1 + fi + + if command -v bash >/dev/null 2>&1; then + bash -n "$PKGBUILD" + fi + + git -C "$PKGS" add pkgbuilds/ttfx + git -C "$PKGS" commit -m "Update ttfx to $TAG" + git -C "$PKGS" push +fi + +echo +echo "Released ttfx $TAG" +echo " $RELEASE_URL" +if [ -n "$SHA" ]; then + echo " omarchy-pkgs updated to $SHA" +fi diff --git a/vendor/ttfx/bin/test b/vendor/ttfx/bin/test new file mode 100755 index 0000000..39dd719 --- /dev/null +++ b/vendor/ttfx/bin/test @@ -0,0 +1,31 @@ +#!/usr/bin/env sh +# Everything CI runs, in one command. Nothing here is optional: if it passes +# locally it passes on the runner, and the workflow is just a call to this. +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" + +for tool in cargo python3; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "$tool is required to run the ttfx tests." >&2 + exit 1 + fi +done + +cd "$ROOT" + +cargo test --release +"$ROOT/bin/build" +./tools/tests/cli_corpus.sh +python3 tools/tests/sigterm_behavior.py +python3 tools/tests/terminal_close_behavior.py + +# Bit-exact parity is pinned to Linux/glibc (plan.md §5.20) — elsewhere the +# reference was never promised to match. The resize suite goes with it: its +# timings are calibrated on that runner, so a slower one would only flake. +if [ "$(uname -s)" = "Linux" ]; then + ./tools/parity/fetch_reference.sh + ./tools/parity/run_suite.sh + ./tools/parity/tty_compare.sh + python3 tools/tests/resize_behavior.py +fi diff --git a/vendor/ttfx/docs/effects/beams.gif b/vendor/ttfx/docs/effects/beams.gif new file mode 100644 index 0000000..12a67ed Binary files /dev/null and b/vendor/ttfx/docs/effects/beams.gif differ diff --git a/vendor/ttfx/docs/effects/binarypath.gif b/vendor/ttfx/docs/effects/binarypath.gif new file mode 100644 index 0000000..0cef0fa Binary files /dev/null and b/vendor/ttfx/docs/effects/binarypath.gif differ diff --git a/vendor/ttfx/docs/effects/blackhole.gif b/vendor/ttfx/docs/effects/blackhole.gif new file mode 100644 index 0000000..d7b536a Binary files /dev/null and b/vendor/ttfx/docs/effects/blackhole.gif differ diff --git a/vendor/ttfx/docs/effects/bouncyballs.gif b/vendor/ttfx/docs/effects/bouncyballs.gif new file mode 100644 index 0000000..8743c95 Binary files /dev/null and b/vendor/ttfx/docs/effects/bouncyballs.gif differ diff --git a/vendor/ttfx/docs/effects/bubbles.gif b/vendor/ttfx/docs/effects/bubbles.gif new file mode 100644 index 0000000..7cc8698 Binary files /dev/null and b/vendor/ttfx/docs/effects/bubbles.gif differ diff --git a/vendor/ttfx/docs/effects/burn.gif b/vendor/ttfx/docs/effects/burn.gif new file mode 100644 index 0000000..6ae5cd0 Binary files /dev/null and b/vendor/ttfx/docs/effects/burn.gif differ diff --git a/vendor/ttfx/docs/effects/colorshift.gif b/vendor/ttfx/docs/effects/colorshift.gif new file mode 100644 index 0000000..7cd6ece Binary files /dev/null and b/vendor/ttfx/docs/effects/colorshift.gif differ diff --git a/vendor/ttfx/docs/effects/crumble.gif b/vendor/ttfx/docs/effects/crumble.gif new file mode 100644 index 0000000..7f37f7a Binary files /dev/null and b/vendor/ttfx/docs/effects/crumble.gif differ diff --git a/vendor/ttfx/docs/effects/decrypt.gif b/vendor/ttfx/docs/effects/decrypt.gif new file mode 100644 index 0000000..54834fb Binary files /dev/null and b/vendor/ttfx/docs/effects/decrypt.gif differ diff --git a/vendor/ttfx/docs/effects/errorcorrect.gif b/vendor/ttfx/docs/effects/errorcorrect.gif new file mode 100644 index 0000000..26caba7 Binary files /dev/null and b/vendor/ttfx/docs/effects/errorcorrect.gif differ diff --git a/vendor/ttfx/docs/effects/expand.gif b/vendor/ttfx/docs/effects/expand.gif new file mode 100644 index 0000000..d772947 Binary files /dev/null and b/vendor/ttfx/docs/effects/expand.gif differ diff --git a/vendor/ttfx/docs/effects/fireworks.gif b/vendor/ttfx/docs/effects/fireworks.gif new file mode 100644 index 0000000..ddfc51a Binary files /dev/null and b/vendor/ttfx/docs/effects/fireworks.gif differ diff --git a/vendor/ttfx/docs/effects/highlight.gif b/vendor/ttfx/docs/effects/highlight.gif new file mode 100644 index 0000000..056f03b Binary files /dev/null and b/vendor/ttfx/docs/effects/highlight.gif differ diff --git a/vendor/ttfx/docs/effects/laseretch.gif b/vendor/ttfx/docs/effects/laseretch.gif new file mode 100644 index 0000000..ff010f3 Binary files /dev/null and b/vendor/ttfx/docs/effects/laseretch.gif differ diff --git a/vendor/ttfx/docs/effects/matrix.gif b/vendor/ttfx/docs/effects/matrix.gif new file mode 100644 index 0000000..a587e47 Binary files /dev/null and b/vendor/ttfx/docs/effects/matrix.gif differ diff --git a/vendor/ttfx/docs/effects/middleout.gif b/vendor/ttfx/docs/effects/middleout.gif new file mode 100644 index 0000000..d669b38 Binary files /dev/null and b/vendor/ttfx/docs/effects/middleout.gif differ diff --git a/vendor/ttfx/docs/effects/orbittingvolley.gif b/vendor/ttfx/docs/effects/orbittingvolley.gif new file mode 100644 index 0000000..36d6b58 Binary files /dev/null and b/vendor/ttfx/docs/effects/orbittingvolley.gif differ diff --git a/vendor/ttfx/docs/effects/overflow.gif b/vendor/ttfx/docs/effects/overflow.gif new file mode 100644 index 0000000..9bf67d5 Binary files /dev/null and b/vendor/ttfx/docs/effects/overflow.gif differ diff --git a/vendor/ttfx/docs/effects/pour.gif b/vendor/ttfx/docs/effects/pour.gif new file mode 100644 index 0000000..476859f Binary files /dev/null and b/vendor/ttfx/docs/effects/pour.gif differ diff --git a/vendor/ttfx/docs/effects/print.gif b/vendor/ttfx/docs/effects/print.gif new file mode 100644 index 0000000..8f59c6f Binary files /dev/null and b/vendor/ttfx/docs/effects/print.gif differ diff --git a/vendor/ttfx/docs/effects/rain.gif b/vendor/ttfx/docs/effects/rain.gif new file mode 100644 index 0000000..d659117 Binary files /dev/null and b/vendor/ttfx/docs/effects/rain.gif differ diff --git a/vendor/ttfx/docs/effects/randomsequence.gif b/vendor/ttfx/docs/effects/randomsequence.gif new file mode 100644 index 0000000..7f03ffb Binary files /dev/null and b/vendor/ttfx/docs/effects/randomsequence.gif differ diff --git a/vendor/ttfx/docs/effects/rings.gif b/vendor/ttfx/docs/effects/rings.gif new file mode 100644 index 0000000..7337412 Binary files /dev/null and b/vendor/ttfx/docs/effects/rings.gif differ diff --git a/vendor/ttfx/docs/effects/scattered.gif b/vendor/ttfx/docs/effects/scattered.gif new file mode 100644 index 0000000..126424d Binary files /dev/null and b/vendor/ttfx/docs/effects/scattered.gif differ diff --git a/vendor/ttfx/docs/effects/slice.gif b/vendor/ttfx/docs/effects/slice.gif new file mode 100644 index 0000000..49f0973 Binary files /dev/null and b/vendor/ttfx/docs/effects/slice.gif differ diff --git a/vendor/ttfx/docs/effects/slide.gif b/vendor/ttfx/docs/effects/slide.gif new file mode 100644 index 0000000..7ec164b Binary files /dev/null and b/vendor/ttfx/docs/effects/slide.gif differ diff --git a/vendor/ttfx/docs/effects/smoke.gif b/vendor/ttfx/docs/effects/smoke.gif new file mode 100644 index 0000000..f7e27bb Binary files /dev/null and b/vendor/ttfx/docs/effects/smoke.gif differ diff --git a/vendor/ttfx/docs/effects/spotlights.gif b/vendor/ttfx/docs/effects/spotlights.gif new file mode 100644 index 0000000..894e14f Binary files /dev/null and b/vendor/ttfx/docs/effects/spotlights.gif differ diff --git a/vendor/ttfx/docs/effects/spray.gif b/vendor/ttfx/docs/effects/spray.gif new file mode 100644 index 0000000..85cc7da Binary files /dev/null and b/vendor/ttfx/docs/effects/spray.gif differ diff --git a/vendor/ttfx/docs/effects/swarm.gif b/vendor/ttfx/docs/effects/swarm.gif new file mode 100644 index 0000000..696d20a Binary files /dev/null and b/vendor/ttfx/docs/effects/swarm.gif differ diff --git a/vendor/ttfx/docs/effects/sweep.gif b/vendor/ttfx/docs/effects/sweep.gif new file mode 100644 index 0000000..52b9ea6 Binary files /dev/null and b/vendor/ttfx/docs/effects/sweep.gif differ diff --git a/vendor/ttfx/docs/effects/synthgrid.gif b/vendor/ttfx/docs/effects/synthgrid.gif new file mode 100644 index 0000000..3c9275e Binary files /dev/null and b/vendor/ttfx/docs/effects/synthgrid.gif differ diff --git a/vendor/ttfx/docs/effects/thunderstorm.gif b/vendor/ttfx/docs/effects/thunderstorm.gif new file mode 100644 index 0000000..504d972 Binary files /dev/null and b/vendor/ttfx/docs/effects/thunderstorm.gif differ diff --git a/vendor/ttfx/docs/effects/unstable.gif b/vendor/ttfx/docs/effects/unstable.gif new file mode 100644 index 0000000..26e16ad Binary files /dev/null and b/vendor/ttfx/docs/effects/unstable.gif differ diff --git a/vendor/ttfx/docs/effects/vhstape.gif b/vendor/ttfx/docs/effects/vhstape.gif new file mode 100644 index 0000000..4144bf1 Binary files /dev/null and b/vendor/ttfx/docs/effects/vhstape.gif differ diff --git a/vendor/ttfx/docs/effects/waves.gif b/vendor/ttfx/docs/effects/waves.gif new file mode 100644 index 0000000..903e750 Binary files /dev/null and b/vendor/ttfx/docs/effects/waves.gif differ diff --git a/vendor/ttfx/docs/effects/wipe.gif b/vendor/ttfx/docs/effects/wipe.gif new file mode 100644 index 0000000..dcfda31 Binary files /dev/null and b/vendor/ttfx/docs/effects/wipe.gif differ diff --git a/vendor/ttfx/docs/ordering-inventory.md b/vendor/ttfx/docs/ordering-inventory.md new file mode 100644 index 0000000..472171f --- /dev/null +++ b/vendor/ttfx/docs/ordering-inventory.md @@ -0,0 +1,79 @@ +# Unordered-iteration inventory (plan.md §4.3) + +Every site where upstream TTE iterates an unordered container (set) or relies +on dict insertion order, with the canonical deterministic order used by ttfx +and matched by the parity shim. Extend this file as effect ports discover new +sites — the parity harness surfaces them as first-divergence failures. + +## Engine (sets → canonical order) + +| Upstream site | Container | Canonical order | ttfx | shim patch | +|---|---|---|---|---| +| `BaseEffectIterator.update` (base_effect.py:92) | `active_characters` set | ascending `character_id` | `BTreeSet` | sort snapshot by id | +| `Terminal._update_terminal_state` (terminal.py:1376) layer ties | `_visible_characters` set | `(layer, character_id)` | sort at render | sort key patched | +| `EffectCharacter.links` iteration in `BreadthFirst.step` (breadthfirst.py:90) | `links` set | ascending `character_id` | id-sorted `Vec` | `sorted(links, key=id)` | + +## Effects (sets → canonical order) + +| Upstream site | Container | Canonical order | ttfx | shim patch | +|---|---|---|---|---| +| `MiddleOutIterator.__next__` full-phase activation loop (effect_middleout.py:229) | rebuilt `active_characters` set | ascending `character_id` | iterate `BTreeSet` | `MiddleOutIterator.__next__` sorts by id | +| `UnstableIterator.__next__` explosion/reassembly tick loops (effect_unstable.py:332, :354) | `active_characters` set | ascending `character_id` | iterate `BTreeSet` snapshot | `UnstableIterator.__next__` sorts by id | + +## Engine (dict insertion order = behavior) + +| Upstream site | ttfx | +|---|---| +| `Motion.paths` / `Animation.scenes` values iteration (e.g. swarm chains `paths.values()`) | `OrderedMap` | +| `Terminal._input_colors_frequency` (equal-count ties in `get_input_colors`) | insertion-ordered `ColorFrequency` | +| `Gradient.build_coordinate_color_mapping` returned dict | `CoordColorMap` (insertion-ordered) | +| `character.neighbors` (north, east, south, west) | fixed-field struct in that order | +| `Scene.frame_index_map` | `Vec` tick map | +| `PrimsWeighted._pending_weighted_links` (defaultdict; `min` over keys) | `BTreeMap` (order-independent result) | +| distance buckets in `get_characters_grouped` CENTER/OUTSIDE | insertion-ordered vec of buckets | + +## Effect-level sets + +**Audit complete: all 37 effects reviewed.** Two needed canonical ordering and +carry shim patches; the rest are documented below as order-unobservable. + +- middleout (effect_middleout.py:229) — set iteration — patched +- unstable (effect_unstable.py:332, :354) — set iteration — patched + +Audited, no patch needed (set iteration present but order-unobservable): + +| Effect | Site | Why order-unobservable | +|---|---|---| +| slice | `for character in self.active_characters` at end of `build` (effect_slice.py) | only calls `set_character_visibility(True)`, a commutative per-character flag; render order is already canonicalized by the `_update_terminal_state` patch. ttfx iterates its `BTreeSet` (ascending id). | +| decrypt | `for char in self.active_characters` at the typing→decrypting transition (effect_decrypt.py:263) | only calls `activate_scene("fast_decrypt")`, which mutates each character alone; no SCENE_ACTIVATED handlers registered, so order is unobservable. ttfx iterates its `BTreeSet` (ascending id). | +| print, overflow | none | no set iteration or dict-order reliance beyond `active_characters` membership; ticking covered by the `BaseEffectIterator.update` patch, rows/lists are ordered Python lists. | +| expand, scattered | none beyond `active_characters` membership | build loops iterate `get_characters()` lists; `active_characters` ticking covered by the `BaseEffectIterator.update` patch. | +| colorshift, highlight, sweep, waves | none beyond `active_characters` membership (`add`/`update` only) | build loops iterate `get_characters()`/`get_characters_grouped()` lists; colorshift's `loop_tracker_map` dict is keyed access only (never iterated); ticking covered by the `BaseEffectIterator.update` patch. | +| crumble | none beyond `active_characters` membership | falling/vacuuming pop from lists (`pending_chars`, `unvacuumed_chars`); resetting iterates `get_characters()`. | +| blackhole | `all(character in self.blackhole_chars for ...)` over `active_characters` (effect_blackhole.py:372) | pure membership test (commutative); all activation loops iterate lists. | +| swarm | `swarm_area_coordinate_map` is a dict (effect_swarm.py:218) | insertion-ordered dict with key-overwrite-in-place; ttfx uses an ordered `Vec<(Coord, Vec)>` with identical semantics. No set iteration. | +| spotlights | `chars_in_range` / `illuminated_chars` set iteration in `illuminate_chars` (effect_spotlights.py:272,283) | per-character `set_appearance` only, disjoint targets, no RNG inside the loops — commutative. ttfx iterates `BTreeSet` (ascending id). | +| burn, smoke, vhstape, synthgrid, matrix | none | no set iteration; ignition/flood/line/column/rain state is held in Python lists and dicts whose order ttfx mirrors. burn's ignition order comes from `PrimsSimple.char_link_order` (a list); smoke's flood order from `BreadthFirst.explored_last_step` (list, and its `links` iteration is already shimmed). | +| laseretch | `color_shifted_chars: set[...]` declared at effect_laseretch.py:336 | dead code upstream — the set is never read, written, or iterated. `beam_chars` is a list. | +| thunderstorm | none | `pending_strike_chars` / `active_strike_chars` / `pending_glow_chars` are all lists (effect_thunderstorm.py:194-198); particle pools iterate their own deques. | + +## lru_cache mutation quirk (plan §5 addendum) + +Plan.md's "Deliberate divergences" claims the geometry `lru_cache` layers are +value-transparent. **False for swarm**: `SwarmIterator.build` calls +`random.shuffle(...)` directly on the list returned by the cached +`find_coords_on_circle`, mutating the cache entry in place. Later calls with +the same focus coord return the *previously shuffled* list. ttfx reproduces +this with an effect-local cache in `src/effects/swarm.rs` whose entries +persist the shuffle mutation. Other ported call sites (blackhole, spotlights) +only read the returned lists, so dropping the cache stays value-transparent +there. Audit any future effect that mutates a geometry function's return +value. +| fireworks | `active_characters.add` in `__next__` (effect_fireworks.py) | membership only; shells are lists popped from the end. Ticking covered by the `BaseEffectIterator.update` patch. | +| bubbles | `active_characters.union(bubble.characters)` in `__next__` (effect_bubbles.py) | membership only; bubbles and their character groups are lists, and `Bubble.move` steps animations in list order. Ticking covered by the `BaseEffectIterator.update` patch. | +| beams | `active_characters.add` / `not self.active_characters` in `__next__` (effect_beams.py) | membership and emptiness only; pending/active groups and wipe groups are lists. Ticking covered by the `BaseEffectIterator.update` patch. | +| rings | none beyond `active_characters` membership | `Ring.characters`, `pending_chars`, `non_ring_chars` are lists; `rings` dict iterated in insertion order (radius ascending, `Vec` in ttfx); RNG order fixed by list order. | +| orbittingvolley | `any(launcher.magazine ...)` / `len(self.active_characters) > 1` | membership/length checks only; `_launchers` and magazines are lists; effect consumes no RNG. | +| binarypath | none beyond `active_characters` membership | `pending_binary_representations`, `active_binary_reps`, `binary_characters`, `final_wipe_chars` are lists; the travel-phase `randrange` pops from a list, order preserved. | +| laseretch | `active_chars` passed into ParticlePool; `color_shifted_chars` (effect_laseretch.py:336) | pool uses the set for membership add/discard only; `color_shifted_chars` is created but never read or iterated upstream (dead attribute, omitted in ttfx). Pool `available` is a deque, `pending_chars`/`beam_chars` are lists; RecursiveBacktracker only checks `links` for emptiness (no links iteration). Ticking covered by the `BaseEffectIterator.update` patch. | +| synthgrid | none beyond `active_characters` membership | `pending_groups`, `grid_lines`, and each GridLine's character lists are Python lists; `group_tracker` dict is only summed over `.values()` (commutative count of nonzero entries, ttfx `Vec` indexed by group number); blocks look up `character_by_input_coord` by explicit coord list, no dict iteration. | diff --git a/vendor/ttfx/packaging/PKGBUILD b/vendor/ttfx/packaging/PKGBUILD new file mode 100644 index 0000000..d736817 --- /dev/null +++ b/vendor/ttfx/packaging/PKGBUILD @@ -0,0 +1,33 @@ +# Maintainer: David Heinemeier Hansson +pkgname=ttfx +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Terminal text effects — single-binary Rust port of terminaltexteffects" +arch=('x86_64') +url="https://github.com/basecamp/ttfx" +license=('MIT') +depends=('gcc-libs') +makedepends=('rust') +source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz") +sha256sums=('SKIP') + +build() { + cd "$pkgname-$pkgver" + export CARGO_TARGET_DIR=target + cargo build --release --locked +} + +check() { + cd "$pkgname-$pkgver" + cargo test --release --locked +} + +package() { + cd "$pkgname-$pkgver" + install -Dm755 "target/release/ttfx" "$pkgdir/usr/bin/ttfx" + install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" + ./target/release/ttfx --print-completion bash \ + | install -Dm644 /dev/stdin "$pkgdir/usr/share/bash-completion/completions/ttfx" + ./target/release/ttfx --print-completion zsh \ + | install -Dm644 /dev/stdin "$pkgdir/usr/share/zsh/site-functions/_ttfx" +} diff --git a/vendor/ttfx/plan.md b/vendor/ttfx/plan.md new file mode 100644 index 0000000..5980aab --- /dev/null +++ b/vendor/ttfx/plan.md @@ -0,0 +1,483 @@ +# ttfx — Rust port of TerminalTextEffects + +> Name: `ttfx` (checked clean against Arch repos, AUR, and Debian; `ttx` was rejected — +> taken by fonttools). + +A pixel-perfect reimplementation of [terminaltexteffects](https://github.com/ChrisBuilds/terminaltexteffects) +(TTE) v0.15.0 (commit `7a91dd9`) in Rust, shipped as a single static binary with no runtime +dependencies. + +## 1. Goals and non-goals + +**Goals** + +- Visual parity: given the same input, config, and random decisions, ttfx produces + byte-identical frames to Python TTE. "Pixel-perfect" is verified mechanically, not by eyeball + (see §7). +- CLI compatibility: same effect names, option names, defaults, metavars, and choices as + `tte`, so existing invocations (`ls | tte decrypt --typing-speed 2`) work with `ttfx` + substituted. +- Single static binary (musl), no runtime deps, fast startup. +- All 37 effects. +- Linux only, targeted exclusively at Omarchy (Arch). No macOS/Windows/BSD support. + +**Non-goals** + +- Bit-identical randomness with Python. TTE uses the module-global Mersenne Twister; we use our + own PRNG. `--seed` remains supported and is deterministic *within ttfx*. +- The Python library API (embedding TTE in Python programs). +- Python plugin effects (`~/.config/terminaltexteffects/effects/*.py`). Out of scope for v1; + the effect registry is static. +- Wide-character correctness. TTE itself treats one codepoint as one cell (no wcwidth + anywhere); we reproduce that behavior for parity and document the limitation. Revisit later + behind a flag. + +## 2. Source of truth + +Pin the reference: TTE v0.15.0, commit `7a91dd9ca6ee0c4f4b1484efee0ecac1bb84104e`. Vendor a +clone under `reference/tte/` (git submodule or plain checkout, excluded from the binary) so the +parity harness and future upgrades diff against a fixed target. + +Scale of the port: ~22k lines of Python — engine ~3.5k, effects ~12.6k, utils ~3.3k. Expect +roughly the same order of magnitude in Rust. + +## 3. Crate layout + +Single crate, lib + bin: + +``` +ttfx/ + Cargo.toml + src/ + main.rs # arg dispatch, stdin/file input, effect selection, run loop + cli.rs # clap root: global/terminal args, subcommand registry, completions + engine/ + mod.rs + terminal.rs # Terminal: config, output, frame pacing, prep/restore + canvas.rs # Canvas: dims, anchoring, coord queries + input.rs # ANSI-aware input parser -> character grid (the mini terminal emulator) + character.rs # EffectCharacter storage (arena), visibility, neighbors/links + animation.rs # Scene, Frame, CharacterVisual, sync/eased stepping + motion.rs # Waypoint, Segment, Path, Motion + events.rs # EventHandler: Event, Action enums, dispatch + particles.rs # ParticlePool, ParticleReset + utils/ + mod.rs + easing.rs # 31 named easings + cubic-bezier make_easing + EasingTracker/SequenceEaser + geometry.rs # Coord, circle/rect/bezier/line math + graphics.rs # Color, ColorPair, Gradient + hexterm.rs # xterm-256 <-> RGB table and nearest-match + ansi.rs # escape sequence constants + SGR builders (colorterm + ansitools) + pycompat.rs # Python-semantics helpers: round-half-even, floor_div, trunc — see §5 + rng.rs # Rng trait + default PRNG + Python-shaped helpers (randint, choice, shuffle, ...) + spanning_tree.rs # PrimsSimple, PrimsWeighted, RecursiveBacktracker, BreadthFirst + effects/ + mod.rs # static registry: name -> (build fn, clap command) + beams.rs ... wipe.rs # one file per effect, mirroring the Python effect files + tools/ + parity/ # Python shim + differ (see §7) + reference/tte/ # pinned upstream checkout +``` + +**Dependencies** (kept deliberately short): + +- `clap` (derive) — CLI. Each effect's config is a struct with `#[arg(...)]` attributes, + the direct analog of TTE's `ArgSpec` dataclass-field trick. +- `terminal_size` — terminal dimensions (replaces `shutil.get_terminal_size`, same + (80, 24) fallback on failure). +- `getrandom` — OS entropy for unseeded runs; `signal-hook` (or a minimal self-pipe via + `libc`) — SIGINT handling. Statically linked crates are not runtime dependencies; the + single-binary goal doesn't require hand-rolling entropy or signal plumbing. +- No rand crate: `rng.rs` implements xoshiro256++ plus Python-shaped helpers ourselves — + we need exact control over call semantics for the parity harness anyway (§7). +- Dev-only: whatever the parity tooling needs (none in the binary). + +## 4. Core architecture decisions + +### 4.1 Arena + IDs instead of the Python object graph + +Python TTE is a web of back-references: character ⇄ animation/motion/event_handler, events +holding Scene/Path/Waypoint objects, `links`/`neighbors` between characters, Paths mutated per +activation. In Rust: + +- `Vec` arena owned by the Terminal, addressed by `CharacterId(u32)` + (TTE already has a monotonic `character_id` — reuse it as the index). +- Scenes/Paths live in per-character maps addressed by `SceneId`/`PathId` (interned string or + small-index newtype). Waypoints by index within their Path. +- `neighbors`/`links` store `CharacterId`s. +- Event targets are IDs, so `EventHandler` tables are plain data: + `HashMap<(Event, CallerId), Vec<(Action, Target)>>` with insertion-order Vec for actions + (dispatch order matters). + +### 4.2 Ticking without aliasing — synchronous event dispatch + +`character.tick()` → motion first, then animation, and event callbacks fired mid-tick can +mutate *other* characters, activate paths/scenes, and add/remove from `active_characters` +(particles do this constantly). Crucially, Python executes actions **immediately at the +emission point** (`_handle_event`, base_character.py:374): segment events fire in the *middle* +of `Path.step` before the coordinate is computed, and `Motion.move` assigns the returned +coordinate *after* callbacks ran — so e.g. a `SET_COORDINATE` action fired from a segment +event is overwritten by the move's own assignment. A deferred queue drained after +`step`/`move`/`tick` would change observable ordering and produce different frames even with +identical RNG draws. Therefore: **no deferred queue.** + +Instead, all engine stepping functions are methods on `EngineCtx` (which owns the character +arena, scenes, paths, events, RNG, clock) operating through IDs, never holding long-lived +borrows of character internals. `EngineCtx::handle_event(caller, event)` is called inline at +exactly the source's emission points, recursing depth-first just like Python's call stack. +Where a step function needs local state across an emission point (e.g. `Path::step`'s segment +walk), that state is plain locals (indices, distances) — not borrows — so the reentrant call +is legal. Path/scene runtime state accessed both by the step and by reentrant actions is +re-fetched by ID after each emission point rather than cached across it. + +Effect callbacks (`EventHandler.Callback` in Python — closures over iterator state, e.g. +burn's `_emit_smoke`, and particle reclaim closures capturing the pool) cannot be boxed +closures in the arena: they'd borrow the effect that transitively owns the arena. Instead an +event action stores `Callback(CallbackId, Payload)` with an owned payload; the engine +surfaces fired callbacks to the effect via `Effect::dispatch_callback(&mut self, ctx, character_id, +callback_id, payload)`. Because Python callbacks can themselves trigger more engine work, +`dispatch_callback` receives `&mut EngineCtx` and may recurse into engine calls — the effect +struct and the engine context are separate ownership trees, so this borrows cleanly. +`ParticlePool`s live in effect state and are addressed by `PoolId`; reclaim-on-event is a +`Callback` carrying the pool id. No `Rc` anywhere. + +### 4.3 One Terminal, deterministic ordering + +Python constructs **two** Terminals per run (one owns the tty, one owns the simulation). +Collapse to one `Terminal` (simulation + renderer) plus a thin `TtyWriter` (prep canvas, +frame pacing, restore cursor — RAII `Drop` replaces the `@contextmanager`). + +**Ordering is behavior.** Python iterates unordered sets in several behaviorally relevant +places — not just the two engine sites (`active_characters` ticking, render layer-ties) but +also inside effects (middleout, unstable iterate sets directly) and `BreadthFirst`, which +traverses the `links` *set*. And dict insertion order is load-bearing too: swarm chains +`motion.paths.values()`, rings iterates `rings.values()`, and equal-frequency input-color +sorting inherits dict insertion order. Rules: + +- **M1 deliverable: a complete inventory of every unordered iteration** (engine + all 37 + effects + spanning trees), each assigned a canonical deterministic order (usually ascending + `CharacterId` or insertion order). The parity shim patches each Python site to the same + canonical order (§7). +- Anywhere Python iterates a dict's values/items, Rust uses `Vec` + id→index lookup or an + insertion-ordered map — plain `HashMap` iteration is only allowed for lookup-only data. +- Render: sort visible characters by `(layer, character_id)`; tick: snapshot + `active_characters` sorted by `CharacterId` (Python snapshots a tuple). + +**`character_id` is not a dense arena index.** Python allocates an id for *every* parsed +character, including ones later overwritten by cursor-movement sequences, popped as trailing +whitespace, or cropped by the canvas — so ids have gaps relative to surviving characters, and +downstream id-ordered iteration depends on the original allocation order. The arena keeps +slot index and `character_id` as separate values; the id counter consumes exactly as the +Python parser does. + +### 4.4 Effects as a trait + static registry + +```rust +pub trait Effect { + fn build(&mut self, ctx: &mut EngineCtx); // Python __init__/build() + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option; // __next__ +} +``` + +`effects/mod.rs` holds the static registry (name → clap `Command` + constructor), replacing +`pkgutil` discovery. `--random-effect`/`--include-effects`/`--exclude-effects`/`--seed` work +as upstream, including the quirk that a randomly selected effect runs with pure default config. + +### 4.5 Config system + +Terminal config: one clap struct with the 15 upstream options, same defaults +(`--tab-width 4`, `--frame-rate 60`, `--canvas-width -1`, `--anchor-canvas sw`, ...), same +value validation as `argutils` (PositiveInt, ratio ranges, hex-or-xterm ColorArg, etc. — +implemented as clap value parsers in one module). Effect configs: one struct per effect with +`#[arg]` attributes. The shared `final_gradient_*` args can't be a naively `#[flatten]`-ed +struct because each effect overrides their defaults: parse them as `Option` in the shared +struct and merge with the effect's own `Default` after parsing (or generate per-effect arg +declarations with a small macro — decide at M0 with a two-effect spike). Help text copied +from upstream so `--help` is familiar (exact help formatting parity is *not* a goal; option +surface parity is). Usage errors exit 2 (clap's default, matching argparse); runtime errors +exit 1 with upstream's stream choice — file errors print to *stdout*, unsupported-ANSI to +*stderr* (yes, really). + +### 4.6 RNG + +`rng.rs` defines the engine RNG with Python-shaped methods matching every call TTE makes +(counts from the survey): `randint(a,b)` (61 uses), `choice(&[T])` (54), `shuffle` +(Fisher-Yates, 13), `randrange` (13), `uniform(a,b)` (12), `random()` (12). Backed by +xoshiro256++; seeded from `--seed` or OS entropy (`getrandom` crate). The RNG lives on +`EngineCtx` and is threaded explicitly — no globals — which is also what makes the parity +harness possible (§7). Helper semantics are pinned exactly (e.g. `choice` = +`seq[randbelow(len)]`, `uniform` = `a + (b-a)*random()`, Fisher-Yates in Python's order) +because the Python shim mirrors them. + +### 4.7 Clock injection + +Two effects read wall/monotonic time directly: matrix (`time.time()` for rain-phase +transitions) and thunderstorm (`time.monotonic()` for the storm budget). Real clocks would +make parity depend on execution speed — with `frame_rate=0` a faster implementation produces +more frames and consumes more RNG draws before a deadline. So `EngineCtx` carries a `Clock` +(`now_wall()`, `now_monotonic()`): the production impl reads real time; the parity impl is +virtual, advancing a fixed `1/frame_rate` per emitted frame. The Python shim monkeypatches +`time.time` and `time.monotonic` with the same virtual clock (§7). This also bounds parity +dump sizes for the time-budgeted effects. + +## 5. Fidelity traps (must-reproduce semantics) + +These are places where a natural Rust translation silently diverges from Python. All go +through `pycompat.rs` helpers with tests pinned to Python-generated golden values. + +1. **`round()` is banker's rounding** (half-to-even). Used for every coordinate quantization, + `Path.max_steps`, animation frame indices, `adjust_color_brightness`. Rust's `f64::round` + is half-away-from-zero. → `pycompat::round_half_even(f64) -> i64` used everywhere Python + calls `round`. +2. **Gradient channel deltas use integer floor division** (`(end - start) // steps`). Python + `//` floors; Rust `/` on integers truncates toward zero — they differ for negative deltas, + and gradients are *not* float lerp. → `pycompat::floor_div`, and `Gradient::generate` + transcribes the integer algorithm exactly (including appending the exact end stop per pair, + the shared-stop skip, and `loop` appending stop[0]). +3. **Truncation vs rounding is inconsistent upstream and must stay that way**: + `shift_color_towards` uses `int(x*255)` (truncation); `adjust_color_brightness` uses + `round(x*255)` (half-to-even). Reproduce each as-is. +4. **`find_length_of_bezier_curve` omits the final t=0.9→1.0 span** (10-sample loop bug). + Path lengths are systematically short; `max_steps` depends on it. Reproduce the bug. +5. **Row deltas are doubled** in path distances (`double_row_diff=True`), circle x-offsets are + doubled, diagonal/radial gradient math doubles rows — the cell-aspect convention. Copy each + call site's choice exactly (`geometry::line_length(a, b, double_row: bool)` defaults off, + like upstream). +6. **Unclamped eased `t`**: `Path.step` deliberately allows `t > 1`/`t < 0` for overshooting + easings (back/elastic) including traveling *past* the final waypoint via the for-else + overshoot re-add; `_step_eased_scene` clamps instead. Copy both behaviors. +7. **`hex_to_xterm` metric** is minimum *mean absolute channel difference* via linear scan over + the 256-entry table (first minimum wins) — not Euclidean, not perceptual. Port the table and + scan order verbatim. +8. **Looping-scene semantics**: `active_scene_is_complete()` returns true for looping scenes + (so loop-only characters get pruned from `active_characters`), and `SCENE_COMPLETE` fires + *every tick* for looping scenes. Effects depend on both quirks. +9. **`activate_scene` does not reset playback** (resume semantics); `reset_scene` restores + played + remaining frames into the frame queue in original order, zeroes each frame's + `ticks_elapsed` and the easing step, and clears `played_frames`. Verify with + partial-playback tests. +10. **Path re-activation mutates the path**: a synthetic origin segment from the current + coordinate is built, the *previous* origin segment's distance is subtracted and the new + one's added (rebase, not cumulative accumulation), segments[0] replaced or inserted, + `current_step`/`hold_time_remaining` reset, `max_steps` recomputed. Copy exactly. +11. **Segment events key off the end Waypoint**, fire once per activation, and do not re-fire + on backwards easing motion. Copy the flag mechanics. +12. **Synced-scene formulas** (`STEP`/`DISTANCE` with their `max(...,1)` guards and + `round()` indexing) transcribed exactly; missing active path → jump to last frame and + force-complete. +13. **Input parsing**: the mini ANSI emulator (CSI-only, specific SGR subset, bold bumping a + pending standard fg by +8, `\t` → N space characters, cursor-movement sequences, the + unsupported-sequence error), trailing space/line trimming, plain uncolored spaces demoted + to fill characters, `"No Input."` fallback, empty-stdin-when-tty → `""`. Transcribe + directly with the same error taxonomy. Stateful quirks that must survive transcription: + *unsupported SGR parameter values are silently ignored* (the SGR loop has no error + fallback — only malformed/unsupported *sequences* raise), and `_input_colors_frequency` + increments at character-creation time, so colors of cells later overwritten by cursor + movement still count (affects `get_input_colors` and RNG ordering). Differential-test the + ugly corpus: cursor overwrites, trailing colored cells, ignored SGR params, private + modes, malformed CSI. +14. **Canvas math**: center formulas (`top//2` + odd adjustment), anchor offset computation, + visible-bounds clamping, `outside_scope` random coords exactly one cell beyond an edge, + `-1`/`0` canvas sizing semantics, `--ignore-terminal-dimensions` overwriting terminal dims. +15. **`get_characters` sort algorithms** including the destructive alternate-pop interleave for + outside/middle sorts (order matters, O(n²) is fine), and grouped variants' exact grouping + (diagonal bands by `row+column` / `column-row`, Manhattan-distance bucketing from + `text_center`). +16. **CharacterVisual SGR order**: bold, italic, underline, blink, reverse, hidden, strike, + fg, bg, symbol, `\x1b[0m`; `dim` stored but never emitted; bare symbol when unformatted. + Frame string = rows joined `"\n"`, top row first. Row dimensions are **`visible_top` × + `visible_right`** — absolute terminal-space extents after canvas anchoring and clipping — + *not* canvas width/height; non-southwest anchors produce leading blank rows/columns inside + every frame. Specify the renderer in `visible_*`/offset terms and golden-test all nine + anchors under both clipped and unclipped terminal sizes. +17. **`existing_color_handling`** three modes including `"always"` overriding every + `add_frame`/`set_appearance` color and applying at parse time; `preexisting_colors_present` + scan. `Color` equality is on the *original argument* (`Color(255) != Color("ffffff")`) — + keep that for `input_colors_frequency` and any keying. +18. **`find_normalized_distance_from_center` stays in [0,1]** for accepted coordinates (it + rejects out-of-rectangle coords with a `ValueError`, and the doubled-row diagonal bounds + the numerator); `Gradient::get_color_at_fraction` validates `0 <= f <= 1` and errors + otherwise. Reproduce both the rejection and the validation as errors (don't clamp), and + pin with exhaustive rectangle-lattice tests. +19. **Frame pacing**: `1/frame_rate` delay, `monotonic` check, sleep remainder, timestamp + taken *after* sleep (drift accumulates), `frame_rate == 0` disables. Same ANSI prep/restore + dance: hide cursor, scroll-make-room (or `reuse_canvas` reposition), DEC save (`\x1b7`), + per-frame DEC restore/save + cursor-up, restore cursor + newline on exit (respecting + `--no-eol`/`--no-restore-cursor`), teardown running even on error/ctrl-C (RAII + + SIGINT handler; Python exits 1 silently on KeyboardInterrupt). +20. **Float expression order**: transcribe arithmetic expressions in the same order/grouping as + Python — don't "simplify" math during port (easing functions, HSL round-trips, bezier De + Casteljau with float intermediates rounded only at the end). Caveat: identical expression + order guarantees identical results only for IEEE basic ops (+,-,*,/,sqrt). Transcendentals + (`sin`, `cos`, `pow`, `exp`) and `hypot` go through libm, and musl-Rust vs glibc-CPython + can differ by ulps. Mitigation: parity CI runs on one pinned platform/toolchain pair; + comparisons happen on *quantized* outputs (rounded coordinates, hex colors), which absorb + ulp noise except exactly at rounding boundaries; fine-grained easing/geometry goldens + flag any boundary case early, and such cases get boundary-tolerant assertions rather + than pretending bit-identity. + +**Deliberate divergences** (accepted, documented): + +- PRNG (xoshiro vs MT19937) and therefore any `--seed` output. +- Set-iteration orderings replaced with `character_id` orderings (§4.3). +- The `lru_cache` layers (geometry, `shift_color_towards`, `make_easing`) are dropped — + they're pure-function caches, so behavior is identical; Rust recomputation is cheaper than + Python cache hits. If profiling disagrees, memoize `make_easing`'s Newton–Raphson solve only. + **Addendum (found during the swarm port):** the value-transparency claim fails when an + effect MUTATES a cached return value — swarm `random.shuffle`s the list returned by the + cached `find_coords_on_circle`, so later same-argument calls observe the shuffled entry. + Such effects reproduce the cache at effect level (a persistent map whose entries carry the + mutation); audit any effect that writes to a geometry function's return value. +- The `AldousBroder` spanning-tree generator is not ported: it exists upstream but no shipped + effect uses it (library-API surface, which is a non-goal). +- No Python plugin loading. +- Error-message *text* may differ; error *conditions* and exit codes match. + +## 6. Porting strategy + +Transcription, not reimagination: each Python file maps to one Rust file; functions keep their +names and internal structure; comments reference upstream line numbers for anything subtle. +The two survey documents (engine architecture + effect catalog) serve as the map; the pinned +checkout is the letter. + +**Phase order** (each phase lands with its parity tests green): + +- **M0 — skeleton + input pipeline.** Cargo scaffold, CLI root with terminal config, stdin/file + input, the input parser (§5.13), Canvas + anchoring, fill characters, neighbors, renderer, + `TtyWriter`. Exit criterion: piping text through a no-op "effect" reproduces Python's + preprocessed first frame byte-for-byte across the anchor/canvas/wrap/tab/existing-color + option matrix. +- **M1 — engine core.** easing (31 + `make_easing`), geometry, graphics, hexterm, animation, + motion, events, particles, spanning trees, `pycompat`, `rng`, clock. Exit criteria: + (a) golden-value tests for every pure function (inputs + expected outputs generated by a + Python script run against the pinned checkout, committed as fixtures); (b) the upstream + engine test suite (`tests/engine_tests/`) ported — it already covers multi-segment event + ordering, particle reclaim/reset, and scene lifecycle; (c) scripted state-trace tests for + the delicate machinery: nested/reentrant events, scene reactivation-resume, looping scenes, + path holds and loop-rebase, pool exhaustion/reuse; (d) the complete unordered-iteration + inventory (§4.3) written down with its canonical orders. +- **M2 — parity harness** (§7). Exit criterion: harness runs end-to-end on a hand-written + trivial effect in both implementations with byte-identical frame streams. +- **M3 — effects, wave 1 (motion + scenes basics):** randomsequence, wipe, expand, slice, + scattered, pour, slide, middleout, spray, rain, bouncyballs, errorcorrect. (~12 effects, + each small.) +- **M4 — effects, wave 2 (gradients, sync, sequence easers, no-motion effects):** colorshift, + highlight, sweep, waves, decrypt, print, overflow, unstable, crumble, blackhole, swarm, + spotlights, fireworks, bubbles, beams, rings, orbittingvolley, binarypath. +- **M5 — effects, wave 3 (heavy machinery):** burn + smoke (spanning trees), laseretch + (backtracker + particles + bezier), vhstape, synthgrid, matrix, thunderstorm. The top-5 + hairiest land last, when the engine is fully proven. +- **M6 — CLI completion + polish.** `--print-completion bash|zsh` (static generation from the + clap model, mirroring upstream's completion behavior), `--version`, `--random-effect` + filtering, error paths/exit codes, README. +- **M7 — release engineering.** `x86_64-unknown-linux-musl` static build (add + `aarch64-unknown-linux-musl` only if Omarchy grows an ARM target), LTO + strip, CI running + the full parity suite against the pinned checkout, benchmark vs Python (startup + big-input + throughput), release artifacts + Arch packaging (PKGBUILD) for Omarchy. + +Each effect PR ships: the ported effect, its clap config struct, and its parity-suite entry. + +## 7. Parity verification (what makes "pixel-perfect" checkable) + +The problem: effects are random, so naive frame-diffing fails. The solution: make randomness a +*shared injected dependency* during tests. + +**Deterministic RNG shim.** Implement one trivially-portable PRNG (xoshiro256++, fixed seed) +identically in Rust (`rng.rs`, it's the real RNG) and in a small Python module +(`tools/parity/shim.py`). The shim monkeypatches `random.randint/choice/shuffle/randrange/ +uniform/random` with the shared implementation before importing TTE. Both sides now draw +identical random sequences — *provided the port makes RNG calls in the same order as Python*, +which is exactly what faithful transcription gives us, and precisely what the harness verifies. + +**Determinism patches.** The shim also patches every site in the §4.3 inventory — +`BaseEffectIterator.update`, render layer-tie ordering, effect-level set iterations +(middleout, unstable, ...), `BreadthFirst`'s `links`-set traversal — to the same canonical +orders as the Rust port. These patches pin *which* of Python's arbitrary orderings we compare +against. + +**Clock patch.** The shim replaces `time.time`/`time.monotonic` with the virtual clock +(§4.7), advancing `1/frame_rate` per frame, so matrix and thunderstorm are deterministic and +their dumps bounded. + +**Shim audit.** Because the shim modifies the reference, we separately guard against "proving +parity with a modified TTE": every deterministic (RNG-free, clock-free) effect and the whole +M0 preprocessing matrix are *also* byte-compared against a completely unmodified pinned +CPython run; and the shim's patches are limited by construction to ordering/RNG/clock +substitution (a diff of shimmed-vs-unmodified TTE source is reviewed, small, and committed +alongside the harness). + +**Frame capture.** Python side: shim iterates the effect with `frame_rate=0` and fixed canvas +(`--canvas-width/height` explicit + `--ignore-terminal-dimensions`), writing each frame string +to a length-prefixed dump. Rust side: hidden `--parity-dump ` flag does the same. A +differ compares streams and reports first divergent frame/row/column with a decoded escape +view. + +**Test matrix.** Per effect: 2–3 input texts (ASCII multiline, colored-ANSI input, ragged +short input) × default config × 1–2 non-default configs exercising that effect's options. +Plus the option-matrix suite for M0 preprocessing. All checked into CI; the suite regenerates +Python dumps from `reference/tte/` on demand (dumps themselves can be cached by content hash). +Parity CI runs on one pinned platform/toolchain pair (Arch x86_64, pinned CPython + rustc) — +cross-platform builds are release targets, not parity targets (§5.20). + +**PTY byte-stream tests.** Frame dumps bypass the tty layer, so a separate small suite runs +both implementations under a pseudo-terminal and byte-compares the *full* output stream: +canvas prep (blank-row scroll, DEC save), per-frame restore/save/cursor-up preamble, and +teardown (cursor show, EOL) — including the `--reuse-canvas`/`--no-eol`/`--no-restore-cursor` +variants and the SIGINT path. + +**Fallback tier.** If an effect turns out to interleave RNG draws through a code path whose +order can't reasonably be matched (none identified yet, but e.g. hash-order-dependent draw +sequences inside an effect), it drops to tier-2 verification: structural frame comparison +(non-random subsets byte-compared; random placements checked for distribution/bounds) plus +manual visual sign-off against side-by-side recordings. Goal: zero tier-2 effects. + +**Unit goldens.** Independent of frame parity: fixtures generated from Python for easing curves +(sampled at 1e-3 steps), all gradient constructions in the effect defaults, hexterm's full +256-entry nearest-match on a color sweep, geometry functions on a coordinate lattice, and the +input parser on an ANSI corpus. + +## 8. Runtime behavior details + +- **Input**: stdin (empty when tty), `--input-file`; empty/whitespace input → `NO INPUT.` on + stdout, exit 1. Decoding is **strict UTF-8** for both file and stdin — upstream file reads + are strict (decode failure → error message, exit 1) and stdin goes through Python's text + stream, strict UTF-8 in the environments we target. No lossy decoding; test the failure + path. +- **Signals**: SIGINT is recorded via flag/self-pipe and *returns control to the main loop*, + which unwinds normally so the RAII teardown runs (`Drop` alone doesn't fire on a signal); + exit 1, no message (matches KeyboardInterrupt handling). +- **Exit codes**: 0 success; 1 runtime errors — no-input, missing effect, file errors (message + on *stdout*, matching upstream), unsupported ANSI sequence (message on *stderr*); 2 usage + errors from argument parsing (argparse/clap convention). A CLI corpus test covers exit + codes, stream routing, root-option-before-subcommand placement, `nargs`-style multi-value + options, negative-looking values, include/exclude filtering, and decode failures. +- **Performance target**: not a goal beyond "never the bottleneck" — full-canvas repaint at + 60fps on a 400×100 canvas is trivial in Rust; the O(n²) upstream algorithms (outside-in + sort, grouped scans) are kept for fidelity and are fine at terminal scale. Per-cell + `formatted_symbol` strings are precomputed on visual change exactly as upstream caches them + in `CharacterVisual`. Frame strings are freshly allocated `String`s at first (simple, + matches the `Option` iterator shape); buffer recycling only if profiling ever says + so. + +## 9. Risks + +| Risk | Mitigation | +|---|---| +| RNG call-order divergence in some effect makes frame parity unachievable there | Faithful transcription makes order match by construction; harness catches drift per-effect at port time, not at the end; tier-2 fallback exists | +| Hidden reliance on Python set/hash/dict order beyond the M1 inventory | Parity harness surfaces it as a reproducible first-divergence; the site joins the inventory, the shim gains a patch, Rust matches it | +| Float divergence (expression order or libm ulps) | §5.20: transcribe expression order, pin the parity platform, compare quantized outputs, fine-grained goldens catch boundary cases early | +| Shim-validated parity diverges from *unmodified* TTE | Shim audit (§7): deterministic effects + preprocessing byte-compared against unpatched CPython; shim diff kept minimal and reviewed | +| clap can't express an argparse corner (e.g. `nargs` tuple actions, dual-type parsers like laseretch's etch-pattern) | Custom value parsers; worst case a manual `TypedValueParser` per odd option — all identified odd options are enumerated in the survey | +| Upstream moves on and the port targets a stale version | Pinned reference is explicit; upgrades are a diff of `reference/tte/` + re-run of parity suite | +| Scope creep into "improving" TTE | §5 divergence list is the only allowed list; everything else is transcription | + +## 10. Open questions — all resolved + +1. Ship a `tte` alias/symlink in packaging? → **No.** It would collide with a real + `terminaltexteffects` install, and the whole point is that `ttfx` stands alone. Users who + want the alias can add a shell alias themselves. +2. Vendor upstream as git submodule vs plain checked-in snapshot? → **Plain snapshot** + (`reference/tte/`, docs stripped, 2.4MB), simplest for CI and immune to upstream retagging. +3. musl static build verified locally? → **CI-only.** This machine has system rust (gnu target + only, no rustup), so the musl job lives in `.github/workflows/ci.yml` and uploads the + artifact. Local `cargo build --release` links just libc/libm/libgcc. diff --git a/vendor/ttfx/src/cli.rs b/vendor/ttfx/src/cli.rs new file mode 100644 index 0000000..a29c4fe --- /dev/null +++ b/vendor/ttfx/src/cli.rs @@ -0,0 +1,186 @@ +//! CLI root: the 15 TerminalConfig options (same names/defaults as upstream tte) +//! plus global args. Effect subcommands land in M3+. + +use clap::Parser; + +use crate::engine::animation::ExistingColorHandling; +use crate::engine::canvas::Anchor; +use crate::engine::terminal::TerminalConfig; +use crate::utils::graphics::Color; + +fn parse_positive_int(s: &str) -> Result { + let v: i64 = s.parse().map_err(|_| format!("invalid int value: '{s}'"))?; + if v > 0 { + Ok(v) + } else { + Err(format!("{v} is not > 0")) + } +} + +fn parse_non_negative_int(s: &str) -> Result { + let v: i64 = s.parse().map_err(|_| format!("invalid int value: '{s}'"))?; + if v >= 0 { + Ok(v) + } else { + Err(format!("{v} is not >= 0")) + } +} + +fn parse_canvas_dimension(s: &str) -> Result { + let v: i64 = s.parse().map_err(|_| format!("invalid int value: '{s}'"))?; + if v >= -1 { + Ok(v) + } else { + Err(format!("{v} is not >= -1")) + } +} + +/// argutils.ColorArg: <=3 chars -> xterm int 0-255, else hex. +pub fn parse_color(s: &str) -> Result { + if s.len() <= 3 { + let code: u8 = s.parse().map_err(|_| format!("invalid color value: '{s}'"))?; + Ok(Color::from_xterm(code)) + } else { + Color::from_hex(s) + } +} + +fn parse_anchor(s: &str) -> Result { + Anchor::parse(s).ok_or_else(|| format!("invalid anchor: '{s}'")) +} + +fn parse_existing_color_handling(s: &str) -> Result { + match s { + "always" => Ok(ExistingColorHandling::Always), + "dynamic" => Ok(ExistingColorHandling::Dynamic), + "ignore" => Ok(ExistingColorHandling::Ignore), + _ => Err(format!("invalid choice: '{s}' (choose from 'always', 'dynamic', 'ignore')")), + } +} + +#[derive(Parser, Debug)] +#[command( + name = "ttfx", + version, + about = "Terminal text effects (Rust port of terminaltexteffects)", + // upstream tte exposes --version/-v, so replace clap's default -V flag + disable_version_flag = true +)] +pub struct Cli { + /// Print the version and exit + #[arg(short = 'v', long = "version", action = clap::ArgAction::Version)] + pub version: Option, + + /// File to read input from + #[arg(short = 'i', long = "input-file")] + pub input_file: Option, + + #[arg(long = "tab-width", default_value_t = 4, value_parser = parse_positive_int)] + pub tab_width: i64, + + #[arg(long = "xterm-colors", default_value_t = false)] + pub xterm_colors: bool, + + #[arg(long = "no-color", default_value_t = false)] + pub no_color: bool, + + #[arg(long = "terminal-background-color", default_value = "#000000", value_parser = parse_color)] + pub terminal_background_color: Color, + + #[arg(long = "existing-color-handling", default_value = "ignore", value_parser = parse_existing_color_handling)] + pub existing_color_handling: ExistingColorHandling, + + #[arg(long = "wrap-text", default_value_t = false)] + pub wrap_text: bool, + + #[arg(long = "frame-rate", default_value_t = 60, value_parser = parse_non_negative_int)] + pub frame_rate: i64, + + #[arg(long = "canvas-width", default_value_t = -1, value_parser = parse_canvas_dimension, allow_negative_numbers = true)] + pub canvas_width: i64, + + #[arg(long = "canvas-height", default_value_t = -1, value_parser = parse_canvas_dimension, allow_negative_numbers = true)] + pub canvas_height: i64, + + #[arg(long = "anchor-canvas", default_value = "sw", value_parser = parse_anchor)] + pub anchor_canvas: Anchor, + + #[arg(long = "anchor-text", default_value = "sw", value_parser = parse_anchor)] + pub anchor_text: Anchor, + + #[arg(long = "ignore-terminal-dimensions", default_value_t = false)] + pub ignore_terminal_dimensions: bool, + + #[arg(long = "reuse-canvas", default_value_t = false)] + pub reuse_canvas: bool, + + #[arg(long = "no-eol", default_value_t = false)] + pub no_eol: bool, + + #[arg(long = "no-restore-cursor", default_value_t = false)] + pub no_restore_cursor: bool, + + /// Seed for the random number generator (deterministic within ttfx) + #[arg(long = "seed")] + pub seed: Option, + + /// Print a shell completion script and exit + #[arg(long = "print-completion", value_name = "SHELL", value_parser = ["bash", "zsh"])] + pub print_completion: Option, + + /// Run a random effect + #[arg(short = 'R', long = "random-effect", default_value_t = false)] + pub random_effect: bool, + + /// Limit random-effect selection to these effects + #[arg(long = "include-effects", num_args = 1.., conflicts_with = "exclude_effects")] + pub include_effects: Vec, + + /// Exclude these effects from random-effect selection + #[arg(long = "exclude-effects", num_args = 1..)] + pub exclude_effects: Vec, + + /// M0 debug: make every canvas character visible and print the first frame + /// (used by the parity harness; hidden from help) + #[arg(long = "m0-dump", default_value_t = false, hide = true)] + pub m0_dump: bool, + + /// Parity harness: dump length-prefixed frames deterministically (requires --seed) + #[arg(long = "parity-dump", default_value_t = false, hide = true)] + pub parity_dump: bool, + + /// Parity harness: stop after N frames + #[arg(long = "max-frames", hide = true)] + pub max_frames: Option, + + /// Parity harness: drive the clock virtually (1/frame_rate per frame) so + /// clock-dependent effects (matrix, thunderstorm) are reproducible on the + /// real tty output path. Implied by --parity-dump. + #[arg(long = "virtual-clock", default_value_t = false, hide = true)] + pub virtual_clock: bool, + + #[command(subcommand)] + pub effect: Option, +} + +impl Cli { + pub fn terminal_config(&self) -> TerminalConfig { + TerminalConfig { + tab_width: self.tab_width, + xterm_colors: self.xterm_colors, + no_color: self.no_color, + terminal_background_color: self.terminal_background_color.clone(), + existing_color_handling: self.existing_color_handling, + wrap_text: self.wrap_text, + frame_rate: self.frame_rate, + canvas_width: self.canvas_width, + canvas_height: self.canvas_height, + anchor_canvas: self.anchor_canvas, + anchor_text: self.anchor_text, + ignore_terminal_dimensions: self.ignore_terminal_dimensions, + reuse_canvas: self.reuse_canvas, + no_eol: self.no_eol, + no_restore_cursor: self.no_restore_cursor, + } + } +} diff --git a/vendor/ttfx/src/effects/beams.rs b/vendor/ttfx/src/effects/beams.rs new file mode 100644 index 0000000..f7bf5a2 --- /dev/null +++ b/vendor/ttfx/src/effects/beams.rs @@ -0,0 +1,435 @@ +//! beams, ported from effects/effect_beams.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_positive_int, parse_positive_int_range, parse_symbol, +}; +use crate::engine::animation::{Animation, ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct BeamsConfig { + /// Symbols to use for the beam effect when moving along a row. Strings will be used in sequence to create an animation. + #[arg(long = "beam-row-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["▂", "▁", "_"])] + pub beam_row_symbols: Vec, + + /// Symbols to use for the beam effect when moving along a column. Strings will be used in sequence to create an animation. + #[arg(long = "beam-column-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["▌", "▍", "▎", "▏"])] + pub beam_column_symbols: Vec, + + /// Number of frames to wait before adding the next group of beams. Beams are added in groups of size random(1, 5). + #[arg(long = "beam-delay", default_value_t = 6, value_parser = parse_positive_int)] + pub beam_delay: i64, + + /// Speed range of the beam when moving along a row. + #[arg(long = "beam-row-speed-range", default_value = "15-60", value_parser = parse_positive_int_range)] + pub beam_row_speed_range: (i64, i64), + + /// Speed range of the beam when moving along a column. + #[arg(long = "beam-column-speed-range", default_value = "9-15", value_parser = parse_positive_int_range)] + pub beam_column_speed_range: (i64, i64), + + /// Space separated, unquoted, list of colors for the beam, a gradient will be created between the colors. + #[arg(long = "beam-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["ffffff", "00D1FF", "8A008A"])] + pub beam_gradient_stops: Vec, + + /// Space separated, unquoted, numbers for the of gradient steps to use. + #[arg(long = "beam-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["2", "6"])] + pub beam_gradient_steps: Vec, + + /// Number of frames to display each gradient step. Increase to slow down the gradient animation. + #[arg(long = "beam-gradient-frames", default_value_t = 2, value_parser = parse_positive_int)] + pub beam_gradient_frames: i64, + + /// Space separated, unquoted, list of colors for the wipe gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "ffffff"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Number of frames to display each gradient step. Increase to slow down the gradient animation. + #[arg(long = "final-gradient-frames", default_value_t = 4, value_parser = parse_positive_int)] + pub final_gradient_frames: i64, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, + + /// Speed of the final wipe as measured in diagonal groups activated per frame. + #[arg(long = "final-wipe-speed", default_value_t = 3, value_parser = parse_positive_int)] + pub final_wipe_speed: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Direction { + Row, + Column, +} + +/// BeamsIterator.Group state (get_next_character lives on Beams for hooks access). +struct Group { + characters: Vec, + direction: Direction, + speed: f64, + next_character_counter: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + Beams, + FinalWipe, + Complete, +} + +pub struct Beams { + config: BeamsConfig, + pending_groups: Vec, + active_groups: Vec, + character_final_color_map: HashMap, + final_wipe_groups: Vec>, + delay: i64, + phase: Phase, +} + +impl Beams { + pub fn new(config: BeamsConfig) -> Self { + Beams { + config, + pending_groups: Vec::new(), + active_groups: Vec::new(), + character_final_color_map: HashMap::new(), + final_wipe_groups: Vec::new(), + delay: 0, + phase: Phase::Beams, + } + } + + /// Group.__init__. + fn make_group(&self, ctx: &mut EngineCtx, mut characters: Vec, direction: Direction) -> Group { + let speed_range = match direction { + Direction::Row => self.config.beam_row_speed_range, + Direction::Column => self.config.beam_column_speed_range, + }; + let speed = ctx.rng.randint(speed_range.0, speed_range.1) as f64 * 0.1; + match direction { + Direction::Row => { + characters.sort_by_key(|&id| ctx.terminal.arena[id.0 as usize].input_coord.column); + } + Direction::Column => { + characters.sort_by_key(|&id| ctx.terminal.arena[id.0 as usize].input_coord.row); + } + } + if *ctx.rng.choice(&[true, false]) { + characters.reverse(); + } + Group { characters, direction, speed, next_character_counter: 0.0 } + } + + /// Group.get_next_character. + fn get_next_character(&mut self, ctx: &mut EngineCtx, group: &mut Group) -> Option { + group.next_character_counter -= 1.0; + let next_character = group.characters.remove(0); + let active_scene = ctx.terminal.arena[next_character.0 as usize].animation.active_scene.clone(); + let return_value = if let Some(scene_id) = active_scene { + ctx.terminal.arena[next_character.0 as usize] + .animation + .scenes + .get_mut(&scene_id) + .unwrap() + .reset_scene(); + None + } else { + ctx.terminal.set_character_visibility(next_character, true); + Some(next_character) + }; + let scene_name = match group.direction { + Direction::Row => "beam_row", + Direction::Column => "beam_column", + }; + ctx.activate_scene(self, next_character, scene_name); + return_value + } +} + +impl EffectHooks for Beams { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Beams { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // __init__ precomputation (no RNG) + self.final_wipe_groups = ctx + .terminal + .get_characters_grouped(CharacterFilter::default(), CharacterGroup::DiagonalTopLeftToBottomRight); + + let all_chars_filter = CharacterFilter { + input_chars: true, + inner_fill_chars: true, + outer_fill_chars: true, + added_chars: false, + }; + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = + ctx.terminal.get_characters(&mut ctx.rng, all_chars_filter, CharacterSort::TopToBottomLeftToRight); + for id in characters { + let ch = &ctx.terminal.arena[id.0 as usize]; + if ch.is_fill_character { + self.character_final_color_map + .insert(id, ColorPair::new(Some(Color::from_hex("#000000").unwrap()), None)); + continue; + } + let final_colors = if dynamic { + ColorPair::new(ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone()) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&ch.input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors); + } + + let beam_gradient = + Gradient::new(&self.config.beam_gradient_stops, &self.config.beam_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let mut groups: Vec = Vec::new(); + for row in ctx.terminal.get_characters_grouped(all_chars_filter, CharacterGroup::RowTopToBottom) { + groups.push(self.make_group(ctx, row, Direction::Row)); + } + for column in ctx.terminal.get_characters_grouped(all_chars_filter, CharacterGroup::ColumnLeftToRight) { + groups.push(self.make_group(ctx, column, Direction::Column)); + } + // Rows and columns contain the same characters; initialize each character's scenes only once. + for group in groups.iter().filter(|group| group.direction == Direction::Row) { + for &id in &group.characters { + let (input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "beam_row", uses_pre); + ch.animation.new_scene(false, None, None, "beam_column", uses_pre); + ch.animation.new_scene(false, None, None, "brighten", uses_pre); + ch.animation + .scenes + .get_mut("beam_row") + .unwrap() + .apply_gradient_to_symbols( + &self.config.beam_row_symbols, + self.config.beam_gradient_frames, + Some(&beam_gradient), + None, + ) + .map_err(EngineError::Other)?; + ch.animation + .scenes + .get_mut("beam_column") + .unwrap() + .apply_gradient_to_symbols( + &self.config.beam_column_symbols, + self.config.beam_gradient_frames, + Some(&beam_gradient), + None, + ) + .map_err(EngineError::Other)?; + } + let char_colors = self.character_final_color_map.get(&id).unwrap().clone(); + let mut fg_fade_gradient: Option = None; + let mut bg_fade_gradient: Option = None; + let mut fg_brighten_gradient: Option = None; + let mut bg_brighten_gradient: Option = None; + if let Some(fg) = &char_colors.fg_color { + let faded_fg_color = Animation::adjust_color_brightness(fg, 0.3); + fg_fade_gradient = Some( + Gradient::with_steps(&[fg.clone(), faded_fg_color.clone()], 10, false) + .map_err(EngineError::Other)?, + ); + fg_brighten_gradient = Some( + Gradient::with_steps(&[faded_fg_color, fg.clone()], 10, false) + .map_err(EngineError::Other)?, + ); + } + if let Some(bg) = &char_colors.bg_color { + let faded_bg_color = Animation::adjust_color_brightness(bg, 0.3); + bg_fade_gradient = Some( + Gradient::with_steps(&[bg.clone(), faded_bg_color.clone()], 10, false) + .map_err(EngineError::Other)?, + ); + bg_brighten_gradient = Some( + Gradient::with_steps(&[faded_bg_color, bg.clone()], 10, false) + .map_err(EngineError::Other)?, + ); + } + + let ch = &mut ctx.terminal.arena[id.0 as usize]; + if fg_fade_gradient.is_some() || bg_fade_gradient.is_some() { + ch.animation + .scenes + .get_mut("beam_row") + .unwrap() + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 2, + fg_fade_gradient.as_ref(), + bg_fade_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + ch.animation + .scenes + .get_mut("beam_column") + .unwrap() + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 2, + fg_fade_gradient.as_ref(), + bg_fade_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + ch.animation + .scenes + .get_mut("beam_row") + .unwrap() + .add_frame( + &input_symbol, + 2, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + ch.animation + .scenes + .get_mut("beam_column") + .unwrap() + .add_frame( + &input_symbol, + 2, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + + if fg_brighten_gradient.is_some() || bg_brighten_gradient.is_some() { + ch.animation + .scenes + .get_mut("brighten") + .unwrap() + .apply_gradient_to_symbols( + &[input_symbol.clone()], + self.config.final_gradient_frames, + fg_brighten_gradient.as_ref(), + bg_brighten_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + ch.animation + .scenes + .get_mut("brighten") + .unwrap() + .add_frame( + &input_symbol, + self.config.final_gradient_frames, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } + } + + self.pending_groups = groups; + ctx.rng.shuffle(&mut self.pending_groups); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.phase != Phase::Complete || !ctx.active_characters.is_empty() { + match self.phase { + Phase::Beams => { + if self.delay == 0 { + if !self.pending_groups.is_empty() { + for _ in 0..ctx.rng.randint(1, 5) { + if !self.pending_groups.is_empty() { + self.active_groups.push(self.pending_groups.remove(0)); + } + } + } + self.delay = self.config.beam_delay; + } else { + self.delay -= 1; + } + let mut active_groups = std::mem::take(&mut self.active_groups); + for group in &mut active_groups { + group.next_character_counter += group.speed; + // int() truncation + let count = group.next_character_counter as i64; + if count > 1 { + for _ in 0..count { + if !group.characters.is_empty() { + if let Some(next_char) = self.get_next_character(ctx, group) { + ctx.active_characters.insert(next_char); + } + } + } + } + } + active_groups.retain(|group| !group.characters.is_empty()); + self.active_groups = active_groups; + if self.pending_groups.is_empty() + && self.active_groups.is_empty() + && ctx.active_characters.is_empty() + { + self.phase = Phase::FinalWipe; + } + } + Phase::FinalWipe => { + if !self.final_wipe_groups.is_empty() { + for _ in 0..self.config.final_wipe_speed { + if self.final_wipe_groups.is_empty() { + break; + } + let next_group = self.final_wipe_groups.remove(0); + for id in next_group { + ctx.activate_scene(self, id, "brighten"); + ctx.terminal.set_character_visibility(id, true); + ctx.active_characters.insert(id); + } + } + } else { + self.phase = Phase::Complete; + } + } + Phase::Complete => {} + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/binarypath.rs b/vendor/ttfx/src/effects/binarypath.rs new file mode 100644 index 0000000..b3b8249 --- /dev/null +++ b/vendor/ttfx/src/effects/binarypath.rs @@ -0,0 +1,426 @@ +//! binarypath, ported from effects/effect_binarypath.py. +//! +//! The inner _BinaryRepresentation class is the `BinaryRepresentation` struct. +//! No observable set iteration beyond the engine-canonical active_characters +//! (docs/ordering-inventory.md). + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_non_negative_ratio, parse_positive_float, +}; +use crate::engine::animation::{Animation, ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct BinaryPathConfig { + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["00d500", "007500"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "radial", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, + + /// Space separated, unquoted, list of colors for the binary characters. Character color is randomly assigned from this list. + #[arg(long = "binary-colors", num_args = 1.., value_parser = parse_color, + default_values = ["044E29", "157e38", "45bf55", "95ed87"])] + pub binary_colors: Vec, + + /// Speed of the binary groups as they travel around the terminal. + #[arg(long = "movement-speed", default_value_t = 1.0, value_parser = parse_positive_float)] + pub movement_speed: f64, + + /// Maximum number of binary groups that are active at any given time as a percentage of the total number of binary groups. Lower this to improve performance. + #[arg(long = "active-binary-groups", default_value_t = 0.08, value_parser = parse_non_negative_ratio)] + pub active_binary_groups: f64, +} + +/// BinaryPathIterator._BinaryRepresentation. +struct BinaryRepresentation { + character: CharId, + binary_characters: Vec, + pending_binary_characters: Vec, + input_coord: Coord, + is_active: bool, +} + +impl BinaryRepresentation { + /// _BinaryRepresentation._travel_complete. + fn travel_complete(&self, ctx: &EngineCtx) -> bool { + self.binary_characters + .iter() + .all(|&bin_char| ctx.terminal.arena[bin_char.0 as usize].motion.current_coord == self.input_coord) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + Travel, + Wipe, +} + +/// typing.Literal["col", "row"]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Orientation { + Col, + Row, +} + +pub struct BinaryPath { + config: BinaryPathConfig, + pending_binary_representations: Vec, + character_final_color_map: HashMap, + last_frame_provided: bool, + active_binary_reps: Vec, + complete: bool, + phase: Phase, + final_wipe_chars: Vec>, + max_active_binary_groups: i64, +} + +impl BinaryPath { + pub fn new(config: BinaryPathConfig) -> Self { + BinaryPath { + config, + pending_binary_representations: Vec::new(), + character_final_color_map: HashMap::new(), + last_frame_provided: false, + active_binary_reps: Vec::new(), + complete: false, + phase: Phase::Travel, + final_wipe_chars: Vec::new(), + max_active_binary_groups: 0, + } + } +} + +impl EffectHooks for BinaryPath { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for BinaryPath { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // __init__: final_wipe_chars computed before build() + self.final_wipe_chars = ctx + .terminal + .get_characters_grouped(CharacterFilter::default(), CharacterGroup::DiagonalTopRightToBottomLeft); + + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + let ch = &ctx.terminal.arena[id.0 as usize]; + let final_colors = if dynamic { + ColorPair::new(ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone()) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&ch.input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors); + } + + for &id in &characters { + let (symbol, input_coord) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.animation.current_character_visual.symbol.clone(), ch.input_coord) + }; + // format(ord(symbol), "08b") + let code_point = symbol.chars().next().expect("empty symbol") as u32; + let binary_string = format!("{code_point:08b}"); + let mut bin_rep = BinaryRepresentation { + character: id, + binary_characters: Vec::new(), + pending_binary_characters: Vec::new(), + input_coord, + is_active: false, + }; + for binary_char in binary_string.chars() { + let added = ctx.terminal.add_character(&binary_char.to_string(), Coord::new(0, 0)); + bin_rep.binary_characters.push(added); + bin_rep.pending_binary_characters.push(added); + } + self.pending_binary_representations.push(bin_rep); + } + + let mut pending_reps = std::mem::take(&mut self.pending_binary_representations); + for bin_rep in &pending_reps { + let mut path_coords: Vec = Vec::new(); + let starting_coord = ctx.terminal.canvas.random_coord(&mut ctx.rng, true, false); + path_coords.push(starting_coord); + let mut last_orientation = *ctx.rng.choice(&[Orientation::Col, Orientation::Row]); + let mut next_coord = starting_coord; // will be rebound in the loop + let input_coord = bin_rep.input_coord; + while *path_coords.last().unwrap() != input_coord { + let last_coord = *path_coords.last().unwrap(); + let column_direction = if last_coord.column > input_coord.column { + -1 + } else if last_coord.column == input_coord.column { + 0 + } else { + 1 + }; + let row_direction = if last_coord.row > input_coord.row { + -1 + } else if last_coord.row == input_coord.row { + 0 + } else { + 1 + }; + let max_column_distance = (last_coord.column - input_coord.column).abs(); + let max_row_distance = (last_coord.row - input_coord.row).abs(); + if last_orientation == Orientation::Col && max_row_distance > 0 { + // min(max_row_distance, max(10, int(canvas.right * 0.2))) — int() truncation + let limit = std::cmp::min( + max_row_distance, + std::cmp::max(10, (ctx.terminal.canvas.right as f64 * 0.2) as i64), + ); + next_coord = Coord::new( + last_coord.column, + last_coord.row + ctx.rng.randint(1, limit) * row_direction, + ); + last_orientation = Orientation::Row; + } else if last_orientation == Orientation::Row && max_column_distance > 0 { + next_coord = Coord::new( + last_coord.column + ctx.rng.randint(1, std::cmp::min(max_column_distance, 4)) * column_direction, + last_coord.row, + ); + last_orientation = Orientation::Col; + } else { + next_coord = input_coord; + } + + path_coords.push(next_coord); + } + + path_coords.push(next_coord); + let final_coord = input_coord; + path_coords.push(final_coord); + for &bin_effectchar in &bin_rep.binary_characters { + let digital_path = { + let ch = &mut ctx.terminal.arena[bin_effectchar.0 as usize]; + ch.motion.set_coordinate(path_coords[0]); + let path_id = ch + .motion + .new_path(self.config.movement_speed, None, None, 0, false, "") + .map_err(EngineError::Other)?; + let path = ch.motion.paths.get_mut(&path_id).unwrap(); + for &coord in &path_coords { + path.new_waypoint(coord, None, "").map_err(EngineError::Other)?; + } + path_id + }; + ctx.activate_path(self, bin_effectchar, &digital_path); + ctx.terminal.arena[bin_effectchar.0 as usize].layer = 1; + let color = ctx.rng.choice(&self.config.binary_colors).clone(); + let color_scn = { + let ch = &mut ctx.terminal.arena[bin_effectchar.0 as usize]; + let symbol = ch.animation.current_character_visual.symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .add_frame( + &symbol, + 1, + VisualParams { colors: Some(ColorPair::new(Some(color), None)), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + scene_id + }; + ctx.activate_scene(self, bin_effectchar, &color_scn); + } + } + self.pending_binary_representations = pending_reps.drain(..).collect(); + + for &id in &characters { + let (input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + let final_colors = self.character_final_color_map.get(&id).unwrap().clone(); + let final_fg_color = final_colors.fg_color.clone(); + let final_bg_color = final_colors.bg_color.clone(); + let dim_fg_color = final_fg_color.as_ref().map(|c| Animation::adjust_color_brightness(c, 0.5)); + let dim_bg_color = final_bg_color.as_ref().map(|c| Animation::adjust_color_brightness(c, 0.5)); + let collapse_fg_gradient = match &dim_fg_color { + Some(dim) => Some( + Gradient::with_steps(&[Color::from_hex("ffffff").unwrap(), dim.clone()], 7, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let collapse_bg_gradient = match &dim_bg_color { + Some(dim) => Some( + Gradient::with_steps(&[Color::from_hex("ffffff").unwrap(), dim.clone()], 7, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let collapse_scn = ch.animation.new_scene(false, None, Some(Easing::InQuad), "collapse_scn", uses_pre); + let scene = ch.animation.scenes.get_mut(&collapse_scn).unwrap(); + if collapse_fg_gradient.is_some() || collapse_bg_gradient.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 3, + collapse_fg_gradient.as_ref(), + collapse_bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + 3, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } + + let brighten_fg_gradient = match (&dim_fg_color, &final_fg_color) { + (Some(dim), Some(final_fg)) => Some( + Gradient::with_steps(&[dim.clone(), final_fg.clone()], 10, false).map_err(EngineError::Other)?, + ), + _ => None, + }; + let brighten_bg_gradient = match (&dim_bg_color, &final_bg_color) { + (Some(dim), Some(final_bg)) => Some( + Gradient::with_steps(&[dim.clone(), final_bg.clone()], 10, false).map_err(EngineError::Other)?, + ), + _ => None, + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let brighten_scn = ch.animation.new_scene(false, None, None, "brighten_scn", uses_pre); + let scene = ch.animation.scenes.get_mut(&brighten_scn).unwrap(); + if brighten_fg_gradient.is_some() || brighten_bg_gradient.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 2, + brighten_fg_gradient.as_ref(), + brighten_bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + 2, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } + } + self.max_active_binary_groups = std::cmp::max( + 1, + (self.config.active_binary_groups * self.pending_binary_representations.len() as f64) as i64, + ); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.complete || !ctx.active_characters.is_empty() { + if self.phase == Phase::Travel { + while (self.active_binary_reps.len() as i64) < self.max_active_binary_groups + && !self.pending_binary_representations.is_empty() + { + let index = + ctx.rng.randrange(0, self.pending_binary_representations.len() as i64) as usize; + let mut next_binary_rep = self.pending_binary_representations.remove(index); + next_binary_rep.is_active = true; + self.active_binary_reps.push(next_binary_rep); + } + + if !self.active_binary_reps.is_empty() { + let mut active_reps = std::mem::take(&mut self.active_binary_reps); + for active_rep in &mut active_reps { + if !active_rep.pending_binary_characters.is_empty() { + let next_char = active_rep.pending_binary_characters.remove(0); + ctx.active_characters.insert(next_char); + ctx.terminal.set_character_visibility(next_char, true); + } else if active_rep.travel_complete(ctx) { + // _deactivate + for &bin_char in &active_rep.binary_characters { + ctx.terminal.set_character_visibility(bin_char, false); + } + active_rep.is_active = false; + // _activate_source_character + ctx.terminal.set_character_visibility(active_rep.character, true); + ctx.activate_scene(self, active_rep.character, "collapse_scn"); + ctx.active_characters.insert(active_rep.character); + } + } + active_reps.retain(|binary_rep| binary_rep.is_active); + self.active_binary_reps = active_reps; + } + + if ctx.active_characters.is_empty() { + self.phase = Phase::Wipe; + } + } + + if self.phase == Phase::Wipe { + for _ in 0..2 { + if !self.final_wipe_chars.is_empty() { + let next_group = self.final_wipe_chars.remove(0); + for character in next_group { + ctx.activate_scene(self, character, "brighten_scn"); + ctx.terminal.set_character_visibility(character, true); + ctx.active_characters.insert(character); + } + } else { + self.complete = true; + } + } + } + + ctx.update(self); + return Some(ctx.frame()); + } + + if !self.last_frame_provided { + self.last_frame_provided = true; + return Some(ctx.frame()); + } + + None + } +} diff --git a/vendor/ttfx/src/effects/blackhole.rs b/vendor/ttfx/src/effects/blackhole.rs new file mode 100644 index 0000000..9c680b6 --- /dev/null +++ b/vendor/ttfx/src/effects/blackhole.rs @@ -0,0 +1,616 @@ +//! blackhole, ported from effects/effect_blackhole.py. + +use std::collections::{HashMap, HashSet}; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_gradient_direction, parse_gradient_steps}; +use crate::engine::animation::{ExistingColorHandling, SyncMetric, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::pycompat::{floor_div, round_half_even}; + +#[derive(Args, Debug, Clone)] +pub struct BlackholeConfig { + /// Color for the stars that comprise the blackhole border. + #[arg(long = "blackhole-color", default_value = "ffffff", value_parser = parse_color)] + pub blackhole_color: Color, + + /// List of colors from which character colors will be chosen and applied after the explosion, but before the cooldown to final color. + #[arg(long = "star-colors", num_args = 1.., value_parser = parse_color, + default_values = ["ffcc0d", "ff7326", "ff194d", "bf2669", "702a8c", "049dbf"])] + pub star_colors: Vec, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "ffffff"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["9"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Phase { + Forming, + Consuming, + Collapsing, + Exploding, + Complete, +} + +pub struct Blackhole { + config: BlackholeConfig, + blackhole_chars: Vec, + awaiting_consumption_chars: Vec, + blackhole_radius: i64, + character_final_color_map: HashMap, + formation_delay: i64, + f_delay: i64, + phase: Phase, + awaiting_blackhole_chars: Vec, +} + +impl Blackhole { + pub fn new(config: BlackholeConfig) -> Self { + Blackhole { + config, + blackhole_chars: Vec::new(), + awaiting_consumption_chars: Vec::new(), + blackhole_radius: 0, + character_final_color_map: HashMap::new(), + formation_delay: 0, + f_delay: 0, + phase: Phase::Forming, + awaiting_blackhole_chars: Vec::new(), + } + } + + /// BlackholeIterator.prepare_blackhole. + fn prepare_blackhole(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let star_symbols = ["*", "'", "`", "¤", "•", "°", "·"]; + let starfield_colors = Gradient::with_steps( + &[Color::from_hex("#4a4a4d").unwrap(), Color::from_hex("#ffffff").unwrap()], + 6, + false, + ) + .map_err(EngineError::Other)? + .spectrum; + // gradient_map: dict keyed by starfield color, indexed here by spectrum position + let mut gradient_map: Vec = Vec::new(); + for color in &starfield_colors { + gradient_map.push( + Gradient::with_steps(&[color.clone(), Color::from_hex("#000000").unwrap()], 10, false) + .map_err(EngineError::Other)?, + ); + } + let mut available_chars: Vec = ctx.terminal.input_characters.clone(); + while (self.blackhole_chars.len() as i64) < self.blackhole_radius * 3 && !available_chars.is_empty() { + let index = ctx.rng.randrange(0, available_chars.len() as i64) as usize; + self.blackhole_chars.push(available_chars.remove(index)); + } + let black_hole_ring_positions = geometry::find_coords_on_circle( + ctx.terminal.canvas.center, + self.blackhole_radius, + self.blackhole_chars.len() as i64, + true, + ); + for (position_index, &id) in self.blackhole_chars.iter().enumerate() { + let starting_pos = black_hole_ring_positions[position_index]; + let blackhole_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let path_id = ch + .motion + .new_path(0.7, Some(Easing::InOutSine), None, 0, false, "blackhole") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(starting_pos, None, "") + .map_err(EngineError::Other)?; + path_id + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let uses_pre = ch.uses_input_preexisting_colors; + let blackhole_scn = ch.animation.new_scene(false, None, None, "blackhole", uses_pre); + ch.animation + .scenes + .get_mut(&blackhole_scn) + .unwrap() + .add_frame( + "*", + 1, + VisualParams { + colors: Some(ColorPair::new(Some(self.config.blackhole_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path(blackhole_path), + EventAction::SetLayer(1), + ) + .map_err(EngineError::Other)?; + // make rotation waypoints + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let rotation_path = ch + .motion + .new_path(0.45, None, None, 0, true, "blackhole_rotation") + .map_err(EngineError::Other)?; + let rotated: Vec<_> = black_hole_ring_positions[position_index..] + .iter() + .chain(black_hole_ring_positions[..position_index].iter()) + .copied() + .collect(); + for coord in rotated { + let path = ch.motion.paths.get_mut(&rotation_path).unwrap(); + let waypoint_id = path.waypoints.len().to_string(); + path.new_waypoint(coord, None, &waypoint_id).map_err(EngineError::Other)?; + } + } + } + let blackhole_set: HashSet = self.blackhole_chars.iter().copied().collect(); + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + let canvas_center = ctx.terminal.canvas.center; + for &id in &characters { + ctx.terminal.set_character_visibility(id, true); + let star_symbol = *ctx.rng.choice(&star_symbols); + let star_color_index = { + // random.choice(starfield_colors) + let _ = &starfield_colors; + ctx.rng.choice_index(starfield_colors.len()) + }; + let star_color = starfield_colors[star_color_index].clone(); + let starting_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let uses_pre = ch.uses_input_preexisting_colors; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .add_frame( + star_symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(Some(star_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + scene_id + }; + ctx.activate_scene(self, id, &starting_scn); + if !blackhole_set.contains(&id) { + let starfield_coord = ctx.terminal.canvas.random_coord(&mut ctx.rng, false, false); + let speed = ctx.rng.uniform(0.17, 0.30); + let singularity_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.set_coordinate(starfield_coord); + let path_id = ch + .motion + .new_path(speed, Some(Easing::InExpo), None, 0, false, "singularity") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(canvas_center, None, "") + .map_err(EngineError::Other)?; + path_id + }; + let consumed_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let uses_pre = ch.uses_input_preexisting_colors; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + let scene = ch.animation.scenes.get_mut(&scene_id).unwrap(); + for color in &gradient_map[star_color_index].spectrum { + scene + .add_frame( + star_symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + scene.add_frame(" ", 1, VisualParams::default()).map_err(EngineError::Other)?; + scene.sync = Some(SyncMetric::Distance); + scene_id + }; + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path(singularity_path.clone()), + EventAction::SetLayer(2), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path(singularity_path), + EventAction::ActivateScene(consumed_scn), + ) + .map_err(EngineError::Other)?; + self.awaiting_consumption_chars.push(id); + } + } + ctx.rng.shuffle(&mut self.awaiting_consumption_chars); + Ok(()) + } + + /// BlackholeIterator.rotate_blackhole. + fn rotate_blackhole(&mut self, ctx: &mut EngineCtx) { + for &id in &self.blackhole_chars.clone() { + ctx.activate_path(self, id, "blackhole_rotation"); + ctx.active_characters.insert(id); + } + } + + /// BlackholeIterator.collapse_blackhole. + fn collapse_blackhole(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let mut black_hole_ring_positions = geometry::find_coords_on_circle( + ctx.terminal.canvas.center, + self.blackhole_radius + 3, + self.blackhole_chars.len() as i64, + true, + ); + let unstable_symbols = ["◦", "◎", "◉", "●", "◉", "◎", "◦"]; + let mut point_char_made = false; + let canvas_center = ctx.terminal.canvas.center; + for &id in &self.blackhole_chars.clone() { + let next_pos = black_hole_ring_positions.remove(0); + let (expand_path, collapse_path) = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let expand_path = + ch.motion.new_path(0.2, Some(Easing::InExpo), None, 0, false, "").map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&expand_path) + .unwrap() + .new_waypoint(next_pos, None, "") + .map_err(EngineError::Other)?; + let collapse_path = + ch.motion.new_path(0.3, Some(Easing::InExpo), None, 0, false, "").map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&collapse_path) + .unwrap() + .new_waypoint(canvas_center, None, "") + .map_err(EngineError::Other)?; + (expand_path, collapse_path) + }; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(expand_path.clone()), + EventAction::ActivatePath(collapse_path.clone()), + ) + .map_err(EngineError::Other)?; + if !point_char_made { + let point_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let uses_pre = ch.uses_input_preexisting_colors; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + for _ in 0..3 { + for symbol in unstable_symbols { + let color = ctx.rng.choice(&self.config.star_colors).clone(); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&point_scn) + .unwrap() + .add_frame( + symbol, + 3, + VisualParams { + colors: Some(ColorPair::new(Some(color), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(collapse_path.clone()), + EventAction::ActivateScene(point_scn), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(collapse_path), + EventAction::SetLayer(3), + ) + .map_err(EngineError::Other)?; + point_char_made = true; + } + + ctx.activate_path(self, id, &expand_path); + ctx.active_characters.insert(id); + } + Ok(()) + } + + /// BlackholeIterator.explode_singularity. + fn explode_singularity(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let star_colors = [ + Color::from_hex("#ffcc0d").unwrap(), + Color::from_hex("#ff7326").unwrap(), + Color::from_hex("#ff194d").unwrap(), + Color::from_hex("#bf2669").unwrap(), + Color::from_hex("#702a8c").unwrap(), + Color::from_hex("#049dbf").unwrap(), + ]; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + let (input_coord, input_symbol, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_coord, + ch.input_symbol.clone(), + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let circle_coords = geometry::find_coords_on_circle(input_coord, 3, 5, true); + let nearby_coord = circle_coords[ctx.rng.randrange(0, 5) as usize]; + let nearby_speed = ctx.rng.randint(3, 4) as f64 / 10.0; + let nearby_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let path_id = ch + .motion + .new_path(nearby_speed, Some(Easing::OutExpo), None, 0, false, "") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(nearby_coord, None, "") + .map_err(EngineError::Other)?; + path_id + }; + let input_speed = ctx.rng.randint(4, 6) as f64 / 100.0; + let input_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let path_id = ch + .motion + .new_path(input_speed, Some(Easing::InCubic), None, 0, false, "") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + path_id + }; + let explode_star_color = ctx.rng.choice(&star_colors).clone(); + let explode_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .add_frame( + &input_symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(Some(explode_star_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + scene_id + }; + let cooling_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + if dynamic && ctx.preexisting_colors_present { + if input_fg.is_none() && input_bg.is_none() { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&cooling_scn) + .unwrap() + .add_frame( + &input_symbol, + 1, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } else { + let cooling_gradient_fg = match &input_fg { + Some(fg) => Some( + Gradient::with_steps(&[explode_star_color.clone(), fg.clone()], 10, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let cooling_gradient_bg = match &input_bg { + Some(bg) => Some( + Gradient::with_steps(&[explode_star_color.clone(), bg.clone()], 10, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&cooling_scn) + .unwrap() + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 20, + cooling_gradient_fg.as_ref(), + cooling_gradient_bg.as_ref(), + ) + .map_err(EngineError::Other)?; + } + } else { + let final_color = self.character_final_color_map.get(&id).unwrap().clone(); + let cooling_gradient = + Gradient::with_steps(&[explode_star_color.clone(), final_color], 10, false) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&cooling_scn) + .unwrap() + .apply_gradient_to_symbols(&[input_symbol.clone()], 20, Some(&cooling_gradient), None) + .map_err(EngineError::Other)?; + } + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(nearby_path.clone()), + EventAction::ActivatePath(input_path), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(nearby_path.clone()), + EventAction::ActivateScene(cooling_scn), + ) + .map_err(EngineError::Other)?; + ctx.activate_scene(self, id, &explode_scn); + ctx.activate_path(self, id, &nearby_path); + ctx.active_characters.insert(id); + } + Ok(()) + } +} + +impl EffectHooks for Blackhole { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Blackhole { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // BlackholeIterator.__init__ + self.blackhole_radius = std::cmp::max( + std::cmp::min( + round_half_even(ctx.terminal.canvas.width as f64 * 0.3), + round_half_even(ctx.terminal.canvas.height as f64 * 0.20), + ), + 3, + ); + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + let input_coord = ctx.terminal.arena[id.0 as usize].input_coord; + self.character_final_color_map + .insert(id, final_gradient_mapping.get(&input_coord).unwrap().clone()); + } + self.prepare_blackhole(ctx)?; + self.formation_delay = std::cmp::max(floor_div(100, self.blackhole_chars.len() as i64), 6); + self.f_delay = self.formation_delay; + self.phase = Phase::Forming; + self.awaiting_blackhole_chars = self.blackhole_chars.clone(); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !ctx.active_characters.is_empty() || self.phase != Phase::Complete { + match self.phase { + Phase::Forming => { + if !self.awaiting_blackhole_chars.is_empty() { + if self.f_delay == 0 { + let next_char = self.awaiting_blackhole_chars.remove(0); + ctx.activate_path(self, next_char, "blackhole"); + ctx.activate_scene(self, next_char, "blackhole"); + ctx.active_characters.insert(next_char); + self.f_delay = self.formation_delay; + } else { + self.f_delay -= 1; + } + } else if ctx.active_characters.is_empty() { + self.rotate_blackhole(ctx); + self.phase = Phase::Consuming; + } + } + Phase::Consuming => { + if !self.awaiting_consumption_chars.is_empty() { + for &id in &self.awaiting_consumption_chars.clone() { + ctx.activate_path(self, id, "singularity"); + ctx.active_characters.insert(id); + } + self.awaiting_consumption_chars.clear(); + } else { + let blackhole_set: HashSet = self.blackhole_chars.iter().copied().collect(); + if ctx.active_characters.iter().all(|id| blackhole_set.contains(&id)) { + self.phase = Phase::Collapsing; + } + } + } + Phase::Collapsing => { + self.collapse_blackhole(ctx).expect("collapse_blackhole failed"); + self.phase = Phase::Exploding; + } + Phase::Exploding => { + if self.blackhole_chars.iter().all(|&id| { + let ch = &ctx.terminal.arena[id.0 as usize]; + ch.motion.active_path.is_none() && ch.animation.active_scene.is_none() + }) { + self.explode_singularity(ctx).expect("explode_singularity failed"); + self.phase = Phase::Complete; + } + } + Phase::Complete => {} + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/bouncyballs.rs b/vendor/ttfx/src/effects/bouncyballs.rs new file mode 100644 index 0000000..994eec6 --- /dev/null +++ b/vendor/ttfx/src/effects/bouncyballs.rs @@ -0,0 +1,253 @@ +//! bouncyballs, ported from effects/effect_bouncyballs.py. + +use std::collections::{BTreeMap, HashMap}; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_easing, parse_gradient_direction, parse_gradient_steps, parse_non_negative_int, parse_positive_float, + parse_symbol, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct BouncyBallsConfig { + /// Space separated list of colors from which ball colors will be randomly selected. + #[arg(long = "ball-colors", num_args = 1.., value_parser = parse_color, + default_values = ["d1f4a5", "96e2a4", "5acda9"])] + pub ball_colors: Vec, + + /// Space separated list of symbols to use for the balls. + #[arg(long = "ball-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["*", "o", "O", "0", "."])] + pub ball_symbols: Vec, + + /// Number of frames between ball drops, increase to reduce ball drop rate. + #[arg(long = "ball-delay", default_value_t = 4, value_parser = parse_non_negative_int)] + pub ball_delay: i64, + + /// Movement speed of the characters. + #[arg(long = "movement-speed", default_value_t = 0.45, value_parser = parse_positive_float)] + pub movement_speed: f64, + + /// Easing function to use for character movement. + #[arg(long = "movement-easing", default_value = "out_bounce", value_parser = parse_easing)] + pub movement_easing: Easing, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["f8ffae", "43c6ac"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct BouncyBalls { + config: BouncyBallsConfig, + pending_chars: Vec, + group_by_row: BTreeMap>, + character_final_color_map: HashMap, + ball_delay: i64, +} + +impl BouncyBalls { + pub fn new(config: BouncyBallsConfig) -> Self { + BouncyBalls { + config, + pending_chars: Vec::new(), + group_by_row: BTreeMap::new(), + character_final_color_map: HashMap::new(), + ball_delay: 0, + } + } +} + +impl EffectHooks for BouncyBalls { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for BouncyBalls { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + let canvas_top = ctx.terminal.canvas.top; + for id in characters { + let (input_coord, input_symbol, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_coord, + ch.input_symbol.clone(), + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + self.character_final_color_map + .insert(id, final_gradient_mapping.get(&input_coord).unwrap().clone()); + let color = ctx.rng.choice(&self.config.ball_colors).clone(); + let symbol = ctx.rng.choice(&self.config.ball_symbols).clone(); + let ball_scene = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .add_frame( + &symbol, + 1, + VisualParams { colors: Some(ColorPair::new(Some(color.clone()), None)), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + scene_id + }; + let final_scene = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + if dynamic { + let fg_gradient = match &input_fg { + Some(fg) => Some( + Gradient::with_steps(&[color.clone(), fg.clone()], 10, false).map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &input_bg { + Some(bg) => Some( + Gradient::with_steps(&[color.clone(), bg.clone()], 10, false).map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&final_scene).unwrap(); + if fg_gradient.is_some() || bg_gradient.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 6, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + 6, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_color = self.character_final_color_map.get(&id).unwrap().clone(); + let char_final_gradient = + Gradient::with_steps(&[color.clone(), final_color], 10, false).map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&final_scene) + .unwrap() + .apply_gradient_to_symbols(&[input_symbol.clone()], 6, Some(&char_final_gradient), None) + .map_err(EngineError::Other)?; + } + // Coord(input column, int(canvas.top * uniform(1.0, 1.5))) — int() truncation + let drop_row = (canvas_top as f64 * ctx.rng.uniform(1.0, 1.5)) as i64; + let input_coord_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.set_coordinate(Coord::new(input_coord.column, drop_row)); + let path_id = ch + .motion + .new_path(self.config.movement_speed, Some(self.config.movement_easing), None, 0, false, "") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + path_id + }; + ctx.activate_path(self, id, &input_coord_path); + ctx.activate_scene(self, id, &ball_scene); + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(input_coord_path), + EventAction::ActivateScene(final_scene), + ) + .map_err(EngineError::Other)?; + self.pending_chars.push(id); + } + let mut sorted_chars = self.pending_chars.clone(); + sorted_chars.sort_by_key(|&id| ctx.terminal.arena[id.0 as usize].input_coord.row); + for id in sorted_chars { + let row = ctx.terminal.arena[id.0 as usize].input_coord.row; + self.group_by_row.entry(row).or_default().push(id); + } + self.pending_chars.clear(); + self.ball_delay = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.group_by_row.is_empty() || !ctx.active_characters.is_empty() || !self.pending_chars.is_empty() { + if self.pending_chars.is_empty() && !self.group_by_row.is_empty() { + let min_row = *self.group_by_row.keys().next().unwrap(); + let group = self.group_by_row.remove(&min_row).unwrap(); + self.pending_chars.extend(group); + } + if !self.pending_chars.is_empty() { + if self.ball_delay == 0 { + for _ in 0..ctx.rng.randint(2, 6) { + if self.pending_chars.is_empty() { + break; + } + let index = ctx.rng.randint(0, self.pending_chars.len() as i64 - 1) as usize; + let next_character = self.pending_chars.remove(index); + ctx.terminal.set_character_visibility(next_character, true); + ctx.active_characters.insert(next_character); + } + self.ball_delay = self.config.ball_delay; + } else { + self.ball_delay -= 1; + } + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/bubbles.rs b/vendor/ttfx/src/effects/bubbles.rs new file mode 100644 index 0000000..39b92ef --- /dev/null +++ b/vendor/ttfx/src/effects/bubbles.rs @@ -0,0 +1,522 @@ +//! bubbles, ported from effects/effect_bubbles.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_easing, parse_gradient_direction, parse_gradient_steps, parse_positive_float, parse_positive_int, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::{self, Coord}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +/// pop_condition choices. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PopCondition { + Row, + Bottom, + Anywhere, +} + +fn parse_pop_condition(s: &str) -> Result { + Ok(match s { + "row" => PopCondition::Row, + "bottom" => PopCondition::Bottom, + "anywhere" => PopCondition::Anywhere, + _ => return Err(format!("invalid choice: '{s}' (choose from 'row', 'bottom', 'anywhere')")), + }) +} + +#[derive(Args, Debug, Clone)] +pub struct BubblesConfig { + /// If set, the bubbles will be colored with a rotating rainbow gradient. + #[arg(long = "rainbow", default_value_t = false)] + pub rainbow: bool, + + /// Space separated, unquoted, list of colors for the bubbles. Ignored if --no-rainbow is left as default False. + #[arg(long = "bubble-colors", num_args = 1.., value_parser = parse_color, + default_values = ["d33aff", "7395c4", "43c2a7", "02ff7f"])] + pub bubble_colors: Vec, + + /// Color for the spray emitted when a bubble pops. + #[arg(long = "pop-color", default_value = "ffffff", value_parser = parse_color)] + pub pop_color: Color, + + /// Speed of the floating bubbles. + #[arg(long = "bubble-speed", default_value_t = 0.5, value_parser = parse_positive_float)] + pub bubble_speed: f64, + + /// Number of frames between bubbles. + #[arg(long = "bubble-delay", default_value_t = 20, value_parser = parse_positive_int)] + pub bubble_delay: i64, + + /// Condition for a bubble to pop. + #[arg(long = "pop-condition", default_value = "row", value_parser = parse_pop_condition)] + pub pop_condition: PopCondition, + + /// Easing function to use for character movement after a bubble pops. + #[arg(long = "movement-easing", default_value = "in_out_sine", value_parser = parse_easing)] + pub movement_easing: Easing, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["d33aff", "02ff7f"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +/// BubblesIterator.Bubble state (methods live on Bubbles for hooks access). +struct Bubble { + characters: Vec, + radius: i64, + anchor_char: CharId, + lowest_row: i64, + landed: bool, +} + +pub struct Bubbles { + config: BubblesConfig, + bubbles: Vec, + animating_bubbles: Vec, + rainbow_gradient: Gradient, + character_final_color_map: HashMap, + steps_since_last_bubble: i64, +} + +impl Bubbles { + pub fn new(config: BubblesConfig) -> Self { + let rainbow_stops = [ + Color::from_hex("e81416").unwrap(), // red + Color::from_hex("ffa500").unwrap(), // orange + Color::from_hex("faeb36").unwrap(), // yellow + Color::from_hex("79c314").unwrap(), // green + Color::from_hex("487de7").unwrap(), // blue + Color::from_hex("4b369d").unwrap(), // indigo + Color::from_hex("70369d").unwrap(), // violet + ]; + let rainbow_gradient = Gradient::with_steps(&rainbow_stops, 5, false).expect("rainbow gradient"); + Bubbles { + config, + bubbles: Vec::new(), + animating_bubbles: Vec::new(), + rainbow_gradient, + character_final_color_map: HashMap::new(), + steps_since_last_bubble: 0, + } + } + + /// Bubble.set_character_coordinates. + fn bubble_set_character_coordinates(&mut self, ctx: &mut EngineCtx, bubble: &mut Bubble) { + let anchor_coord = ctx.terminal.arena[bubble.anchor_char.0 as usize].motion.current_coord; + let points = geometry::find_coords_on_circle( + anchor_coord, + bubble.radius, + bubble.characters.len() as i64, + false, + ); + for (i, &id) in bubble.characters.iter().enumerate() { + let point = points[i]; + ctx.terminal.arena[id.0 as usize].motion.set_coordinate(point); + if point.row == bubble.lowest_row { + bubble.landed = true; + } + } + if self.config.pop_condition == PopCondition::Anywhere && ctx.rng.random() < 0.002 { + bubble.landed = true; + } + } + + /// Bubble.__init__ (+ make_waypoints + make_gradients). + fn make_bubble( + &mut self, + ctx: &mut EngineCtx, + origin: Coord, + characters: Vec, + ) -> Result { + let radius = std::cmp::max(characters.len() as i64 / 5, 1); + let anchor_char = ctx.terminal.add_character(" ", origin); + let lowest_row = if self.config.pop_condition == PopCondition::Row { + characters.iter().map(|&id| ctx.terminal.arena[id.0 as usize].input_coord.row).min().unwrap() + } else { + ctx.terminal.canvas.bottom + }; + let mut bubble = Bubble { characters, radius, anchor_char, lowest_row, landed: false }; + self.bubble_set_character_coordinates(ctx, &mut bubble); + bubble.landed = false; + // make_waypoints + let waypoint_column = ctx.rng.randint(ctx.terminal.canvas.left, ctx.terminal.canvas.right); + let floor_path = { + let ch = &mut ctx.terminal.arena[bubble.anchor_char.0 as usize]; + let path_id = ch + .motion + .new_path(self.config.bubble_speed, None, None, 0, false, "") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(Coord::new(waypoint_column, bubble.lowest_row), None, "") + .map_err(EngineError::Other)?; + path_id + }; + ctx.activate_path(self, bubble.anchor_char, &floor_path); + // make_gradients + if self.config.rainbow { + let mut rainbow_gradient: Vec = self.rainbow_gradient.spectrum.clone(); + let mut gradient_offset: usize = 0; + for &id in &bubble.characters { + let (input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + let sheen_scene = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + let scene = ch.animation.scenes.get_mut(&scene_id).unwrap(); + for step in &rainbow_gradient { + scene + .add_frame( + &input_symbol, + 4, + VisualParams { + colors: Some(ColorPair::new(Some(step.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + scene_id + }; + gradient_offset += 2; + gradient_offset %= rainbow_gradient.len(); + let mut rotated = rainbow_gradient[gradient_offset..].to_vec(); + rotated.extend_from_slice(&rainbow_gradient[..gradient_offset]); + rainbow_gradient = rotated; + ctx.activate_scene(self, id, &sheen_scene); + let active_scene = ctx.terminal.arena[id.0 as usize].animation.active_scene.clone(); + if let Some(scene_id) = active_scene { + ctx.terminal.arena[id.0 as usize] + .animation + .scenes + .get_mut(&scene_id) + .unwrap() + .is_looping = true; + } + } + } else { + let bubble_color = ctx.rng.choice(&self.config.bubble_colors).clone(); + for &id in &bubble.characters { + let (input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + let sheen_scene = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .add_frame( + &input_symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(Some(bubble_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + scene_id + }; + ctx.activate_scene(self, id, &sheen_scene); + } + } + Ok(bubble) + } + + /// Bubble.pop. + fn bubble_pop(&mut self, ctx: &mut EngineCtx, bubble: &Bubble) { + let anchor_coord = ctx.terminal.arena[bubble.anchor_char.0 as usize].motion.current_coord; + let points = geometry::find_coords_on_circle( + anchor_coord, + bubble.radius + 3, + bubble.characters.len() as i64, + true, + ); + // zip(characters, points) — truncates at the shorter sequence + for (&id, &point) in bubble.characters.iter().zip(points.iter()) { + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let pop_out_path = ch + .motion + .new_path(0.3, Some(Easing::OutExpo), None, 0, false, "pop_out") + .expect("pop_out path"); + ch.motion + .paths + .get_mut(&pop_out_path) + .unwrap() + .new_waypoint(point, None, "") + .expect("pop_out waypoint"); + } + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path("pop_out".to_string()), + EventAction::ActivatePath("final".to_string()), + ) + .expect("pop_out event"); + } + for &id in &bubble.characters { + ctx.activate_scene(self, id, "pop_1"); + ctx.activate_path(self, id, "pop_out"); + } + } + + /// Bubble.move. + fn bubble_move(&mut self, ctx: &mut EngineCtx, bubble: &mut Bubble) { + ctx.motion_move(self, bubble.anchor_char); + self.bubble_set_character_coordinates(ctx, bubble); + for i in 0..bubble.characters.len() { + let id = bubble.characters[i]; + ctx.step_animation(self, id); + } + } +} + +impl EffectHooks for Bubbles { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Bubbles { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_coord, input_symbol, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_coord, + ch.input_symbol.clone(), + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + self.character_final_color_map + .insert(id, final_gradient_mapping.get(&input_coord).unwrap().clone()); + ctx.terminal.arena[id.0 as usize].layer = 1; + let (pop_1_scene, pop_2_scene) = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let pop_1 = ch.animation.new_scene(false, None, None, "pop_1", uses_pre); + let pop_2 = ch.animation.new_scene(false, None, None, "", uses_pre); + ch.animation + .scenes + .get_mut(&pop_1) + .unwrap() + .add_frame( + "*", + 9, + VisualParams { + colors: Some(ColorPair::new(Some(self.config.pop_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + ch.animation + .scenes + .get_mut(&pop_2) + .unwrap() + .add_frame( + "'", + 9, + VisualParams { + colors: Some(ColorPair::new(Some(self.config.pop_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + (pop_1, pop_2) + }; + let final_scene = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + if dynamic { + let fg_gradient = match &input_fg { + Some(fg) => Some( + Gradient::with_steps(&[self.config.pop_color.clone(), fg.clone()], 8, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &input_bg { + Some(bg) => Some( + Gradient::with_steps(&[self.config.pop_color.clone(), bg.clone()], 8, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&final_scene).unwrap(); + if fg_gradient.is_some() || bg_gradient.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 6, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + 6, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_color = self.character_final_color_map.get(&id).unwrap().clone(); + let char_final_gradient = + Gradient::with_steps(&[self.config.pop_color.clone(), final_color], 8, false) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&final_scene) + .unwrap() + .apply_gradient_to_symbols(&[input_symbol.clone()], 6, Some(&char_final_gradient), None) + .map_err(EngineError::Other)?; + } + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene(pop_1_scene), + EventAction::ActivateScene(pop_2_scene.clone()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene(pop_2_scene), + EventAction::ActivateScene(final_scene), + ) + .map_err(EngineError::Other)?; + let final_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let path_id = ch + .motion + .new_path(0.3, Some(Easing::InOutExpo), None, 0, false, "final") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + path_id + }; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(final_path), + EventAction::SetLayer(0), + ) + .map_err(EngineError::Other)?; + } + + let mut unbubbled_chars: Vec = Vec::new(); + for char_list in + ctx.terminal.get_characters_grouped(CharacterFilter::default(), CharacterGroup::RowBottomToTop) + { + unbubbled_chars.extend(char_list); + } + self.bubbles = Vec::new(); + while !unbubbled_chars.is_empty() { + let mut bubble_group: Vec = Vec::new(); + if unbubbled_chars.len() < 5 { + bubble_group.append(&mut unbubbled_chars); + } else { + let count = ctx.rng.randint(5, std::cmp::min(unbubbled_chars.len() as i64, 20)); + for _ in 0..count { + bubble_group.push(unbubbled_chars.remove(0)); + } + } + let bubble_origin = Coord::new( + ctx.rng.randint(ctx.terminal.canvas.left, ctx.terminal.canvas.right), + ctx.terminal.canvas.top + 10, + ); + let new_bubble = self.make_bubble(ctx, bubble_origin, bubble_group)?; + self.bubbles.push(new_bubble); + } + self.animating_bubbles = Vec::new(); + self.steps_since_last_bubble = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.animating_bubbles.is_empty() || !ctx.active_characters.is_empty() || !self.bubbles.is_empty() { + if !self.bubbles.is_empty() && self.steps_since_last_bubble >= self.config.bubble_delay { + let next_bubble = self.bubbles.remove(0); + for &id in &next_bubble.characters { + ctx.terminal.set_character_visibility(id, true); + } + self.animating_bubbles.push(next_bubble); + self.steps_since_last_bubble = 0; + } + self.steps_since_last_bubble += 1; + + let mut animating = std::mem::take(&mut self.animating_bubbles); + for bubble in &animating { + if bubble.landed { + self.bubble_pop(ctx, bubble); + for &id in &bubble.characters { + ctx.active_characters.insert(id); + } + } + } + animating.retain(|bubble| !bubble.landed); + for bubble in &mut animating { + self.bubble_move(ctx, bubble); + } + self.animating_bubbles = animating; + + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/burn.rs b/vendor/ttfx/src/effects/burn.rs new file mode 100644 index 0000000..10c71c7 --- /dev/null +++ b/vendor/ttfx/src/effects/burn.rs @@ -0,0 +1,380 @@ +//! burn, ported from effects/effect_burn.py. +//! +//! RNG order mirrors BurnIterator.__init__: PrimsSimple construction (random +//! starting coord) first, then the smoke ParticlePool preallocation (2000 +//! symbol choice() draws), then the build() body (algo run to completion), +//! then per-frame randint(2, 4) + smoke emission draws. +//! +//! No new observable set iterations: char_link_order and the pool's available +//! deque are ordered upstream lists; BreadthFirst is not used here +//! (docs/ordering-inventory.md unchanged). + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_gradient_direction, parse_gradient_steps, parse_non_negative_ratio}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallbackValue, CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::particles::{ParticlePool, ParticleReset}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::spanning_tree::PrimsSimple; + +/// Callback id: EventHandler.Callback(lambda c: self._emit_smoke(c.input_coord, ...)). +const CB_EMIT_SMOKE: u32 = 0; +/// Callback id: ParticlePool.reclaim_on_event's reclaim closure. +const CB_RECLAIM_SMOKE: u32 = 1; + +#[derive(Args, Debug, Clone)] +pub struct BurnConfig { + /// Color of the characters before they start to burn. + #[arg(long = "starting-color", default_value = "837373", value_parser = parse_color)] + pub starting_color: Color, + + /// Colors transitioned through as the characters burn. + #[arg(long = "burn-colors", num_args = 1.., value_parser = parse_color, + default_values = ["ffffff", "fff75d", "fe650d", "8A003C", "510100"])] + pub burn_colors: Vec, + + /// Chance a given character will produce smoke while burning. Use 0 for no smoke. + #[arg(long = "smoke-chance", default_value_t = 0.5, value_parser = parse_non_negative_ratio)] + pub smoke_chance: f64, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["00c3ff", "ffff1c"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +const BURN_CHAR_ORDER: [&str; 9] = ["'", ".", "▖", "▙", "█", "▜", "▀", "▝", "."]; +const SMOKE_SYMBOLS: [&str; 6] = [".", ",", "'", "`", "#", "*"]; + +pub struct Burn { + config: BurnConfig, + character_final_color_map: HashMap, + /// PrimsSimple.char_link_order, consumed FIFO in next_frame. + char_link_order: Vec, + /// Option so _emit_smoke can move the pool out of self while the on_emit + /// closure needs &mut self for event dispatch (see emit_smoke). + smoke_particles: Option, + /// Makes each reclaim Callback registration unique, mirroring Python's + /// fresh closure object per reclaim_on_event call (identity inequality + /// lets registrations accumulate upstream instead of raising + /// DuplicateEventRegistrationError; firing reclaim repeatedly is + /// idempotent, so behavior is identical). + emission_counter: i64, +} + +impl Burn { + pub fn new(config: BurnConfig) -> Self { + Burn { + config, + character_final_color_map: HashMap::new(), + char_link_order: Vec::new(), + smoke_particles: None, + emission_counter: 0, + } + } + + /// BurnIterator._has_input_colors. + fn has_input_colors(ctx: &EngineCtx, id: CharId) -> bool { + let anim = &ctx.terminal.arena[id.0 as usize].animation; + anim.input_fg_color.is_some() || anim.input_bg_color.is_some() + } + + /// BurnIterator._is_burnable. + fn is_burnable(&self, ctx: &EngineCtx, id: CharId) -> bool { + ctx.terminal.arena[id.0 as usize].input_symbol != " " + || (ctx.terminal.config.existing_color_handling != ExistingColorHandling::Ignore + && Self::has_input_colors(ctx, id)) + } + + /// BurnIterator._make_smoke_pool's initialize_smoke: one reusable "smoke" + /// scene (10-frame 504F4F->C7C7C7 fade) and layer 2. Passed to every pool + /// call; runs only for newly created particles. + fn initialize_smoke(ctx: &mut EngineCtx, id: CharId) { + let (input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + let gradient = Gradient::with_steps( + &[Color::from_hex("504F4F").unwrap(), Color::from_hex("C7C7C7").unwrap()], + 9, + false, + ) + .expect("smoke gradient"); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let smoke_scn = ch.animation.new_scene(false, None, None, "smoke", uses_pre); + let scene = ch.animation.scenes.get_mut(&smoke_scn).unwrap(); + for color in &gradient.spectrum { + scene + .add_frame( + &input_symbol, + 10, + VisualParams { colors: Some(ColorPair::new(Some(color.clone()), None)), ..Default::default() }, + ) + .expect("smoke frame"); + } + ch.layer = 2; + } + + /// BurnIterator._emit_smoke. + fn emit_smoke(&mut self, ctx: &mut EngineCtx, origin: Coord) { + if ctx.rng.random() > self.config.smoke_chance { + return; + } + // ParticleReset(clear_paths=True, deactivate_path=True, + // deactivate_scene=True) == the upstream defaults. + let reset = ParticleReset::default(); + self.emission_counter += 1; + let emission_id = self.emission_counter; + // Move the pool out of self so the on_emit closure can borrow self as + // EffectHooks. Safe: on_emit only fires PATH_ACTIVATED/SCENE_ACTIVATED, + // for which smoke particles never register actions, so CB_RECLAIM_SMOKE + // cannot dispatch while the pool is out. + let mut pool = self.smoke_particles.take().expect("smoke pool"); + pool.emit(ctx, origin, None, true, reset, Self::initialize_smoke, |ctx, next_particle| { + // on_emit_smoke + ctx.terminal.arena[next_particle.0 as usize] + .animation + .scenes + .get_mut("smoke") + .expect("smoke scene") + .reset_scene(); + let smoke_path = ctx.terminal.arena[next_particle.0 as usize] + .motion + .new_path(0.5, None, None, 0, false, "") + .expect("smoke path"); + let rise_target_coord = Coord::new( + ctx.rng.randint(origin.column - 4, origin.column + 4), + ctx.terminal.canvas.top + 1, + ); + ctx.terminal.arena[next_particle.0 as usize] + .motion + .paths + .get_mut(&smoke_path) + .unwrap() + .new_waypoint(rise_target_coord, None, "") + .expect("smoke waypoint"); + ctx.activate_path(self, next_particle, &smoke_path); + ctx.activate_scene(self, next_particle, "smoke"); + // ParticlePool.reclaim_on_event(next_particle, caller="smoke") + ctx.register_event( + next_particle, + Event::SceneComplete, + CallerKey::Scene("smoke".to_string()), + EventAction::Callback(EffectCallback { + id: CB_RECLAIM_SMOKE, + args: vec![CallbackValue::Int(emission_id)], + }), + ) + .expect("register reclaim"); + }); + self.smoke_particles = Some(pool); + } +} + +impl EffectHooks for Burn { + fn dispatch_callback(&mut self, ctx: &mut EngineCtx, character: CharId, callback: &EffectCallback) { + match callback.id { + CB_EMIT_SMOKE => { + let origin = ctx.terminal.arena[character.0 as usize].input_coord; + self.emit_smoke(ctx, origin); + } + CB_RECLAIM_SMOKE => { + // reclaim(completed_character, hide=True, deactivate=True) + self.smoke_particles.as_mut().expect("smoke pool").reclaim(ctx, character, true, true); + } + _ => {} + } + } +} + +impl Effect for Burn { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // BurnIterator.__init__ order: PrimsSimple (random starting coord), + // then the pool preallocation (2000 choice() draws + initializer each). + let mut algo = PrimsSimple::new(ctx, None, true).map_err(EngineError::Other)?; + let mut pool = + ParticlePool::new(SMOKE_SYMBOLS.iter().map(|s| s.to_string()).collect(), Some(2000), None) + .map_err(EngineError::Other)?; + pool.preallocate(ctx, 2000, Self::initialize_smoke).map_err(EngineError::Other)?; + self.smoke_particles = Some(pool); + + // BurnIterator.build() + let burn_char_order: Vec = BURN_CHAR_ORDER.iter().map(|s| s.to_string()).collect(); + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + let coord = ctx.terminal.arena[id.0 as usize].input_coord; + self.character_final_color_map + .insert(id, final_gradient_mapping.get(&coord).expect("gradient mapping").clone()); + } + let fire_gradient = Gradient::with_steps(&self.config.burn_colors, 10, false).map_err(EngineError::Other)?; + + while !algo.complete { + algo.step(ctx); + } + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + ctx.terminal.set_character_visibility(id, true); + let (input_symbol, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_symbol.clone(), + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.set_appearance( + &input_symbol, + uses_pre, + Some(&input_symbol), + Some(ColorPair::new(Some(self.config.starting_color.clone()), None)), + ); + } + let burn_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, None, "burn", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .apply_gradient_to_symbols(&burn_char_order, 4, Some(&fire_gradient), None) + .map_err(EngineError::Other)?; + scene_id + }; + let final_color_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + let fire_last = fire_gradient.spectrum.last().expect("fire gradient spectrum").clone(); + if dynamic { + let fg_gradient = match &input_fg { + Some(fg) => Some( + Gradient::with_steps(&[fire_last.clone(), fg.clone()], 8, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &input_bg { + Some(bg) => Some( + Gradient::with_steps(&[fire_last.clone(), bg.clone()], 8, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&final_color_scn).unwrap(); + if fg_gradient.is_some() || bg_gradient.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 4, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + 4, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_color = self.character_final_color_map.get(&id).unwrap().clone(); + let char_gradient = + Gradient::with_steps(&[fire_last, final_color], 8, false).map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&final_color_scn).unwrap(); + for color in &char_gradient.spectrum { + scene + .add_frame( + &input_symbol, + 4, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene(burn_scn.clone()), + EventAction::ActivateScene(final_color_scn), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene(burn_scn), + EventAction::Callback(EffectCallback { id: CB_EMIT_SMOKE, args: Vec::new() }), + ) + .map_err(EngineError::Other)?; + } + self.char_link_order = algo.char_link_order; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.char_link_order.is_empty() || !ctx.active_characters.is_empty() { + for _ in 0..ctx.rng.randint(2, 4) { + if !self.char_link_order.is_empty() { + let next_char = self.char_link_order.remove(0); + if !self.is_burnable(ctx, next_char) { + continue; + } + ctx.activate_scene(self, next_char, "burn"); + ctx.active_characters.insert(next_char); + } + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/colorshift.rs b/vendor/ttfx/src/effects/colorshift.rs new file mode 100644 index 0000000..d53659d --- /dev/null +++ b/vendor/ttfx/src/effects/colorshift.rs @@ -0,0 +1,275 @@ +//! colorshift, ported from effects/effect_colorshift.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_gradient_direction, parse_gradient_steps, parse_positive_int}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::geometry; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct ColorShiftConfig { + /// Space separated, unquoted, list of colors for the gradient. + #[arg(long = "gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["e81416", "ffa500", "faeb36", "79c314", "487de7", "4b369d", "70369d"])] + pub gradient_stops: Vec, + + /// Number of gradient steps to use. More steps will create a smoother gradient animation. + #[arg(long = "gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub gradient_steps: Vec, + + /// Number of frames to display each gradient step. Increase to slow down the gradient animation. + #[arg(long = "gradient-frames", default_value_t = 2, value_parser = parse_positive_int)] + pub gradient_frames: i64, + + /// Do not display the gradient as a wave. + #[arg(long = "no-travel")] + pub no_travel: bool, + + /// Direction the gradient travels across the canvas. + #[arg(long = "travel-direction", default_value = "radial", value_parser = parse_gradient_direction)] + pub travel_direction: GradientDirection, + + /// Reverse the gradient travel direction. + #[arg(long = "reverse-travel-direction")] + pub reverse_travel_direction: bool, + + /// Do not loop the gradient. + #[arg(long = "no-loop")] + pub no_loop: bool, + + /// Number of times to cycle the gradient. + #[arg(long = "cycles", default_value_t = 3, value_parser = parse_positive_int)] + pub cycles: i64, + + /// Skip the final gradient. + #[arg(long = "skip-final-gradient")] + pub skip_final_gradient: bool, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["e81416", "ffa500", "faeb36", "79c314", "487de7", "4b369d", "70369d"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use for the final gradient. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct ColorShift { + config: ColorShiftConfig, + character_final_color_map: HashMap, + loop_tracker_map: HashMap, +} + +impl ColorShift { + pub fn new(config: ColorShiftConfig) -> Self { + ColorShift { config, character_final_color_map: HashMap::new(), loop_tracker_map: HashMap::new() } + } +} + +impl EffectHooks for ColorShift { + /// ColorShiftIterator.loop_tracker. + fn dispatch_callback(&mut self, ctx: &mut EngineCtx, character: CharId, _callback: &EffectCallback) { + let count = { + let entry = self.loop_tracker_map.entry(character).or_insert(0); + *entry += 1; + *entry + }; + if self.config.cycles == 0 || count < self.config.cycles { + ctx.activate_scene(self, character, "gradient"); + } else if !self.config.skip_final_gradient { + ctx.activate_scene(self, character, "final_gradient"); + } + } +} + +impl Effect for ColorShift { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + let input_coord = ctx.terminal.arena[id.0 as usize].input_coord; + self.character_final_color_map + .insert(id, final_gradient_mapping.get(&input_coord).unwrap().clone()); + } + let gradient = + Gradient::new(&self.config.gradient_stops, &self.config.gradient_steps, false, !self.config.no_loop) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + ctx.terminal.set_character_visibility(id, true); + let (input_fg, input_bg, input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.input_coord, + ch.input_symbol.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let colors: Vec = if self.config.no_travel { + gradient.spectrum.clone() + } else { + let direction_index = match self.config.travel_direction { + GradientDirection::Horizontal => { + input_coord.column as f64 / ctx.terminal.canvas.right as f64 + } + GradientDirection::Vertical => input_coord.row as f64 / ctx.terminal.canvas.top as f64, + GradientDirection::Diagonal => { + (input_coord.row + input_coord.column) as f64 + / (ctx.terminal.canvas.right + ctx.terminal.canvas.top) as f64 + } + GradientDirection::Radial => geometry::find_normalized_distance_from_center( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + input_coord, + ) + .map_err(EngineError::Other)?, + }; + // int() truncation + let mut shift_distance = (gradient.spectrum.len() as f64 * direction_index) as i64; + if self.config.reverse_travel_direction { + shift_distance *= -1; + } + // Python slicing: spectrum[shift:] + spectrum[:shift], negative shifts wrap + let len = gradient.spectrum.len() as i64; + let k = if shift_distance < 0 { + (len + shift_distance).max(0) as usize + } else { + shift_distance.min(len) as usize + }; + let mut rotated = gradient.spectrum[k..].to_vec(); + rotated.extend_from_slice(&gradient.spectrum[..k]); + rotated + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "gradient", uses_pre); + let scene = ch.animation.scenes.get_mut("gradient").unwrap(); + for color in &colors { + scene + .add_frame( + &input_symbol, + self.config.gradient_frames, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + ch.animation.new_scene(false, None, None, "final_gradient", uses_pre); + } + let last_color = colors.last().unwrap().clone(); + if dynamic { + let fg_gradient = match &input_fg { + Some(c) => Some( + Gradient::with_steps(&[last_color.clone(), c.clone()], 8, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &input_bg { + Some(c) => Some( + Gradient::with_steps(&[last_color.clone(), c.clone()], 8, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut("final_gradient").unwrap(); + if fg_gradient.is_some() || bg_gradient.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + self.config.gradient_frames, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + self.config.gradient_frames, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_color = self.character_final_color_map.get(&id).unwrap().clone(); + let final_scene_gradient = + Gradient::with_steps(&[last_color, final_color], 8, false).map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut("final_gradient").unwrap(); + for color in &final_scene_gradient.spectrum { + scene + .add_frame( + &input_symbol, + self.config.gradient_frames, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + ctx.activate_scene(self, id, "gradient"); + ctx.active_characters.insert(id); + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene("gradient".to_string()), + EventAction::Callback(EffectCallback { id: 0, args: Vec::new() }), + ) + .map_err(EngineError::Other)?; + } + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !ctx.active_characters.is_empty() { + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/common.rs b/vendor/ttfx/src/effects/common.rs new file mode 100644 index 0000000..f50efff --- /dev/null +++ b/vendor/ttfx/src/effects/common.rs @@ -0,0 +1,145 @@ +//! Shared clap value parsers for effect configs (argutils validators). + +use crate::utils::easing::Easing; +use crate::utils::graphics::GradientDirection; + +pub fn parse_positive_float(s: &str) -> Result { + let v: f64 = s.parse().map_err(|_| format!("invalid float value: '{s}'"))?; + if v > 0.0 { + Ok(v) + } else { + Err(format!("{v} is not a valid value. Argument must be a float > 0.")) + } +} + +pub fn parse_non_negative_float(s: &str) -> Result { + let v: f64 = s.parse().map_err(|_| format!("invalid float value: '{s}'"))?; + if v >= 0.0 { + Ok(v) + } else { + Err(format!("{v} is not a valid value. Argument must be a float >= 0.")) + } +} + +/// argutils.PositiveInt for gradient steps (nargs +). +pub fn parse_gradient_steps(s: &str) -> Result { + let v: i64 = s.parse().map_err(|_| format!("invalid int value: '{s}'"))?; + if v > 0 { + Ok(v) + } else { + Err(format!("{v} is not a valid value. Argument must be an int > 0.")) + } +} + +pub fn parse_positive_int(s: &str) -> Result { + parse_gradient_steps(s) +} + +pub fn parse_non_negative_int(s: &str) -> Result { + let v: i64 = s.parse().map_err(|_| format!("invalid int value: '{s}'"))?; + if v >= 0 { + Ok(v) + } else { + Err(format!("{v} is not a valid value. Argument must be an int >= 0.")) + } +} + +/// argutils.NonNegativeRatio: 0 <= n <= 1. +pub fn parse_non_negative_ratio(s: &str) -> Result { + let v: f64 = s.parse().map_err(|_| format!("invalid float value: '{s}'"))?; + if (0.0..=1.0).contains(&v) { + Ok(v) + } else { + Err(format!("{v} is not a valid value. Argument must be a float 0 <= n <= 1.")) + } +} + +/// argutils.PositiveRatio: 0 < n <= 1. +pub fn parse_positive_ratio(s: &str) -> Result { + let v: f64 = s.parse().map_err(|_| format!("invalid float value: '{s}'"))?; + if v > 0.0 && v <= 1.0 { + Ok(v) + } else { + Err(format!("{v} is not a valid value. Argument must be a float 0 < n <= 1.")) + } +} + +pub fn parse_gradient_direction(s: &str) -> Result { + match s { + "horizontal" => Ok(GradientDirection::Horizontal), + "vertical" => Ok(GradientDirection::Vertical), + "diagonal" => Ok(GradientDirection::Diagonal), + "radial" => Ok(GradientDirection::Radial), + _ => Err(format!("invalid gradient direction: '{s}'")), + } +} + +pub fn parse_easing(s: &str) -> Result { + Easing::parse(s).ok_or_else(|| format!("invalid easing function: '{s}'")) +} + +/// argutils.CharacterGroupArg: lowercased enum member names. +pub fn parse_character_group(s: &str) -> Result { + use crate::engine::terminal::CharacterGroup::*; + Ok(match s { + "column_left_to_right" => ColumnLeftToRight, + "column_right_to_left" => ColumnRightToLeft, + "row_top_to_bottom" => RowTopToBottom, + "row_bottom_to_top" => RowBottomToTop, + "diagonal_top_left_to_bottom_right" => DiagonalTopLeftToBottomRight, + "diagonal_bottom_left_to_top_right" => DiagonalBottomLeftToTopRight, + "diagonal_top_right_to_bottom_left" => DiagonalTopRightToBottomLeft, + "diagonal_bottom_right_to_top_left" => DiagonalBottomRightToTopLeft, + "center_to_outside" => CenterToOutside, + "outside_to_center" => OutsideToCenter, + _ => return Err(format!("invalid character group: '{s}'")), + }) +} + +/// argutils.CharacterSortArg: lowercased enum member names. +pub fn parse_character_sort(s: &str) -> Result { + use crate::engine::terminal::CharacterSort::*; + Ok(match s { + "random" => Random, + "top_to_bottom_left_to_right" => TopToBottomLeftToRight, + "top_to_bottom_right_to_left" => TopToBottomRightToLeft, + "bottom_to_top_left_to_right" => BottomToTopLeftToRight, + "bottom_to_top_right_to_left" => BottomToTopRightToLeft, + "outside_row_to_middle" => OutsideRowToMiddle, + "middle_row_to_outside" => MiddleRowToOutside, + _ => return Err(format!("invalid character sort: '{s}'")), + }) +} + +/// argutils.Symbol: single printable character. +pub fn parse_symbol(s: &str) -> Result { + if s.chars().count() == 1 { + Ok(s.to_string()) + } else { + Err(format!("invalid symbol: '{s}' argument must be a single character")) + } +} + +/// argutils.PositiveIntRange: "1-10". +pub fn parse_positive_int_range(s: &str) -> Result<(i64, i64), String> { + let (a, b) = s.split_once('-').ok_or_else(|| format!("invalid range: '{s}'"))?; + let a: i64 = a.parse().map_err(|_| format!("invalid range: '{s}'"))?; + let b: i64 = b.parse().map_err(|_| format!("invalid range: '{s}'"))?; + if a > 0 && a <= b { + Ok((a, b)) + } else { + Err(format!("invalid range: '{s}'")) + } +} + +/// argutils.PositiveFloatRange: "0.25-0.5". +pub fn parse_positive_float_range(s: &str) -> Result<(f64, f64), String> { + let (a, b) = s.split_once('-').ok_or_else(|| format!("invalid range: '{s}'"))?; + let a: f64 = a.parse().map_err(|_| format!("invalid range: '{s}'"))?; + let b: f64 = b.parse().map_err(|_| format!("invalid range: '{s}'"))?; + if a > 0.0 && a <= b { + Ok((a, b)) + } else { + Err(format!("invalid range: '{s}'")) + } +} diff --git a/vendor/ttfx/src/effects/crumble.rs b/vendor/ttfx/src/effects/crumble.rs new file mode 100644 index 0000000..13bd5b5 --- /dev/null +++ b/vendor/ttfx/src/effects/crumble.rs @@ -0,0 +1,476 @@ +//! crumble, ported from effects/effect_crumble.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_gradient_direction, parse_gradient_steps}; +use crate::engine::animation::{Animation, ExistingColorHandling, SyncMetric, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct CrumbleConfig { + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["5CE1FF", "FF8C00"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Stage { + Falling, + Vacuuming, + Resetting, + Complete, +} + +pub struct Crumble { + config: CrumbleConfig, + pending_chars: Vec, + character_final_color_map: HashMap, + fall_delay: i64, + max_fall_delay: i64, + min_fall_delay: i64, + reset: bool, + fall_group_maxsize: i64, + stage: Stage, + unvacuumed_chars: Vec, +} + +impl Crumble { + pub fn new(config: CrumbleConfig) -> Self { + Crumble { + config, + pending_chars: Vec::new(), + character_final_color_map: HashMap::new(), + fall_delay: 0, + max_fall_delay: 0, + min_fall_delay: 0, + reset: false, + fall_group_maxsize: 1, + stage: Stage::Falling, + unvacuumed_chars: Vec::new(), + } + } +} + +impl EffectHooks for Crumble { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Crumble { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // CrumbleIterator.DYNAMIC_NEUTRAL_GRAY + let dynamic_neutral_gray = Color::from_hex("#808080").unwrap(); + let white = Color::from_hex("#ffffff").unwrap(); + + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + let canvas_bottom = ctx.terminal.canvas.bottom; + let canvas_top = ctx.terminal.canvas.top; + let canvas_center_column = ctx.terminal.canvas.center_column; + let canvas_center_row = ctx.terminal.canvas.center_row; + for &id in &characters { + let (input_coord, input_symbol, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_coord, + ch.input_symbol.clone(), + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + self.character_final_color_map + .insert(id, final_gradient_mapping.get(&input_coord).unwrap().clone()); + let ( + weak_fg_color, + weak_bg_color, + dust_fg_color, + dust_bg_color, + strengthen_flash_fg_gradient, + strengthen_flash_bg_gradient, + strengthen_fg_gradient, + strengthen_bg_gradient, + ); + if dynamic { + let has_existing_colors = input_fg.is_some() || input_bg.is_some(); + weak_fg_color = match &input_fg { + Some(fg) => Some(Animation::adjust_color_brightness(fg, 0.65)), + None => { + if input_bg.is_none() { + Some(Animation::adjust_color_brightness(&dynamic_neutral_gray, 0.65)) + } else { + None + } + } + }; + weak_bg_color = input_bg.as_ref().map(|bg| Animation::adjust_color_brightness(bg, 0.65)); + dust_fg_color = match &input_fg { + Some(fg) => Some(Animation::adjust_color_brightness(fg, 0.55)), + None => { + if input_bg.is_none() { + Some(Animation::adjust_color_brightness(&dynamic_neutral_gray, 0.55)) + } else { + None + } + } + }; + dust_bg_color = input_bg.as_ref().map(|bg| Animation::adjust_color_brightness(bg, 0.55)); + strengthen_flash_fg_gradient = match &input_fg { + Some(fg) => Some( + Gradient::with_steps(&[fg.clone(), white.clone()], 6, false).map_err(EngineError::Other)?, + ), + None => { + if !has_existing_colors { + Some( + Gradient::with_steps(&[dynamic_neutral_gray.clone(), white.clone()], 6, false) + .map_err(EngineError::Other)?, + ) + } else { + None + } + } + }; + strengthen_flash_bg_gradient = match &input_bg { + Some(bg) => Some( + Gradient::with_steps(&[bg.clone(), white.clone()], 6, false).map_err(EngineError::Other)?, + ), + None => None, + }; + strengthen_fg_gradient = match &input_fg { + Some(fg) => Some( + Gradient::with_steps(&[white.clone(), fg.clone()], 9, false).map_err(EngineError::Other)?, + ), + None => None, + }; + strengthen_bg_gradient = match &input_bg { + Some(bg) => Some( + Gradient::with_steps(&[white.clone(), bg.clone()], 9, false).map_err(EngineError::Other)?, + ), + None => None, + }; + } else { + let final_color = self.character_final_color_map.get(&id).unwrap().clone(); + weak_fg_color = Some(Animation::adjust_color_brightness(&final_color, 0.65)); + weak_bg_color = None; + dust_fg_color = Some(Animation::adjust_color_brightness(&final_color, 0.55)); + dust_bg_color = None; + strengthen_flash_fg_gradient = Some( + Gradient::with_steps(&[final_color.clone(), white.clone()], 6, false) + .map_err(EngineError::Other)?, + ); + strengthen_flash_bg_gradient = None; + strengthen_fg_gradient = Some( + Gradient::with_steps(&[white.clone(), final_color.clone()], 9, false) + .map_err(EngineError::Other)?, + ); + strengthen_bg_gradient = None; + } + let weaken_fg_gradient = match (&weak_fg_color, &dust_fg_color) { + (Some(weak), Some(dust)) => Some( + Gradient::with_steps(&[weak.clone(), dust.clone()], 9, false).map_err(EngineError::Other)?, + ), + _ => None, + }; + let weaken_bg_gradient = match (&weak_bg_color, &dust_bg_color) { + (Some(weak), Some(dust)) => Some( + Gradient::with_steps(&[weak.clone(), dust.clone()], 9, false).map_err(EngineError::Other)?, + ), + _ => None, + }; + ctx.terminal.set_character_visibility(id, true); + // set up initial and falling stage + let initial_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .add_frame( + &input_symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(weak_fg_color.clone(), weak_bg_color.clone())), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + scene_id + }; + ctx.activate_scene(self, id, &initial_scn); + let fall_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let path_id = ch + .motion + .new_path(0.65, Some(Easing::OutBounce), None, 0, false, "") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(Coord::new(input_coord.column, canvas_bottom), None, "") + .map_err(EngineError::Other)?; + path_id + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let weaken_scn = ch.animation.new_scene(false, None, None, "weaken", uses_pre); + ch.animation + .scenes + .get_mut(&weaken_scn) + .unwrap() + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 4, + weaken_fg_gradient.as_ref(), + weaken_bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let top_path = ch + .motion + .new_path(1.0, Some(Easing::OutQuint), None, 0, false, "top") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&top_path) + .unwrap() + .new_waypoint( + Coord::new(input_coord.column, canvas_top), + Some(vec![Coord::new(canvas_center_column, canvas_center_row)]), + "", + ) + .map_err(EngineError::Other)?; + } + // set up reset stage + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let input_path = ch.motion.new_path(1.0, None, None, 0, false, "input").map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&input_path) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + } + let strengthen_flash_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 4, + strengthen_flash_fg_gradient.as_ref(), + strengthen_flash_bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + scene_id + }; + let strengthen_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + let scene = ch.animation.scenes.get_mut(&scene_id).unwrap(); + if dynamic && input_fg.is_none() && input_bg.is_none() { + scene + .add_frame( + &input_symbol, + 4, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } else { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 4, + strengthen_fg_gradient.as_ref(), + strengthen_bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } + scene_id + }; + let dust_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, Some(SyncMetric::Distance), None, "", uses_pre) + }; + let dust_symbols = ["*", ".", ","]; + for _ in 0..5 { + let symbol = *ctx.rng.choice(&dust_symbols); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&dust_scn) + .unwrap() + .add_frame( + symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(dust_fg_color.clone(), dust_bg_color.clone())), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene("weaken".to_string()), + EventAction::ActivatePath(fall_path), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene("weaken".to_string()), + EventAction::SetLayer(1), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene("weaken".to_string()), + EventAction::ActivateScene(dust_scn), + ) + .map_err(EngineError::Other)?; + + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path("input".to_string()), + EventAction::ActivateScene(strengthen_flash_scn.clone()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene(strengthen_flash_scn), + EventAction::ActivateScene(strengthen_scn), + ) + .map_err(EngineError::Other)?; + self.pending_chars.push(id); + } + ctx.rng.shuffle(&mut self.pending_chars); + self.fall_delay = 12; + self.max_fall_delay = 12; + self.min_fall_delay = 9; + self.reset = false; + self.fall_group_maxsize = 1; + self.stage = Stage::Falling; + self.unvacuumed_chars = ctx.terminal.input_characters.clone(); + ctx.rng.shuffle(&mut self.unvacuumed_chars); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.stage != Stage::Complete { + match self.stage { + Stage::Falling => { + if !self.pending_chars.is_empty() { + if self.fall_delay == 0 { + // Determine the size of the next group of falling characters + let fall_group_size = ctx.rng.randint(1, self.fall_group_maxsize); + // Add the next group of falling characters to the animating characters list + for _ in 0..fall_group_size { + if !self.pending_chars.is_empty() { + let next_char = self.pending_chars.remove(0); + ctx.activate_scene(self, next_char, "weaken"); + ctx.active_characters.insert(next_char); + } + } + // Reset the fall delay and adjust the fall group size and delay range + self.fall_delay = ctx.rng.randint(self.min_fall_delay, self.max_fall_delay); + if ctx.rng.randint(1, 10) > 4 { + // 60% chance to modify the fall delay and group size + self.fall_group_maxsize += 1; + self.min_fall_delay = std::cmp::max(0, self.min_fall_delay - 1); + self.max_fall_delay = std::cmp::max(0, self.max_fall_delay - 1); + } + } else { + self.fall_delay -= 1; + } + } + if self.pending_chars.is_empty() && ctx.active_characters.is_empty() { + self.stage = Stage::Vacuuming; + } + } + Stage::Vacuuming => { + if !self.unvacuumed_chars.is_empty() { + for _ in 0..ctx.rng.randint(3, 10) { + if !self.unvacuumed_chars.is_empty() { + let next_char = self.unvacuumed_chars.remove(0); + ctx.activate_path(self, next_char, "top"); + ctx.active_characters.insert(next_char); + } + } + } + if ctx.active_characters.is_empty() { + self.stage = Stage::Resetting; + } + } + Stage::Resetting => { + if !self.reset { + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + ctx.activate_path(self, id, "input"); + ctx.active_characters.insert(id); + } + self.reset = true; + } + if ctx.active_characters.is_empty() { + self.stage = Stage::Complete; + } + } + Stage::Complete => {} + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/decrypt.rs b/vendor/ttfx/src/effects/decrypt.rs new file mode 100644 index 0000000..2eb1374 --- /dev/null +++ b/vendor/ttfx/src/effects/decrypt.rs @@ -0,0 +1,367 @@ +//! decrypt, ported from effects/effect_decrypt.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_gradient_direction, parse_gradient_steps, parse_positive_int}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct DecryptConfig { + /// Number of characters typed per keystroke. + #[arg(long = "typing-speed", default_value_t = 2, value_parser = parse_positive_int)] + pub typing_speed: i64, + + /// Space separated, unquoted, list of colors for the ciphertext. Color will be randomly selected for each character. + #[arg(long = "ciphertext-colors", num_args = 1.., value_parser = parse_color, + default_values = ["008000", "00cb00", "00ff00"])] + pub ciphertext_colors: Vec, + + /// Space separated, unquoted, list of colors for the character gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["eda000"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + Typing, + Decrypting, +} + +pub struct Decrypt { + config: DecryptConfig, + typing_pending_chars: Vec, + /// Upstream is a set; membership only feeds `active_characters`, which is + /// canonically ordered, so insertion-ordered Vec is equivalent. + decrypting_pending_chars: Vec, + phase: Phase, + encrypted_symbols: Vec, + character_final_color_map: HashMap, +} + +impl Decrypt { + pub fn new(config: DecryptConfig) -> Self { + let mut effect = Decrypt { + config, + typing_pending_chars: Vec::new(), + decrypting_pending_chars: Vec::new(), + phase: Phase::Typing, + encrypted_symbols: Vec::new(), + character_final_color_map: HashMap::new(), + }; + effect.make_encrypted_symbols(); + effect + } + + /// DecryptIterator.make_encrypted_symbols (_DecryptChars ranges). + fn make_encrypted_symbols(&mut self) { + for n in 33u32..127 { + self.encrypted_symbols.push(char::from_u32(n).unwrap().to_string()); + } + for n in 9608u32..9632 { + self.encrypted_symbols.push(char::from_u32(n).unwrap().to_string()); + } + for n in 9472u32..9599 { + self.encrypted_symbols.push(char::from_u32(n).unwrap().to_string()); + } + for n in 174u32..452 { + self.encrypted_symbols.push(char::from_u32(n).unwrap().to_string()); + } + } + + /// DecryptIterator.make_decrypting_animation_scenes. + fn make_decrypting_animation_scenes(&mut self, ctx: &mut EngineCtx, id: CharId) -> Result<(), EngineError> { + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let (input_symbol, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_symbol.clone(), + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "fast_decrypt", uses_pre); + } + let color = ctx.rng.choice(&self.config.ciphertext_colors).clone(); + for _ in 0..80 { + let symbol = ctx.rng.choice(&self.encrypted_symbols).clone(); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("fast_decrypt") + .unwrap() + .add_frame( + &symbol, + 2, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "slow_decrypt", uses_pre); + } + for _ in 0..ctx.rng.randint(1, 15) { + // 1-15 longer duration units + let symbol = ctx.rng.choice(&self.encrypted_symbols).clone(); + // 30% chance of extra long duration + // wide duration range reduces 'waves' in the animation + // shorter duration creates flipping effect + let duration = if ctx.rng.randint(0, 100) <= 30 { + ctx.rng.randrange(35, 60) + } else { + ctx.rng.randrange(3, 6) + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("slow_decrypt") + .unwrap() + .add_frame( + &symbol, + duration, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "discovered", uses_pre); + } + let white = Color::from_hex("ffffff").unwrap(); + if dynamic { + let fg_gradient = match &input_fg { + Some(fg) => Some( + Gradient::with_steps(&[white.clone(), fg.clone()], 10, false).map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &input_bg { + Some(bg) => Some( + Gradient::with_steps(&[white.clone(), bg.clone()], 10, false).map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut("discovered").unwrap(); + if fg_gradient.is_some() || bg_gradient.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 5, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + 5, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_fg = self + .character_final_color_map + .get(&id) + .unwrap() + .fg_color + .clone() + .expect("gradient mapping fg"); + let discovered_gradient = + Gradient::with_steps(&[white, final_fg], 10, false).map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("discovered") + .unwrap() + .apply_gradient_to_symbols(&[input_symbol.clone()], 5, Some(&discovered_gradient), None) + .map_err(EngineError::Other)?; + } + Ok(()) + } + + /// DecryptIterator.prepare_data_for_type_effect. + fn prepare_data_for_type_effect(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let uses_pre = ctx.terminal.arena[id.0 as usize].uses_input_preexisting_colors; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "typing", uses_pre); + } + for block_char in ["▉", "▓", "▒", "░"] { + let color = ctx.rng.choice(&self.config.ciphertext_colors).clone(); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("typing") + .unwrap() + .add_frame( + block_char, + 2, + VisualParams { colors: Some(ColorPair::new(Some(color), None)), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + let symbol = ctx.rng.choice(&self.encrypted_symbols).clone(); + let color = ctx.rng.choice(&self.config.ciphertext_colors).clone(); + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("typing") + .unwrap() + .add_frame( + &symbol, + 1, + VisualParams { colors: Some(ColorPair::new(Some(color), None)), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + self.typing_pending_chars.push(id); + } + Ok(()) + } + + /// DecryptIterator.prepare_data_for_decrypt_effect. + fn prepare_data_for_decrypt_effect(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + self.make_decrypting_animation_scenes(ctx, id)?; + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene("fast_decrypt".to_string()), + EventAction::ActivateScene("slow_decrypt".to_string()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene("slow_decrypt".to_string()), + EventAction::ActivateScene("discovered".to_string()), + ) + .map_err(EngineError::Other)?; + ctx.activate_scene(self, id, "fast_decrypt"); + self.decrypting_pending_chars.push(id); + } + Ok(()) + } +} + +impl EffectHooks for Decrypt { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Decrypt { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + let final_colors = { + let ch = &ctx.terminal.arena[id.0 as usize]; + if dynamic { + ColorPair::new(ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone()) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&ch.input_coord).unwrap().clone()), None) + } + }; + self.character_final_color_map.insert(id, final_colors); + } + self.prepare_data_for_type_effect(ctx)?; + self.prepare_data_for_decrypt_effect(ctx)?; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.phase == Phase::Typing { + if !self.typing_pending_chars.is_empty() || !ctx.active_characters.is_empty() { + if !self.typing_pending_chars.is_empty() && ctx.rng.randint(0, 100) <= 75 { + for _ in 0..self.config.typing_speed { + if !self.typing_pending_chars.is_empty() { + let next_character = self.typing_pending_chars.remove(0); + ctx.terminal.set_character_visibility(next_character, true); + ctx.activate_scene(self, next_character, "typing"); + ctx.active_characters.insert(next_character); + } + } + } + ctx.update(self); + return Some(ctx.frame()); + } + // Upstream iterates the decrypting_pending_chars set here to + // activate "fast_decrypt" (effect_decrypt.py:263) — activation + // only mutates each character itself (no handlers registered for + // SCENE_ACTIVATED), so order is unobservable; ttfx iterates the + // canonical ascending-id order. + ctx.active_characters.clear(); + ctx.active_characters.extend(self.decrypting_pending_chars.iter().copied()); + let active: Vec = ctx.active_characters.iter().collect(); + for id in active { + ctx.activate_scene(self, id, "fast_decrypt"); + } + self.phase = Phase::Decrypting; + } + + if self.phase == Phase::Decrypting { + if !ctx.active_characters.is_empty() { + ctx.update(self); + return Some(ctx.frame()); + } + return None; + } + None + } +} diff --git a/vendor/ttfx/src/effects/errorcorrect.rs b/vendor/ttfx/src/effects/errorcorrect.rs new file mode 100644 index 0000000..cee14b7 --- /dev/null +++ b/vendor/ttfx/src/effects/errorcorrect.rs @@ -0,0 +1,445 @@ +//! errorcorrect, ported from effects/effect_errorcorrect.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_positive_float, parse_positive_int, +}; +use crate::engine::animation::{ExistingColorHandling, SyncMetric, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct ErrorCorrectConfig { + /// Percent of characters that are in the wrong position. This is a float between 0 and 1.0. + #[arg(long = "error-pairs", default_value_t = 0.1, value_parser = parse_positive_float)] + pub error_pairs: f64, + + /// Number of frames between swaps. + #[arg(long = "swap-delay", default_value_t = 6, value_parser = parse_positive_int)] + pub swap_delay: i64, + + /// Color for the characters that are in the wrong position. + #[arg(long = "error-color", default_value = "e74c3c", value_parser = parse_color)] + pub error_color: Color, + + /// Color for the characters once corrected, this is a gradient from error-color and fades to final-color. + #[arg(long = "correct-color", default_value = "45bf55", value_parser = parse_color)] + pub correct_color: Color, + + /// Speed of the characters while moving to the correct position. + #[arg(long = "movement-speed", default_value_t = 0.9, value_parser = parse_positive_float)] + pub movement_speed: f64, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +const BLOCK_WIPE_START: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; +const BLOCK_WIPE_END: [&str; 7] = ["▇", "▆", "▅", "▄", "▃", "▂", "▁"]; + +pub struct ErrorCorrect { + config: ErrorCorrectConfig, + swapped: Vec<(CharId, CharId)>, + swap_delay: i64, + character_final_color_map: HashMap, +} + +impl ErrorCorrect { + pub fn new(config: ErrorCorrectConfig) -> Self { + ErrorCorrect { config, swapped: Vec::new(), swap_delay: 0, character_final_color_map: HashMap::new() } + } + + /// ErrorCorrectIterator._get_dynamic_final_scene. + fn get_dynamic_final_scene(&self, ctx: &mut EngineCtx, id: CharId) -> Result { + let (input_symbol, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_symbol.clone(), + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let final_scene = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + let fg_gradient = match &input_fg { + Some(fg) => Some( + Gradient::with_steps(&[self.config.correct_color.clone(), fg.clone()], 10, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &input_bg { + Some(bg) => Some( + Gradient::with_steps(&[self.config.correct_color.clone(), bg.clone()], 10, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&final_scene).unwrap(); + if fg_gradient.is_some() || bg_gradient.is_some() { + scene + .apply_gradient_to_symbols(&[input_symbol.clone()], 3, fg_gradient.as_ref(), bg_gradient.as_ref()) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame(&input_symbol, 3, VisualParams { colors: Some(ColorPair::default()), ..Default::default() }) + .map_err(EngineError::Other)?; + } + Ok(final_scene) + } + + /// ErrorCorrectIterator._configure_swapped_character. + fn configure_swapped_character( + &mut self, + ctx: &mut EngineCtx, + id: CharId, + correcting_gradient: &Gradient, + ) -> Result<(), EngineError> { + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let (input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + let (first_block_wipe, last_block_wipe) = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let first = ch.animation.new_scene(false, None, None, "", uses_pre); + let last = ch.animation.new_scene(false, None, None, "", uses_pre); + (first, last) + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&first_block_wipe).unwrap(); + for block in BLOCK_WIPE_START { + scene + .add_frame( + block, + 3, + VisualParams { + colors: Some(ColorPair::new(Some(self.config.error_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + let final_colors = self.character_final_color_map.get(&id).unwrap().clone(); + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&last_block_wipe).unwrap(); + if dynamic { + for block in &BLOCK_WIPE_END[..BLOCK_WIPE_END.len() - 1] { + scene + .add_frame( + block, + 3, + VisualParams { + colors: Some(ColorPair::new(Some(self.config.correct_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + scene + .add_frame( + BLOCK_WIPE_END[BLOCK_WIPE_END.len() - 1], + 3, + VisualParams { colors: Some(final_colors), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } else { + for block in BLOCK_WIPE_END { + scene + .add_frame( + block, + 3, + VisualParams { + colors: Some(ColorPair::new(Some(self.config.correct_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + } + let initial_scene = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .add_frame( + &input_symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(Some(self.config.error_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + scene_id + }; + ctx.activate_scene(self, id, &initial_scene); + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let error_scene = ch.animation.new_scene(false, None, None, "error", uses_pre); + let scene = ch.animation.scenes.get_mut(&error_scene).unwrap(); + for _ in 0..10 { + scene + .add_frame( + "▓", + 3, + VisualParams { + colors: Some(ColorPair::new(Some(self.config.error_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + scene + .add_frame( + &input_symbol, + 3, + VisualParams { + colors: Some(ColorPair::new(Some(Color::from_hex("ffffff").unwrap()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + let correcting_scene = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, Some(SyncMetric::Distance), None, "", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .apply_gradient_to_symbols(&["█".to_string()], 3, Some(correcting_gradient), None) + .map_err(EngineError::Other)?; + scene_id + }; + let final_scene = if dynamic { + self.get_dynamic_final_scene(ctx, id)? + } else { + let final_fg = final_colors_fg(&self.character_final_color_map, id); + let char_final_gradient = + Gradient::with_steps(&[self.config.correct_color.clone(), final_fg], 10, false) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .apply_gradient_to_symbols(&[input_symbol.clone()], 3, Some(&char_final_gradient), None) + .map_err(EngineError::Other)?; + scene_id + }; + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene("error".to_string()), + EventAction::ActivateScene(first_block_wipe.clone()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene(first_block_wipe.clone()), + EventAction::ActivateScene(correcting_scene), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene(first_block_wipe), + EventAction::ActivatePath("input_coord".to_string()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path("input_coord".to_string()), + EventAction::SetLayer(1), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path("input_coord".to_string()), + EventAction::SetLayer(0), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path("input_coord".to_string()), + EventAction::ActivateScene(last_block_wipe.clone()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene(last_block_wipe), + EventAction::ActivateScene(final_scene), + ) + .map_err(EngineError::Other)?; + Ok(()) + } +} + +fn final_colors_fg(map: &HashMap, id: CharId) -> Color { + map.get(&id).unwrap().fg_color.clone().expect("gradient mapping fg") +} + +impl EffectHooks for ErrorCorrect { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for ErrorCorrect { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + let final_colors = { + let ch = &ctx.terminal.arena[id.0 as usize]; + if dynamic { + ColorPair::new(ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone()) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&ch.input_coord).unwrap().clone()), None) + } + }; + self.character_final_color_map.insert(id, final_colors); + } + for &id in &characters { + let spawn_colors = self.character_final_color_map.get(&id).unwrap().clone(); + let spawn_scene = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let uses_pre = ch.uses_input_preexisting_colors; + let input_symbol = ch.input_symbol.clone(); + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .add_frame(&input_symbol, 1, VisualParams { colors: Some(spawn_colors), ..Default::default() }) + .map_err(EngineError::Other)?; + scene_id + }; + ctx.activate_scene(self, id, &spawn_scene); + ctx.terminal.set_character_visibility(id, true); + } + + let mut all_characters: Vec = ctx.terminal.input_characters.clone(); + let correcting_gradient = Gradient::with_steps( + &[self.config.error_color.clone(), self.config.correct_color.clone()], + 10, + false, + ) + .map_err(EngineError::Other)?; + + let pair_count = (self.config.error_pairs * characters.len() as f64) as i64; + for _ in 0..pair_count { + if all_characters.len() < 2 { + break; + } + let index1 = ctx.rng.randrange(0, all_characters.len() as i64) as usize; + let char1 = all_characters.remove(index1); + let index2 = ctx.rng.randrange(0, all_characters.len() as i64) as usize; + let char2 = all_characters.remove(index2); + let char1_input_coord = ctx.terminal.arena[char1.0 as usize].input_coord; + let char2_input_coord = ctx.terminal.arena[char2.0 as usize].input_coord; + { + let ch = &mut ctx.terminal.arena[char1.0 as usize]; + ch.motion.set_coordinate(char2_input_coord); + let path_id = ch + .motion + .new_path(self.config.movement_speed, None, None, 0, false, "input_coord") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(char1_input_coord, None, "") + .map_err(EngineError::Other)?; + } + { + let ch = &mut ctx.terminal.arena[char2.0 as usize]; + ch.motion.set_coordinate(char1_input_coord); + let path_id = ch + .motion + .new_path(self.config.movement_speed, None, None, 0, false, "input_coord") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(char2_input_coord, None, "") + .map_err(EngineError::Other)?; + } + self.swapped.push((char1, char2)); + for id in [char1, char2] { + self.configure_swapped_character(ctx, id, &correcting_gradient)?; + } + } + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.swapped.is_empty() && self.swap_delay == 0 { + let (char1, char2) = self.swapped.remove(0); + for id in [char1, char2] { + ctx.activate_scene(self, id, "error"); + ctx.active_characters.insert(id); + } + self.swap_delay = self.config.swap_delay; + } else if self.swap_delay != 0 { + self.swap_delay -= 1; + } + if !ctx.active_characters.is_empty() { + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/expand.rs b/vendor/ttfx/src/effects/expand.rs new file mode 100644 index 0000000..a6d87c4 --- /dev/null +++ b/vendor/ttfx/src/effects/expand.rs @@ -0,0 +1,202 @@ +//! expand, ported from effects/effect_expand.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_easing, parse_gradient_direction, parse_gradient_steps, parse_positive_float}; +use crate::engine::animation::{ExistingColorHandling, SyncMetric, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct ExpandConfig { + /// Easing function to use for character movement. + #[arg(long = "expand-easing", default_value = "in_out_quart", value_parser = parse_easing)] + pub expand_easing: Easing, + + /// Movement speed of the characters. + #[arg(long = "movement-speed", default_value_t = 0.35, value_parser = parse_positive_float)] + pub movement_speed: f64, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Expand { + config: ExpandConfig, + character_final_color_map: HashMap, +} + +impl Expand { + pub fn new(config: ExpandConfig) -> Self { + Expand { config, character_final_color_map: HashMap::new() } + } +} + +impl EffectHooks for Expand { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Expand { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_fg, input_bg, input_coord) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone(), ch.input_coord) + }; + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors); + } + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_fg, input_bg, input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.input_coord, + ch.input_symbol.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let center = ctx.terminal.canvas.center; + let input_coord_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.set_coordinate(center); + let path_id = ch + .motion + .new_path(self.config.movement_speed, Some(self.config.expand_easing), None, 0, false, "") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + path_id + }; + ctx.terminal.set_character_visibility(id, true); + ctx.active_characters.insert(id); + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path(input_coord_path.clone()), + EventAction::SetLayer(1), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(input_coord_path.clone()), + EventAction::SetLayer(0), + ) + .map_err(EngineError::Other)?; + ctx.activate_path(self, id, &input_coord_path); + let gradient_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, Some(SyncMetric::Distance), None, "", uses_pre) + }; + let symbols = vec![input_symbol.clone()]; + { + let scene = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.scenes.get_mut(&gradient_scn).unwrap() + }; + if dynamic { + let fg_gradient = match &input_fg { + Some(c) => Some( + Gradient::with_steps(&[final_gradient.spectrum[0].clone(), c.clone()], 10, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &input_bg { + Some(c) => Some( + Gradient::with_steps(&[final_gradient.spectrum[0].clone(), c.clone()], 10, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + if fg_gradient.is_some() || bg_gradient.is_some() { + scene + .apply_gradient_to_symbols(&symbols, 1, fg_gradient.as_ref(), bg_gradient.as_ref()) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + 1, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_fg = self.character_final_color_map[&id] + .fg_color + .clone() + .expect("gradient mapping fg"); + let gradient = + Gradient::with_steps(&[final_gradient.spectrum[0].clone(), final_fg], 10, false) + .map_err(EngineError::Other)?; + scene + .apply_gradient_to_symbols(&symbols, 5, Some(&gradient), None) + .map_err(EngineError::Other)?; + } + } + ctx.activate_scene(self, id, &gradient_scn); + } + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !ctx.active_characters.is_empty() { + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/fireworks.rs b/vendor/ttfx/src/effects/fireworks.rs new file mode 100644 index 0000000..72cd4fa --- /dev/null +++ b/vendor/ttfx/src/effects/fireworks.rs @@ -0,0 +1,421 @@ +//! fireworks, ported from effects/effect_fireworks.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_non_negative_int, parse_non_negative_ratio, parse_symbol, +}; +use crate::engine::animation::{ExistingColorHandling, SyncMetric, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::{self, Coord}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::pycompat::{floor_div, round_half_even}; + +#[derive(Args, Debug, Clone)] +pub struct FireworksConfig { + /// If set, fireworks explode anywhere in the canvas. Otherwise, fireworks explode above highest settled row of text. + #[arg(long = "explode-anywhere", default_value_t = false)] + pub explode_anywhere: bool, + + /// Space separated list of colors from which firework colors will be randomly selected. + #[arg(long = "firework-colors", num_args = 1.., value_parser = parse_color, + default_values = ["88F7E2", "44D492", "F5EB67", "FFA15C", "FA233E"])] + pub firework_colors: Vec, + + /// Symbol to use for the firework shell. + #[arg(long = "firework-symbol", default_value = "o", value_parser = parse_symbol)] + pub firework_symbol: String, + + /// Percent of total characters in each firework shell. + #[arg(long = "firework-volume", default_value_t = 0.05, value_parser = parse_non_negative_ratio)] + pub firework_volume: f64, + + /// Number of frames to wait between launching each firework shell. +/- 0-50 percent randomness is applied to this value. + #[arg(long = "launch-delay", default_value_t = 45, value_parser = parse_non_negative_int)] + pub launch_delay: i64, + + /// Maximum distance from the firework shell origin to the explode waypoint as a percentage of the total canvas width. + #[arg(long = "explode-distance", default_value_t = 0.2, value_parser = parse_non_negative_ratio)] + pub explode_distance: f64, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "horizontal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Fireworks { + config: FireworksConfig, + shells: Vec>, + firework_volume: i64, + explode_distance: i64, + character_final_color_map: HashMap, + launch_delay: i64, +} + +impl Fireworks { + pub fn new(config: FireworksConfig) -> Self { + Fireworks { + config, + shells: Vec::new(), + firework_volume: 0, + explode_distance: 0, + character_final_color_map: HashMap::new(), + launch_delay: 0, + } + } + + /// FireworksIterator.prepare_waypoints. + fn prepare_waypoints(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let mut firework_shell: Vec = Vec::new(); + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + let canvas_bottom = ctx.terminal.canvas.bottom; + let canvas_top = ctx.terminal.canvas.top; + let canvas_right = ctx.terminal.canvas.right; + // Loop variables assigned at each shell boundary and reused across the + // following characters, like the Python loop-scoped names. + let mut origin_x: i64 = 0; + let mut origin_coord = Coord::new(0, 0); + let mut explode_waypoint_coords: Vec = Vec::new(); + for id in characters { + if firework_shell.len() as i64 == self.firework_volume || firework_shell.is_empty() { + origin_x = ctx.rng.randrange(0, canvas_right); + self.shells.push(std::mem::take(&mut firework_shell)); + let min_row = if !self.config.explode_anywhere { + ctx.terminal.arena[id.0 as usize].input_coord.row + } else { + canvas_bottom + }; + let origin_y = ctx.rng.randrange(min_row, canvas_top + 1); + origin_coord = Coord::new(origin_x, origin_y); + explode_waypoint_coords = geometry::find_coords_in_circle(origin_coord, self.explode_distance); + } + let input_coord = ctx.terminal.arena[id.0 as usize].input_coord; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.set_coordinate(Coord::new(origin_x, canvas_bottom)); + let apex_path = ch + .motion + .new_path(0.35, Some(Easing::OutExpo), Some(2), 0, false, "apex_pth") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&apex_path) + .unwrap() + .new_waypoint(origin_coord, None, "") + .map_err(EngineError::Other)?; + } + let apex_wpt_coord = origin_coord; + let explode_speed = ctx.rng.uniform(0.2, 0.4); + let explode_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion + .new_path(explode_speed, Some(Easing::OutCirc), Some(2), 0, false, "") + .map_err(EngineError::Other)? + }; + let explode_wpt_coord = *ctx.rng.choice(&explode_waypoint_coords); + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion + .paths + .get_mut(&explode_path) + .unwrap() + .new_waypoint(explode_wpt_coord, None, "") + .map_err(EngineError::Other)?; + } + + let bloom_control_point = geometry::extrapolate_along_ray( + apex_wpt_coord, + explode_wpt_coord, + floor_div(self.explode_distance, 2) as f64, + ); + let bloom_wpt_coord = + Coord::new(bloom_control_point.column, std::cmp::max(1, bloom_control_point.row - 7)); + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion + .paths + .get_mut(&explode_path) + .unwrap() + .new_waypoint(bloom_wpt_coord, Some(vec![bloom_control_point]), "") + .map_err(EngineError::Other)?; + } + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let input_path = ch + .motion + .new_path(0.6, Some(Easing::InOutQuart), Some(2), 0, false, "input_pth") + .map_err(EngineError::Other)?; + let input_control_point = Coord::new(bloom_wpt_coord.column, 1); + ch.motion + .paths + .get_mut(&input_path) + .unwrap() + .new_waypoint(input_coord, Some(vec![input_control_point]), "") + .map_err(EngineError::Other)?; + } + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path("apex_pth".to_string()), + EventAction::ActivatePath(explode_path.clone()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(explode_path), + EventAction::ActivatePath("input_pth".to_string()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path("input_pth".to_string()), + EventAction::SetLayer(0), + ) + .map_err(EngineError::Other)?; + + ctx.activate_path(self, id, "apex_pth"); + + firework_shell.push(id); + } + if !firework_shell.is_empty() { + self.shells.push(firework_shell); + } + Ok(()) + } + + /// FireworksIterator.prepare_scenes. + fn prepare_scenes(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + let final_colors = { + let ch = &ctx.terminal.arena[id.0 as usize]; + if dynamic { + ColorPair::new(ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone()) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&ch.input_coord).unwrap().clone()), None) + } + }; + self.character_final_color_map.insert(id, final_colors); + } + let white = Color::from_hex("FFFFFF").unwrap(); + let shells = self.shells.clone(); + for firework_shell in shells { + let shell_color = ctx.rng.choice(&self.config.firework_colors).clone(); + let shell_gradient = + Gradient::with_steps(&[shell_color.clone(), white.clone(), shell_color.clone()], 5, false) + .map_err(EngineError::Other)?; + for id in firework_shell { + let (input_symbol, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_symbol.clone(), + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + // launch scene + let launch_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + let scene = ch.animation.scenes.get_mut(&scene_id).unwrap(); + scene + .add_frame( + &self.config.firework_symbol, + 2, + VisualParams { + colors: Some(ColorPair::new(Some(shell_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + scene + .add_frame( + &self.config.firework_symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(Some(white.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + scene.is_looping = true; + scene_id + }; + // bloom scene + let bloom_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, Some(SyncMetric::Step), None, "", uses_pre); + let scene = ch.animation.scenes.get_mut(&scene_id).unwrap(); + for color in &shell_gradient.spectrum { + scene + .add_frame( + &input_symbol, + 2, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + scene_id + }; + // fall scene + let fall_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "fall_scn", uses_pre) + }; + if dynamic { + let fg_gradient = match &input_fg { + Some(fg) => Some( + Gradient::with_steps(&[shell_color.clone(), fg.clone()], 15, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &input_bg { + Some(bg) => Some( + Gradient::with_steps(&[shell_color.clone(), bg.clone()], 15, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&fall_scn).unwrap(); + if fg_gradient.is_some() || bg_gradient.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 10, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + 10, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_fg = self + .character_final_color_map + .get(&id) + .unwrap() + .fg_color + .clone() + .expect("gradient mapping fg"); + let fall_gradient = Gradient::with_steps(&[shell_color.clone(), final_fg], 15, false) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&fall_scn) + .unwrap() + .apply_gradient_to_symbols(&[input_symbol.clone()], 10, Some(&fall_gradient), None) + .map_err(EngineError::Other)?; + } + ctx.activate_scene(self, id, &launch_scn); + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path("apex_pth".to_string()), + EventAction::ActivateScene(bloom_scn), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path("input_pth".to_string()), + EventAction::ActivateScene(fall_scn), + ) + .map_err(EngineError::Other)?; + } + } + Ok(()) + } +} + +impl EffectHooks for Fireworks { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Fireworks { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // __init__ precomputations (no RNG) + self.firework_volume = std::cmp::max( + 1, + round_half_even(self.config.firework_volume * ctx.terminal.input_characters.len() as f64), + ); + self.explode_distance = std::cmp::min( + 15, + std::cmp::max(1, round_half_even(ctx.terminal.canvas.right as f64 * self.config.explode_distance)), + ); + self.launch_delay = 0; + self.prepare_waypoints(ctx)?; + self.prepare_scenes(ctx)?; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.shells.is_empty() || !ctx.active_characters.is_empty() { + if !self.shells.is_empty() && self.launch_delay <= 0 { + let next_group = self.shells.pop().unwrap(); + for id in next_group { + ctx.terminal.set_character_visibility(id, true); + ctx.active_characters.insert(id); + } + // int(launch_delay * uniform(0.5, 1.5)) — truncation + self.launch_delay = (self.config.launch_delay as f64 * ctx.rng.uniform(0.5, 1.5)) as i64; + } + self.launch_delay -= 1; + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/highlight.rs b/vendor/ttfx/src/effects/highlight.rs new file mode 100644 index 0000000..5166a41 --- /dev/null +++ b/vendor/ttfx/src/effects/highlight.rs @@ -0,0 +1,176 @@ +//! highlight, ported from effects/effect_highlight.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_character_group, parse_gradient_direction, parse_gradient_steps, parse_positive_float, parse_positive_int, +}; +use crate::engine::animation::{Animation, ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::easing::{Easing, SequenceEaser}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct HighlightConfig { + /// Brightness of the highlight color. + #[arg(long = "highlight-brightness", default_value_t = 1.75, value_parser = parse_positive_float)] + pub highlight_brightness: f64, + + /// Direction the highlight will travel. + #[arg(long = "highlight-direction", default_value = "diagonal_bottom_left_to_top_right", + value_parser = parse_character_group)] + pub highlight_direction: CharacterGroup, + + /// Width of the highlight. n >= 1 + #[arg(long = "highlight-width", default_value_t = 8, value_parser = parse_positive_int)] + pub highlight_width: i64, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Highlight { + config: HighlightConfig, + #[allow(dead_code)] // upstream stores this map; nothing reads it (faithful) + character_final_color_map: HashMap>, + easer: Option>>, +} + +impl Highlight { + pub fn new(config: HighlightConfig) -> Self { + Highlight { config, character_final_color_map: HashMap::new(), easer: None } + } +} + +impl EffectHooks for Highlight { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Highlight { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let groups = + ctx.terminal.get_characters_grouped(CharacterFilter::default(), self.config.highlight_direction); + self.easer = Some(SequenceEaser::new(groups, Easing::InOutCirc, 100)); + + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_fg, input_bg, input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.input_coord, + ch.input_symbol.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let (base_color, input_bg_color) = if dynamic { + (input_fg, input_bg) + } else { + (Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, base_color.clone()); + let base_colors = ColorPair::new(base_color.clone(), input_bg_color.clone()); + let highlight_gradient = match &base_color { + Some(base) => { + let highlight_color = + Animation::adjust_color_brightness(base, self.config.highlight_brightness); + Some( + Gradient::new( + &[base.clone(), highlight_color.clone(), highlight_color, base.clone()], + &[3, self.config.highlight_width, 3], + false, + false, + ) + .map_err(EngineError::Other)?, + ) + } + None => None, + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.set_appearance(&input_symbol, uses_pre, Some(&input_symbol), Some(base_colors.clone())); + ch.animation.new_scene(false, None, None, "highlight", uses_pre); + let scene = ch.animation.scenes.get_mut("highlight").unwrap(); + if let Some(gradient) = &highlight_gradient { + for color in &gradient.spectrum { + scene + .add_frame( + &input_symbol, + 2, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), input_bg_color.clone())), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } else { + scene + .add_frame( + &input_symbol, + 2, + VisualParams { colors: Some(base_colors.clone()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } + ctx.terminal.set_character_visibility(id, true); + } + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + let easer_complete = self.easer.as_ref().unwrap().is_complete(); + if !ctx.active_characters.is_empty() || !easer_complete { + let mut easer = self.easer.take().unwrap(); + let step = easer.step(); + for group in step.added { + for &id in group { + ctx.activate_scene(self, id, "highlight"); + ctx.active_characters.insert(id); + } + } + self.easer = Some(easer); + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/laseretch.rs b/vendor/ttfx/src/effects/laseretch.rs new file mode 100644 index 0000000..1673898 --- /dev/null +++ b/vendor/ttfx/src/effects/laseretch.rs @@ -0,0 +1,491 @@ +//! laseretch, ported from effects/effect_laseretch.py. +//! +//! The inner LaserEtchIterator.Laser class is the `Laser` struct; its methods +//! live on `LaserEtch` (they need &mut self for event hooks). Sparks are a +//! ParticlePool; reclaim-on-event is an EventAction::Callback dispatched in +//! dispatch_callback (plan.md §4.2). +//! +//! Upstream quirk (effect_laseretch.py:404): `--etch-pattern ` parses to +//! a CharacterGroup enum member, but build() tests membership against the +//! enum's NAME strings (`CharacterGroup._member_names_`), which never matches a +//! member — the grouped-etch branch is dead code, pending_chars stays empty, +//! and the effect emits exactly one frame. Verified against the pinned +//! reference (`laseretch --etch-pattern row_top_to_bottom` -> frames=1). +//! Reproduced faithfully; only "algorithm" (the default) etches. +//! +//! No observable set iteration beyond the engine-canonical active_characters +//! (docs/ordering-inventory.md): `color_shifted_chars` is created but never +//! used upstream, and the pool's available queue is a deque. + +use std::collections::{HashMap, VecDeque}; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_non_negative_int, parse_positive_int, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::particles::{ParticlePool, ParticleReset}; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::spanning_tree::RecursiveBacktracker; + +/// Callback id: sparks_pool.reclaim(spark, hide=True, deactivate=True). +const CB_RECLAIM_SPARK: u32 = 0; + +/// --etch-pattern accepts either a CharacterGroup name or the literal +/// "algorithm" (upstream `_etch_pattern_type_parser` dual-type parser). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EtchPattern { + Group(CharacterGroup), + Algorithm, +} + +fn parse_etch_pattern(s: &str) -> Result { + if s == "algorithm" { + return Ok(EtchPattern::Algorithm); + } + crate::effects::common::parse_character_group(s).map(EtchPattern::Group) +} + +#[derive(Args, Debug, Clone)] +pub struct LaserEtchConfig { + /// Pattern used to etch the text. + #[arg(long = "etch-pattern", default_value = "algorithm", value_parser = parse_etch_pattern)] + pub etch_pattern: EtchPattern, + + /// Along with etch_delay, determines the speed at which the characters are etched onto the terminal. This value specifies the number of characters to etch simultaneously. + #[arg(long = "etch-speed", default_value_t = 1, value_parser = parse_positive_int)] + pub etch_speed: i64, + + /// Along with etch_speed, determines the speed at which the characters are etched onto the terminal. This values specifies the number of frames to wait before etching the next set of characters. + #[arg(long = "etch-delay", default_value_t = 1, value_parser = parse_non_negative_int)] + pub etch_delay: i64, + + /// Space separated, unquoted, list of colors for the gradient used to cool the characters after etching. + #[arg(long = "cool-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["ffe680", "ff7b00"])] + pub cool_gradient_stops: Vec, + + /// Space separated, unquoted, list of colors for the laser gradient. + #[arg(long = "laser-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["ffffff", "376cff"])] + pub laser_gradient_stops: Vec, + + /// Space separated, unquoted, list of colors for the spark cooling gradient. + #[arg(long = "spark-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["ffffff", "ffe680", "ff7b00", "1a0900"])] + pub spark_gradient_stops: Vec, + + /// Number of frames to display each spark cooling gradient step. Increase to slow down the rate of cooling. + #[arg(long = "spark-cooling-frames", default_value_t = 7, value_parser = parse_positive_int)] + pub spark_cooling_frames: i64, + + /// Space separated, unquoted, list of colors for the character gradient (applied across the canvas). + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "ffffff"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["8"])] + pub final_gradient_steps: Vec, + + /// Number of frames to display each gradient step. Increase to slow down the gradient animation. + #[arg(long = "final-gradient-frames", default_value_t = 4, value_parser = parse_positive_int)] + pub final_gradient_frames: i64, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +/// LaserEtchIterator.Laser state (methods live on LaserEtch for hooks access). +struct Laser { + position: Coord, + beam_chars: Vec, + spark_gradient: Gradient, + sparks_pool: ParticlePool, +} + +/// Laser._make_sparks_pool initialize_sparks closure. +fn initialize_spark(ctx: &mut EngineCtx, spark: CharId, spark_colors: &[Color], cooling_frames: i64) { + let ch = &mut ctx.terminal.arena[spark.0 as usize]; + ch.layer = 2; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + let spark_scn = ch.animation.new_scene(false, None, None, "spark", uses_pre); + let scene = ch.animation.scenes.get_mut(&spark_scn).unwrap(); + for color in spark_colors { + scene + .add_frame( + &input_symbol, + cooling_frames, + VisualParams { colors: Some(ColorPair::new(Some(color.clone()), None)), ..Default::default() }, + ) + .expect("spark frame"); + } +} + +/// LaserEtchIterator._has_input_colors. +fn has_input_colors(ctx: &EngineCtx, id: CharId) -> bool { + let anim = &ctx.terminal.arena[id.0 as usize].animation; + anim.input_fg_color.is_some() || anim.input_bg_color.is_some() +} + +pub struct LaserEtch { + config: LaserEtchConfig, + character_final_color_map: HashMap, + pending_chars: Vec, + char_delay: i64, + laser: Option, +} + +impl LaserEtch { + pub fn new(config: LaserEtchConfig) -> Self { + LaserEtch { + config, + character_final_color_map: HashMap::new(), + pending_chars: Vec::new(), + char_delay: 0, + laser: None, + } + } + + /// Laser.__init__ (+ _make_sparks_pool). The pool is created BEFORE the + /// beam characters, matching upstream's character_id allocation order. + fn make_laser(&mut self, ctx: &mut EngineCtx) -> Result { + let mut laser_gradient: VecDeque = + Gradient::new(&self.config.laser_gradient_stops, &[6], true, true) + .map_err(EngineError::Other)? + .spectrum + .into(); + let spark_gradient = Gradient::new(&self.config.spark_gradient_stops, &[3, 8], false, false) + .map_err(EngineError::Other)?; + + // Laser._make_sparks_pool + let mut sparks_pool = + ParticlePool::new(vec![".".to_string(), ",".to_string(), "*".to_string()], None, None) + .map_err(EngineError::Other)?; + let spark_colors = spark_gradient.spectrum.clone(); + let cooling_frames = self.config.spark_cooling_frames; + sparks_pool + .preallocate(ctx, 2000, |ctx, spark| initialize_spark(ctx, spark, &spark_colors, cooling_frames)) + .map_err(EngineError::Other)?; + for &spark in &sparks_pool.particles { + ctx.register_event( + spark, + Event::SceneComplete, + CallerKey::Scene("spark".to_string()), + EventAction::Callback(EffectCallback { id: CB_RECLAIM_SPARK, args: Vec::new() }), + ) + .map_err(EngineError::Other)?; + } + + // beam characters up the diagonal from (0, 0) + let mut row: i64 = 0; + let mut col: i64 = 0; + let mut beam_chars: Vec = Vec::new(); + while row <= ctx.terminal.canvas.top { + let symbol = if beam_chars.is_empty() { "*" } else { "/" }; + let char_id = ctx.terminal.add_character(symbol, Coord::new(col, row)); + ctx.terminal.arena[char_id.0 as usize].layer = 2; + ctx.terminal.set_character_visibility(char_id, true); + row += 1; + col += 1; + beam_chars.push(char_id); + { + let ch = &mut ctx.terminal.arena[char_id.0 as usize]; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + let laser_scn = ch.animation.new_scene(true, None, None, "laser", uses_pre); + let scene = ch.animation.scenes.get_mut(&laser_scn).unwrap(); + for color in laser_gradient.iter() { + scene + .add_frame( + &input_symbol, + 3, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + // deque.rotate(-1) + if let Some(front) = laser_gradient.pop_front() { + laser_gradient.push_back(front); + } + ctx.activate_scene(self, char_id, "laser"); + } + Ok(Laser { position: Coord::new(0, 0), beam_chars, spark_gradient, sparks_pool }) + } + + /// Laser.reposition. + fn laser_reposition(&mut self, ctx: &mut EngineCtx, target: Coord) { + let mut laser = self.laser.take().expect("laser missing"); + laser.position = target; + let mut row = target.row; + let mut col = target.column; + for &char_id in &laser.beam_chars { + ctx.terminal.arena[char_id.0 as usize].motion.set_coordinate(Coord::new(col, row)); + row += 1; + col += 1; + } + self.laser_emit_sparks(ctx, &mut laser, 1); + self.laser = Some(laser); + } + + /// Laser.emit_sparks (+ its setup_spark_path closure). + fn laser_emit_sparks(&mut self, ctx: &mut EngineCtx, laser: &mut Laser, spark_count: usize) { + let spark_colors = laser.spark_gradient.spectrum.clone(); + let cooling_frames = self.config.spark_cooling_frames; + let position = laser.position; + let bottom = ctx.terminal.canvas.bottom; + for _ in 0..spark_count { + laser.sparks_pool.emit( + ctx, + position, + None, + true, + ParticleReset::default(), + |ctx, spark| initialize_spark(ctx, spark, &spark_colors, cooling_frames), + |ctx, spark| { + // setup_spark_path + ctx.terminal.arena[spark.0 as usize].motion.set_coordinate(position); + let spark_path = ctx.terminal.arena[spark.0 as usize] + .motion + .new_path(0.3, Some(Easing::OutSine), None, 0, false, "") + .expect("spark path"); + let fall_target_coord = + Coord::new(ctx.rng.randint(position.column - 20, position.column + 20), bottom); + let control = Coord::new(fall_target_coord.column, position.row + ctx.rng.randint(-10, 20)); + ctx.terminal.arena[spark.0 as usize] + .motion + .paths + .get_mut(&spark_path) + .unwrap() + .new_waypoint(fall_target_coord, Some(vec![control]), "") + .expect("spark waypoint"); + ctx.activate_path(self, spark, &spark_path); + ctx.activate_scene(self, spark, "spark"); + }, + ); + } + } + + /// Laser.disable. + fn laser_disable(&mut self, ctx: &mut EngineCtx) { + let beam_chars = self.laser.as_ref().expect("laser missing").beam_chars.clone(); + for char_id in beam_chars { + ctx.terminal.set_character_visibility(char_id, false); + } + } +} + +impl EffectHooks for LaserEtch { + fn dispatch_callback(&mut self, ctx: &mut EngineCtx, character: CharId, callback: &EffectCallback) { + if callback.id == CB_RECLAIM_SPARK { + if let Some(laser) = self.laser.as_mut() { + laser.sparks_pool.reclaim(ctx, character, true, true); + } + } + } +} + +impl Effect for LaserEtch { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // LaserEtchIterator.build + let final_fg_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_fg_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_coord, input_symbol, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_coord, + ch.input_symbol.clone(), + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let (final_fg_color, final_bg_color, cool_gradient) = if dynamic { + let pair = ColorPair::new(input_fg, input_bg); + self.character_final_color_map.insert(id, pair.clone()); + let cool = Gradient::with_steps(&self.config.cool_gradient_stops, 8, false) + .map_err(EngineError::Other)?; + (pair.fg_color, pair.bg_color, cool) + } else { + let mapped = final_gradient_mapping.get(&input_coord).unwrap().clone(); + let pair = ColorPair::new(Some(mapped.clone()), None); + self.character_final_color_map.insert(id, pair.clone()); + let mut stops = self.config.cool_gradient_stops.clone(); + stops.push(mapped); + let cool = Gradient::with_steps(&stops, 8, false).map_err(EngineError::Other)?; + (pair.fg_color, pair.bg_color, cool) + }; + // gradients for the dynamic tail, built before borrowing the scene + let cool_last = cool_gradient.spectrum.last().unwrap().clone(); + let mut fg_gradient: Option = None; + let mut bg_gradient: Option = None; + let mut white_cooldown: Option = None; + if dynamic { + if final_fg_color.is_some() || final_bg_color.is_some() { + if let Some(fg) = &final_fg_color { + fg_gradient = Some( + Gradient::with_steps(&[cool_last.clone(), fg.clone()], 8, false) + .map_err(EngineError::Other)?, + ); + } + if let Some(bg) = &final_bg_color { + bg_gradient = Some( + Gradient::with_steps(&[cool_last.clone(), bg.clone()], 8, false) + .map_err(EngineError::Other)?, + ); + } + } else { + white_cooldown = Some( + Gradient::with_steps(&[cool_last.clone(), Color::from_hex("ffffff").unwrap()], 8, false) + .map_err(EngineError::Other)?, + ); + } + } + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let spawn_scn = ch.animation.new_scene(false, None, None, "spawn", uses_pre); + let scene = ch.animation.scenes.get_mut(&spawn_scn).unwrap(); + scene + .add_frame( + "^", + 3, + VisualParams { + colors: Some(ColorPair::new(Some(Color::from_hex("ffe680").unwrap()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + for color in &cool_gradient.spectrum { + scene + .add_frame( + &input_symbol, + 3, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + if dynamic { + if final_fg_color.is_some() || final_bg_color.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 3, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .apply_gradient_to_symbols(&[input_symbol.clone()], 3, white_cooldown.as_ref(), None) + .map_err(EngineError::Other)?; + scene + .add_frame( + &input_symbol, + 3, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } + } + ctx.activate_scene(self, id, "spawn"); + } + match self.config.etch_pattern { + EtchPattern::Group(_) => { + // Dead upstream branch — see module docs. pending_chars stays empty. + } + EtchPattern::Algorithm => { + let mut algo = RecursiveBacktracker::new(ctx, None, true).map_err(EngineError::Other)?; + while !algo.complete { + algo.step(ctx); + } + self.pending_chars = algo.char_link_order; + } + } + + // LaserEtchIterator.__init__ tail + self.char_delay = 0; + let laser = self.make_laser(ctx)?; + for &id in &laser.beam_chars { + ctx.active_characters.insert(id); + } + self.laser = Some(laser); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.pending_chars.is_empty() && ctx.active_characters.is_empty() { + return None; + } + if self.char_delay == 0 { + for _ in 0..self.config.etch_speed { + if self.pending_chars.is_empty() { + break; + } + let mut next_char = self.pending_chars.remove(0); + while ctx.terminal.arena[next_char.0 as usize].input_symbol == " " + && !has_input_colors(ctx, next_char) + { + if !self.pending_chars.is_empty() { + next_char = self.pending_chars.remove(0); + } else { + break; + } + } + ctx.terminal.set_character_visibility(next_char, true); + ctx.active_characters.insert(next_char); + let target = ctx.terminal.arena[next_char.0 as usize].input_coord; + self.laser_reposition(ctx, target); + } + self.char_delay = self.config.etch_delay; + } else { + self.char_delay -= 1; + } + if !self.pending_chars.is_empty() { + let beam_chars = self.laser.as_ref().expect("laser missing").beam_chars.clone(); + for id in beam_chars { + ctx.active_characters.insert(id); + } + } else { + self.laser_disable(ctx); + } + ctx.update(self); + Some(ctx.frame()) + } +} diff --git a/vendor/ttfx/src/effects/matrix.rs b/vendor/ttfx/src/effects/matrix.rs new file mode 100644 index 0000000..8c2b4bf --- /dev/null +++ b/vendor/ttfx/src/effects/matrix.rs @@ -0,0 +1,659 @@ +//! matrix, ported from effects/effect_matrix.py. +//! +//! THE CLOCK EFFECT: upstream reads time.time() at effect_matrix.py:430 +//! (rain_start, set after build) and :549 (rain-phase deadline check). Both +//! sites use ctx.clock.now_wall(); the parity harness virtualizes the clock +//! on both sides (plan.md §4.7). +//! +//! The inner RainColumn class is the `RainColumn` struct. Columns migrate +//! between pending/active/full lists and are compared by identity upstream +//! (`column not in self.full_columns`), so ttfx keeps them in a Vec arena and +//! the lists hold indices. No observable set iteration beyond the +//! engine-canonical active_characters (docs/ordering-inventory.md). + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_positive_float, parse_positive_int, + parse_positive_int_range, parse_symbol, +}; +use crate::engine::animation::{Animation, ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::pycompat::floor_div; + +#[derive(Args, Debug, Clone)] +pub struct MatrixConfig { + /// Color for the bottom of the rain column. + #[arg(long = "highlight-color", default_value = "dbffdb", value_parser = parse_color)] + pub highlight_color: Color, + + /// Space separated, unquoted, list of colors for the rain gradient. Colors are selected from the gradient randomly. If only one color is provided, the characters will be displayed in that color. + #[arg(long = "rain-color-gradient", num_args = 1.., value_parser = parse_color, + default_values = ["92be92", "185318"])] + pub rain_color_gradient: Vec, + + /// Space separated, unquoted, list of symbols to use for the rain. + #[arg(long = "rain-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = [ + "2", "5", "9", "8", "Z", "*", ")", ":", ".", "\"", "=", "+", "-", "¦", "|", "_", + "ヲ", "ア", "ウ", "エ", "オ", "カ", "キ", "ケ", "コ", "サ", "シ", "ス", "セ", "ソ", "タ", "ツ", + "テ", "ナ", "ニ", "ヌ", "ネ", "ハ", "ヒ", "ホ", "マ", "ミ", "ム", "メ", "モ", "ヤ", "ユ", "ラ", + "リ", "ワ", + ])] + pub rain_symbols: Vec, + + /// Range for the speed of the falling rain as determined by the delay between rows. Actual delay is randomly selected from the range. + #[arg(long = "rain-fall-delay-range", default_value = "2-15", value_parser = parse_positive_int_range)] + pub rain_fall_delay_range: (i64, i64), + + /// Range of frames to wait between adding new rain columns. + #[arg(long = "rain-column-delay-range", default_value = "3-9", value_parser = parse_positive_int_range)] + pub rain_column_delay_range: (i64, i64), + + /// Time, in seconds, to display the rain effect before transitioning to the input text. + #[arg(long = "rain-time", default_value_t = 15, value_parser = parse_positive_int)] + pub rain_time: i64, + + /// Chance of swapping a character's symbol on each tick. + #[arg(long = "symbol-swap-chance", default_value_t = 0.005, value_parser = parse_positive_float)] + pub symbol_swap_chance: f64, + + /// Chance of swapping a character's color on each tick. + #[arg(long = "color-swap-chance", default_value_t = 0.001, value_parser = parse_positive_float)] + pub color_swap_chance: f64, + + /// Number of frames to wait between resolving the next group of characters. This is used to adjust the speed of the final resolve phase. + #[arg(long = "resolve-delay", default_value_t = 3, value_parser = parse_positive_int)] + pub resolve_delay: i64, + + /// Space separated, unquoted, list of colors for the character gradient (applied from bottom to top). If only one color is provided, the characters will be displayed in that color. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["92be92", "336b33"])] + pub final_gradient_stops: Vec, + + /// Space separated, unquoted, list of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Number of frames to display each gradient step. Increase to slow down the gradient animation. + #[arg(long = "final-gradient-frames", default_value_t = 3, value_parser = parse_positive_int)] + pub final_gradient_frames: i64, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "radial", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +/// Animation.set_appearance shorthand (upstream character.animation.set_appearance). +fn set_appearance(ctx: &mut EngineCtx, id: CharId, symbol: &str, colors: ColorPair) { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + ch.animation.set_appearance(&input_symbol, uses_pre, Some(symbol), Some(colors)); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ColumnPhase { + Rain, + Fill, +} + +/// MatrixIterator.RainColumn. +struct RainColumn { + characters: Vec, + pending_characters: Vec, + visible_characters: Vec, + phase: ColumnPhase, + column_drop_chance: f64, + base_rain_fall_delay: i64, + active_rain_fall_delay: i64, + length: usize, + hold_time: i64, +} + +impl RainColumn { + /// RainColumn.__init__ (calls setup_column("rain")). + fn new(ctx: &mut EngineCtx, config: &MatrixConfig, characters: Vec) -> Self { + let mut column = RainColumn { + characters, + pending_characters: Vec::new(), + visible_characters: Vec::new(), + phase: ColumnPhase::Rain, + column_drop_chance: 0.08, + base_rain_fall_delay: 0, + active_rain_fall_delay: 0, + length: 0, + hold_time: 0, + }; + column.setup_column(ctx, config, ColumnPhase::Rain); + column + } + + /// RainColumn.setup_column. + fn setup_column(&mut self, ctx: &mut EngineCtx, config: &MatrixConfig, phase: ColumnPhase) { + self.pending_characters.clear(); + self.phase = phase; + for &character in &self.characters { + ctx.terminal.set_character_visibility(character, false); + self.pending_characters.push(character); + let ch = &mut ctx.terminal.arena[character.0 as usize]; + ch.motion.current_coord = ch.input_coord; + } + self.visible_characters = Vec::new(); + self.base_rain_fall_delay = if self.phase == ColumnPhase::Fill { + ctx.rng.randint( + std::cmp::max(floor_div(config.rain_fall_delay_range.0, 3), 1), + std::cmp::max(floor_div(config.rain_fall_delay_range.1, 3), 1), + ) + } else { + ctx.rng.randint(config.rain_fall_delay_range.0, config.rain_fall_delay_range.1) + }; + self.active_rain_fall_delay = 0; + self.length = if self.phase == ColumnPhase::Rain { + ctx.rng.randint( + std::cmp::max(1, (self.characters.len() as f64 * 0.1) as i64), + self.characters.len() as i64, + ) as usize + } else { + self.characters.len() + }; + self.hold_time = 0; + if self.length == self.characters.len() { + self.hold_time = ctx.rng.randint(20, 45); + } + } + + /// RainColumn.trim_column. + fn trim_column(&mut self, ctx: &mut EngineCtx, rain_colors: &[Color]) { + if self.visible_characters.is_empty() { + return; + } + let popped_char = self.visible_characters.remove(0); + ctx.terminal.set_character_visibility(popped_char, false); + if self.visible_characters.len() > 1 { + self.fade_last_character(ctx, rain_colors); + } + } + + /// RainColumn.drop_column. + fn drop_column(&mut self, ctx: &mut EngineCtx) { + let canvas_bottom = ctx.terminal.canvas.bottom; + let mut out_of_canvas: Vec = Vec::new(); + for &character in &self.visible_characters { + let new_coord = { + let motion = &mut ctx.terminal.arena[character.0 as usize].motion; + let current = motion.current_coord; + motion.current_coord = Coord::new(current.column, current.row - 1); + motion.current_coord + }; + if new_coord.row < canvas_bottom { + ctx.terminal.set_character_visibility(character, false); + out_of_canvas.push(character); + } + } + self.visible_characters.retain(|ch| !out_of_canvas.contains(ch)); + } + + /// RainColumn.fade_last_character. + fn fade_last_character(&mut self, ctx: &mut EngineCtx, rain_colors: &[Color]) { + // random.choice(self.rain_colors[-3:]) + let tail = &rain_colors[rain_colors.len().saturating_sub(3)..]; + let darker_color = Animation::adjust_color_brightness(ctx.rng.choice(tail), 0.65); + let target = self.visible_characters[0]; + let symbol = ctx.terminal.arena[target.0 as usize].animation.current_character_visual.symbol.clone(); + set_appearance(ctx, target, &symbol, ColorPair::new(Some(darker_color), None)); + } + + /// RainColumn.resolve_char. + fn resolve_char(&mut self, ctx: &mut EngineCtx) -> CharId { + let index = ctx.rng.randint(0, self.visible_characters.len() as i64 - 1) as usize; + self.visible_characters.remove(index) + } + + /// RainColumn.tick. + fn tick(&mut self, ctx: &mut EngineCtx, config: &MatrixConfig, rain_colors: &[Color]) { + if self.active_rain_fall_delay == 0 { + if !self.pending_characters.is_empty() { + let next_char = self.pending_characters.remove(0); + let symbol = ctx.rng.choice(&config.rain_symbols).clone(); + set_appearance( + ctx, + next_char, + &symbol, + ColorPair::new(Some(config.highlight_color.clone()), None), + ); + let previous_character = self.visible_characters.last().copied(); + // if there is a previous character, remove the highlight + if let Some(previous_character) = previous_character { + let prev_symbol = ctx.terminal.arena[previous_character.0 as usize] + .animation + .current_character_visual + .symbol + .clone(); + let fg = ctx.rng.choice(rain_colors).clone(); + set_appearance(ctx, previous_character, &prev_symbol, ColorPair::new(Some(fg), None)); + } + ctx.terminal.set_character_visibility(next_char, true); + self.visible_characters.push(next_char); + } else if !self.visible_characters.is_empty() { + // adjust the bottom character color to remove the highlight + let last_char = *self.visible_characters.last().unwrap(); + let last_is_highlight = { + let visual = &ctx.terminal.arena[last_char.0 as usize].animation.current_character_visual; + visual + .colors + .as_ref() + .is_some_and(|colors| colors.fg_color.as_ref() == Some(&config.highlight_color)) + }; + if last_is_highlight { + let symbol = ctx.terminal.arena[last_char.0 as usize] + .animation + .current_character_visual + .symbol + .clone(); + let fg = ctx.rng.choice(rain_colors).clone(); + set_appearance(ctx, last_char, &symbol, ColorPair::new(Some(fg), None)); + } + + if self.hold_time != 0 { + self.hold_time -= 1; + } else if self.phase == ColumnPhase::Rain { + if ctx.rng.random() < self.column_drop_chance { + self.drop_column(ctx); + } + self.trim_column(ctx, rain_colors); + } + } + + // if the column is longer than the preset length while still adding characters, trim it + if self.visible_characters.len() > self.length { + self.trim_column(ctx, rain_colors); + } + self.active_rain_fall_delay = self.base_rain_fall_delay; + } else { + self.active_rain_fall_delay -= 1; + } + + // randomly change the symbol and/or color of the characters + for &character in &self.visible_characters { + // Draw both chances (and any resulting choices) in upstream order, + // but leave the cached visual alone when neither value changes. + let next_symbol = if ctx.rng.random() < config.symbol_swap_chance { + Some(ctx.rng.choice(&config.rain_symbols).as_str()) + } else { + None + }; + let next_color = if ctx.rng.random() < config.color_swap_chance { + Some(ctx.rng.choice(rain_colors)) + } else { + None + }; + if next_symbol.is_none() && next_color.is_none() { + continue; + } + + let values_unchanged = { + let visual = &ctx.terminal.arena[character.0 as usize].animation.current_character_visual; + next_symbol.is_none_or(|symbol| symbol == visual.symbol) + && next_color.is_none_or(|color| { + visual.colors.as_ref().and_then(|colors| colors.fg_color.as_ref()) == Some(color) + }) + }; + if values_unchanged { + continue; + } + + match (next_symbol, next_color) { + (Some(symbol), Some(color)) => { + set_appearance(ctx, character, symbol, ColorPair::new(Some(color.clone()), None)); + } + (Some(symbol), None) => { + let color = ctx.terminal.arena[character.0 as usize] + .animation + .current_character_visual + .colors + .as_ref() + .and_then(|colors| colors.fg_color.clone()); + set_appearance(ctx, character, symbol, ColorPair::new(color, None)); + } + (None, Some(color)) => { + let symbol = ctx.terminal.arena[character.0 as usize] + .animation + .current_character_visual + .symbol + .clone(); + set_appearance(ctx, character, &symbol, ColorPair::new(Some(color.clone()), None)); + } + (None, None) => unreachable!("no-change case returned above"), + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + Rain, + Fill, + Resolve, +} + +pub struct Matrix { + config: MatrixConfig, + /// Column arena; pending/active/full hold indices (identity semantics). + columns: Vec, + pending_columns: Vec, + active_columns: Vec, + full_columns: Vec, + character_final_color_map: HashMap, + /// Gradient(*rain_color_gradient, steps=6).spectrum. + rain_colors: Vec, + column_delay: i64, + resolve_delay: i64, + final_frame_shown: bool, + rain_complete: bool, + phase: Phase, + /// time.time() taken after build (effect_matrix.py:430). + rain_start: f64, +} + +impl Matrix { + pub fn new(config: MatrixConfig) -> Self { + let resolve_delay = config.resolve_delay; + Matrix { + config, + columns: Vec::new(), + pending_columns: Vec::new(), + active_columns: Vec::new(), + full_columns: Vec::new(), + character_final_color_map: HashMap::new(), + rain_colors: Vec::new(), + column_delay: 0, + resolve_delay, + final_frame_shown: false, + rain_complete: false, + phase: Phase::Rain, + rain_start: 0.0, + } + } + + /// MatrixIterator._has_input_colors. + fn has_input_colors(ctx: &EngineCtx, character: CharId) -> bool { + let anim = &ctx.terminal.arena[character.0 as usize].animation; + anim.input_fg_color.is_some() || anim.input_bg_color.is_some() + } + + fn column_phase_for(&self) -> ColumnPhase { + match self.phase { + Phase::Rain => ColumnPhase::Rain, + Phase::Fill => ColumnPhase::Fill, + Phase::Resolve => unreachable!("columns are never set up during resolve"), + } + } +} + +impl EffectHooks for Matrix { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Matrix { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // __init__: rain_colors gradient (no RNG consumed) + self.rain_colors = + Gradient::with_steps(&self.config.rain_color_gradient, 6, false).map_err(EngineError::Other)?.spectrum; + + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = + ctx.terminal.get_characters(&mut ctx.rng, CharacterFilter::default(), CharacterSort::TopToBottomLeftToRight); + for character in characters { + let (input_symbol, input_coord, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[character.0 as usize]; + ( + ch.input_symbol.clone(), + ch.input_coord, + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(character, final_colors.clone()); + let final_fg_color = final_colors.fg_color.clone(); + let final_bg_color = final_colors.bg_color.clone(); + let resolve_scn = ctx.terminal.arena[character.0 as usize] + .animation + .new_scene(false, None, None, "resolve", uses_pre); + if dynamic { + let fg_gradient = match &final_fg_color { + Some(fg) => Some( + Gradient::with_steps(&[self.config.highlight_color.clone(), fg.clone()], 8, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &final_bg_color { + Some(bg) => Some( + Gradient::with_steps(&[self.config.highlight_color.clone(), bg.clone()], 8, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let scene = ctx.terminal.arena[character.0 as usize] + .animation + .scenes + .get_mut(&resolve_scn) + .unwrap(); + if fg_gradient.is_some() || bg_gradient.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + self.config.final_gradient_frames, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + self.config.final_gradient_frames, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_fg_color = final_fg_color.expect("non-dynamic final fg color"); + let resolve_gradient = + Gradient::with_steps(&[self.config.highlight_color.clone(), final_fg_color], 8, false) + .map_err(EngineError::Other)?; + for color in &resolve_gradient.spectrum { + ctx.terminal.arena[character.0 as usize] + .animation + .scenes + .get_mut(&resolve_scn) + .unwrap() + .add_frame( + &input_symbol, + self.config.final_gradient_frames, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + } + + let all_chars_filter = CharacterFilter { + input_chars: true, + inner_fill_chars: true, + outer_fill_chars: true, + added_chars: false, + }; + for mut column_chars in + ctx.terminal.get_characters_grouped(all_chars_filter, CharacterGroup::ColumnLeftToRight) + { + column_chars.reverse(); + let column = RainColumn::new(ctx, &self.config, column_chars); + self.columns.push(column); + self.pending_columns.push(self.columns.len() - 1); + } + ctx.rng.shuffle(&mut self.pending_columns); + + // MatrixIterator.__init__: rain_start = time.time() (after build). + self.rain_start = ctx.clock.now_wall(); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.phase == Phase::Rain || self.phase == Phase::Fill { + if self.column_delay == 0 { + if self.phase == Phase::Rain { + for _ in 0..ctx.rng.randint(1, 3) { + if !self.pending_columns.is_empty() { + self.active_columns.push(self.pending_columns.remove(0)); + } + } + } else { + while !self.pending_columns.is_empty() { + self.active_columns.push(self.pending_columns.remove(0)); + } + } + self.column_delay = if self.phase == Phase::Rain { + ctx.rng.randint(self.config.rain_column_delay_range.0, self.config.rain_column_delay_range.1) + } else { + 1 + }; + } else { + self.column_delay -= 1; + } + let active_snapshot = self.active_columns.clone(); + for column_index in active_snapshot { + { + let (columns, config, rain_colors) = (&mut self.columns, &self.config, &self.rain_colors); + columns[column_index].tick(ctx, config, rain_colors); + } + + if self.columns[column_index].pending_characters.is_empty() { + if self.columns[column_index].phase == ColumnPhase::Fill + && !self.full_columns.contains(&column_index) + { + self.full_columns.push(column_index); + } else if self.columns[column_index].visible_characters.is_empty() { + let column_phase = self.column_phase_for(); + let (columns, config) = (&mut self.columns, &self.config); + columns[column_index].setup_column(ctx, config, column_phase); + self.pending_columns.push(column_index); + } + } + } + + { + let columns = &self.columns; + self.active_columns.retain(|&ci| !columns[ci].visible_characters.is_empty()); + } + if self.phase == Phase::Fill + && self.pending_columns.is_empty() + && self.active_columns.iter().all(|&ci| { + self.columns[ci].pending_characters.is_empty() && self.columns[ci].phase == ColumnPhase::Fill + }) + { + self.phase = Phase::Resolve; + self.active_columns.clear(); + } + + // effect_matrix.py:549 — time.time() rain deadline check. + if self.phase == Phase::Rain + && self.config.rain_time > 0 + && ctx.clock.now_wall() - self.rain_start > self.config.rain_time as f64 + { + self.rain_complete = true; + self.phase = Phase::Fill; + for &ci in &self.active_columns { + self.columns[ci].hold_time = 0; + self.columns[ci].column_drop_chance = 1.0; + } + let pending_snapshot = self.pending_columns.clone(); + for ci in pending_snapshot { + let (columns, config) = (&mut self.columns, &self.config); + columns[ci].setup_column(ctx, config, ColumnPhase::Fill); + } + } + } else if self.phase == Phase::Resolve { + let full_snapshot = self.full_columns.clone(); + for column_index in full_snapshot { + { + let (columns, config, rain_colors) = (&mut self.columns, &self.config, &self.rain_colors); + columns[column_index].tick(ctx, config, rain_colors); + } + if !self.columns[column_index].visible_characters.is_empty() { + if self.resolve_delay == 0 { + for _ in 0..ctx.rng.randint(1, 4) { + if !self.columns[column_index].visible_characters.is_empty() { + let next_char = self.columns[column_index].resolve_char(ctx); + let input_symbol = + ctx.terminal.arena[next_char.0 as usize].input_symbol.clone(); + if input_symbol != " " || Self::has_input_colors(ctx, next_char) { + ctx.activate_scene(self, next_char, "resolve"); + ctx.active_characters.insert(next_char); + } else { + ctx.terminal.set_character_visibility(next_char, false); + } + } + } + self.resolve_delay = self.config.resolve_delay; + } else { + self.resolve_delay -= 1; + } + } + } + { + let columns = &self.columns; + self.full_columns.retain(|&ci| !columns[ci].visible_characters.is_empty()); + } + } + + if !self.full_columns.is_empty() + || !self.active_columns.is_empty() + || !ctx.active_characters.is_empty() + || !self.pending_columns.is_empty() + || !self.rain_complete + { + ctx.update(self); + return Some(ctx.frame()); + } + if !self.final_frame_shown { + self.final_frame_shown = true; + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/middleout.rs b/vendor/ttfx/src/effects/middleout.rs new file mode 100644 index 0000000..f9f3cbd --- /dev/null +++ b/vendor/ttfx/src/effects/middleout.rs @@ -0,0 +1,264 @@ +//! middleout, ported from effects/effect_middleout.py. +//! +//! Ordering note: upstream's __next__ iterates the freshly rebuilt +//! active_characters *set* (effect_middleout.py:229-232) to activate the +//! "full" path/scene. Canonical order here is ascending character_id +//! (docs/ordering-inventory.md), matched by a shim patch on +//! MiddleOutIterator.__next__. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_easing, parse_gradient_direction, parse_gradient_steps, parse_positive_float}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +/// typing.Literal["vertical", "horizontal"]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExpandDirection { + Vertical, + Horizontal, +} + +fn parse_expand_direction(s: &str) -> Result { + Ok(match s { + "vertical" => ExpandDirection::Vertical, + "horizontal" => ExpandDirection::Horizontal, + _ => return Err(format!("invalid choice: '{s}' (choose from 'vertical', 'horizontal')")), + }) +} + +#[derive(Args, Debug, Clone)] +pub struct MiddleoutConfig { + /// Color for the initial text in the center of the canvas. + #[arg(long = "starting-color", default_value = "ffffff", value_parser = parse_color)] + pub starting_color: Color, + + /// Direction the text will expand. + #[arg(long = "expand-direction", default_value = "vertical", value_parser = parse_expand_direction)] + pub expand_direction: ExpandDirection, + + /// Speed of the characters during the initial expansion of the center vertical/horiztonal line. + #[arg(long = "center-movement-speed", default_value_t = 0.6, value_parser = parse_positive_float)] + pub center_movement_speed: f64, + + /// Speed of the characters during the final full expansion. + #[arg(long = "full-movement-speed", default_value_t = 0.6, value_parser = parse_positive_float)] + pub full_movement_speed: f64, + + /// Easing function to use for initial expansion. + #[arg(long = "center-easing", default_value = "in_out_sine", value_parser = parse_easing)] + pub center_easing: Easing, + + /// Easing function to use for full expansion. + #[arg(long = "full-easing", default_value = "in_out_sine", value_parser = parse_easing)] + pub full_easing: Easing, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + Center, + Full, +} + +pub struct Middleout { + config: MiddleoutConfig, + character_final_color_map: HashMap, + phase: Phase, +} + +impl Middleout { + pub fn new(config: MiddleoutConfig) -> Self { + Middleout { config, character_final_color_map: HashMap::new(), phase: Phase::Center } + } +} + +impl EffectHooks for Middleout { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Middleout { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_fg, input_bg, input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.input_coord, + ch.input_symbol.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors.clone()); + let center = ctx.terminal.canvas.center; + ctx.terminal.arena[id.0 as usize].motion.set_coordinate(center); + // setup waypoints + let (column, row) = match self.config.expand_direction { + ExpandDirection::Vertical => (input_coord.column, ctx.terminal.canvas.center_row), + ExpandDirection::Horizontal => (ctx.terminal.canvas.center_column, input_coord.row), + }; + let center_path = { + let motion = &mut ctx.terminal.arena[id.0 as usize].motion; + let path_id = motion + .new_path(self.config.center_movement_speed, Some(self.config.center_easing), None, 0, false, "") + .map_err(EngineError::Other)?; + motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(Coord::new(column, row), None, "") + .map_err(EngineError::Other)?; + path_id + }; + { + let motion = &mut ctx.terminal.arena[id.0 as usize].motion; + motion + .new_path(self.config.full_movement_speed, Some(self.config.full_easing), None, 0, false, "full") + .map_err(EngineError::Other)?; + motion + .paths + .get_mut("full") + .unwrap() + .new_waypoint(input_coord, None, "full") + .map_err(EngineError::Other)?; + } + + // setup scenes + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "full", uses_pre); + let scene = ch.animation.scenes.get_mut("full").unwrap(); + let final_fg_color = final_colors.fg_color.clone(); + let final_bg_color = final_colors.bg_color.clone(); + if dynamic { + let fg_gradient = match &final_fg_color { + Some(c) => Some( + Gradient::with_steps(&[self.config.starting_color.clone(), c.clone()], 10, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &final_bg_color { + Some(c) => Some( + Gradient::with_steps(&[self.config.starting_color.clone(), c.clone()], 10, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + if fg_gradient.is_some() || bg_gradient.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 6, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + 6, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_fg_color = final_fg_color.expect("gradient mapping fg"); + let full_gradient = + Gradient::with_steps(&[self.config.starting_color.clone(), final_fg_color], 10, false) + .map_err(EngineError::Other)?; + scene + .apply_gradient_to_symbols(&[input_symbol.clone()], 6, Some(&full_gradient), None) + .map_err(EngineError::Other)?; + } + } + + // initialize character state + ctx.activate_path(self, id, ¢er_path); + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + ch.animation.set_appearance( + &input_symbol, + uses_pre, + Some(&input_symbol.clone()), + Some(ColorPair::new(Some(self.config.starting_color.clone()), None)), + ); + } + ctx.terminal.set_character_visibility(id, true); + ctx.active_characters.insert(id); + } + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.phase == Phase::Center && ctx.active_characters.is_empty() { + self.phase = Phase::Full; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + ctx.active_characters = characters.into_iter().collect(); + // upstream iterates the set here; canonical ascending character_id + let ordered: Vec = ctx.active_characters.iter().collect(); + for id in ordered { + ctx.activate_path(self, id, "full"); + ctx.activate_scene(self, id, "full"); + } + } + if !ctx.active_characters.is_empty() { + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/mod.rs b/vendor/ttfx/src/effects/mod.rs new file mode 100644 index 0000000..c4a79c1 --- /dev/null +++ b/vendor/ttfx/src/effects/mod.rs @@ -0,0 +1,256 @@ +//! Static effect registry (replaces upstream pkgutil discovery). +//! GENERATED structure — keep alphabetical by variant when adding effects. + +pub mod common; +pub mod beams; +pub mod binarypath; +pub mod blackhole; +pub mod bouncyballs; +pub mod bubbles; +pub mod burn; +pub mod colorshift; +pub mod crumble; +pub mod decrypt; +pub mod errorcorrect; +pub mod expand; +pub mod fireworks; +pub mod highlight; +pub mod laseretch; +pub mod matrix; +pub mod middleout; +pub mod orbittingvolley; +pub mod overflow; +pub mod pour; +pub mod print_effect; +pub mod rain; +pub mod random_sequence; +pub mod rings; +pub mod scattered; +pub mod slice; +pub mod slide; +pub mod smoke; +pub mod spotlights; +pub mod spray; +pub mod swarm; +pub mod sweep; +pub mod synthgrid; +pub mod thunderstorm; +pub mod unstable; +pub mod vhstape; +pub mod waves; +pub mod wipe; + +use clap::{Parser, Subcommand}; + +use crate::engine::effect::Effect; +use crate::cli::Cli; + +#[derive(Subcommand, Debug, Clone)] +pub enum EffectCommand { + /// Create beams which travel over the canvas illuminating the characters behind them. + Beams(beams::BeamsConfig), + /// Binary representations of each character move towards the home coordinate of the character. + Binarypath(binarypath::BinaryPathConfig), + /// Characters are consumed by a black hole and explode outwards. + Blackhole(blackhole::BlackholeConfig), + /// Characters are bouncy balls falling from the top of the canvas. + Bouncyballs(bouncyballs::BouncyBallsConfig), + /// Characters are formed into bubbles that float down and pop. + Bubbles(bubbles::BubblesConfig), + /// Burns vertically in the canvas. + Burn(burn::BurnConfig), + /// Display a gradient that shifts colors across the terminal. + Colorshift(colorshift::ColorShiftConfig), + /// Characters lose color and crumble into dust, vacuumed up, and reformed. + Crumble(crumble::CrumbleConfig), + /// Display a movie style decryption effect. + Decrypt(decrypt::DecryptConfig), + /// Some characters start in the wrong position and are corrected in sequence. + Errorcorrect(errorcorrect::ErrorCorrectConfig), + /// Expands the text from a single point. + Expand(expand::ExpandConfig), + /// Characters launch and explode like fireworks and fall into place. + Fireworks(fireworks::FireworksConfig), + /// Run a specular highlight across the text. + Highlight(highlight::HighlightConfig), + /// A laser etches characters onto the terminal. + Laseretch(laseretch::LaserEtchConfig), + /// Matrix digital rain effect. + Matrix(matrix::MatrixConfig), + /// Text expands in a single row or column in the middle of the canvas then out. + Middleout(middleout::MiddleoutConfig), + /// Four launchers orbit the canvas firing volleys of characters inward to build the input text from the center out. + Orbittingvolley(orbittingvolley::OrbittingVolleyConfig), + /// Input text overflows and scrolls the terminal in a random order until eventually appearing ordered. + Overflow(overflow::OverflowConfig), + /// Pours the characters into position from the given direction. + Pour(pour::PourConfig), + /// Lines are printed one at a time following a print head. Print head performs line feed, carriage return. + Print(print_effect::PrintConfig), + /// Rain characters from the top of the canvas. + Rain(rain::RainConfig), + /// Prints the input data in a random sequence. + Randomsequence(random_sequence::RandomSequenceConfig), + /// Characters are dispersed and form into spinning rings. + Rings(rings::RingsConfig), + /// Text is scattered across the canvas and moves into position. + Scattered(scattered::ScatteredConfig), + /// Slices the input in half and slides it into place from opposite directions. + Slice(slice::SliceConfig), + /// Slide characters into view from outside the terminal. + Slide(slide::SlideConfig), + /// Smoke floods the canvas colorizing any characters it crosses. + Smoke(smoke::SmokeConfig), + /// Spotlights search the text area, illuminating characters, before converging in the center and expanding. + Spotlights(spotlights::SpotlightsConfig), + /// Draws the characters spawning at varying rates from a single point. + Spray(spray::SprayConfig), + /// Characters are grouped into swarms and move around the terminal before settling into position. + Swarm(swarm::SwarmConfig), + /// Sweep across the canvas to reveal uncolored text, reverse sweep to color the text. + Sweep(sweep::SweepConfig), + /// Create a grid which fills with characters dissolving into the final text. + Synthgrid(synthgrid::SynthGridConfig), + /// Create a thunderstorm in the terminal. + Thunderstorm(thunderstorm::ThunderstormConfig), + /// Spawn characters jumbled, explode them to the edge of the canvas, then reassemble them in the correct layout. + Unstable(unstable::UnstableConfig), + /// Lines of characters glitch left and right and lose detail like an old VHS tape. + Vhstape(vhstape::VhsTapeConfig), + /// Waves travel across the terminal leaving behind the characters. + Waves(waves::WavesConfig), + /// Wipes the text across the terminal to reveal characters. + Wipe(wipe::WipeConfig), +} + +impl EffectCommand { + pub fn build_effect(&self) -> Box { + match self { + EffectCommand::Beams(config) => Box::new(beams::Beams::new(config.clone())), + EffectCommand::Binarypath(config) => Box::new(binarypath::BinaryPath::new(config.clone())), + EffectCommand::Blackhole(config) => Box::new(blackhole::Blackhole::new(config.clone())), + EffectCommand::Bouncyballs(config) => Box::new(bouncyballs::BouncyBalls::new(config.clone())), + EffectCommand::Bubbles(config) => Box::new(bubbles::Bubbles::new(config.clone())), + EffectCommand::Burn(config) => Box::new(burn::Burn::new(config.clone())), + EffectCommand::Colorshift(config) => Box::new(colorshift::ColorShift::new(config.clone())), + EffectCommand::Crumble(config) => Box::new(crumble::Crumble::new(config.clone())), + EffectCommand::Decrypt(config) => Box::new(decrypt::Decrypt::new(config.clone())), + EffectCommand::Errorcorrect(config) => Box::new(errorcorrect::ErrorCorrect::new(config.clone())), + EffectCommand::Expand(config) => Box::new(expand::Expand::new(config.clone())), + EffectCommand::Fireworks(config) => Box::new(fireworks::Fireworks::new(config.clone())), + EffectCommand::Highlight(config) => Box::new(highlight::Highlight::new(config.clone())), + EffectCommand::Laseretch(config) => Box::new(laseretch::LaserEtch::new(config.clone())), + EffectCommand::Matrix(config) => Box::new(matrix::Matrix::new(config.clone())), + EffectCommand::Middleout(config) => Box::new(middleout::Middleout::new(config.clone())), + EffectCommand::Orbittingvolley(config) => Box::new(orbittingvolley::OrbittingVolley::new(config.clone())), + EffectCommand::Overflow(config) => Box::new(overflow::Overflow::new(config.clone())), + EffectCommand::Pour(config) => Box::new(pour::Pour::new(config.clone())), + EffectCommand::Print(config) => Box::new(print_effect::Print::new(config.clone())), + EffectCommand::Rain(config) => Box::new(rain::Rain::new(config.clone())), + EffectCommand::Randomsequence(config) => Box::new(random_sequence::RandomSequence::new(config.clone())), + EffectCommand::Rings(config) => Box::new(rings::Rings::new(config.clone())), + EffectCommand::Scattered(config) => Box::new(scattered::Scattered::new(config.clone())), + EffectCommand::Slice(config) => Box::new(slice::Slice::new(config.clone())), + EffectCommand::Slide(config) => Box::new(slide::Slide::new(config.clone())), + EffectCommand::Smoke(config) => Box::new(smoke::Smoke::new(config.clone())), + EffectCommand::Spotlights(config) => Box::new(spotlights::Spotlights::new(config.clone())), + EffectCommand::Spray(config) => Box::new(spray::Spray::new(config.clone())), + EffectCommand::Swarm(config) => Box::new(swarm::Swarm::new(config.clone())), + EffectCommand::Sweep(config) => Box::new(sweep::Sweep::new(config.clone())), + EffectCommand::Synthgrid(config) => Box::new(synthgrid::SynthGrid::new(config.clone())), + EffectCommand::Thunderstorm(config) => Box::new(thunderstorm::Thunderstorm::new(config.clone())), + EffectCommand::Unstable(config) => Box::new(unstable::Unstable::new(config.clone())), + EffectCommand::Vhstape(config) => Box::new(vhstape::VhsTape::new(config.clone())), + EffectCommand::Waves(config) => Box::new(waves::Waves::new(config.clone())), + EffectCommand::Wipe(config) => Box::new(wipe::Wipe::new(config.clone())), + } + } + + pub fn name(&self) -> &'static str { + match self { + EffectCommand::Beams(_) => "beams", + EffectCommand::Binarypath(_) => "binarypath", + EffectCommand::Blackhole(_) => "blackhole", + EffectCommand::Bouncyballs(_) => "bouncyballs", + EffectCommand::Bubbles(_) => "bubbles", + EffectCommand::Burn(_) => "burn", + EffectCommand::Colorshift(_) => "colorshift", + EffectCommand::Crumble(_) => "crumble", + EffectCommand::Decrypt(_) => "decrypt", + EffectCommand::Errorcorrect(_) => "errorcorrect", + EffectCommand::Expand(_) => "expand", + EffectCommand::Fireworks(_) => "fireworks", + EffectCommand::Highlight(_) => "highlight", + EffectCommand::Laseretch(_) => "laseretch", + EffectCommand::Matrix(_) => "matrix", + EffectCommand::Middleout(_) => "middleout", + EffectCommand::Orbittingvolley(_) => "orbittingvolley", + EffectCommand::Overflow(_) => "overflow", + EffectCommand::Pour(_) => "pour", + EffectCommand::Print(_) => "print", + EffectCommand::Rain(_) => "rain", + EffectCommand::Randomsequence(_) => "randomsequence", + EffectCommand::Rings(_) => "rings", + EffectCommand::Scattered(_) => "scattered", + EffectCommand::Slice(_) => "slice", + EffectCommand::Slide(_) => "slide", + EffectCommand::Smoke(_) => "smoke", + EffectCommand::Spotlights(_) => "spotlights", + EffectCommand::Spray(_) => "spray", + EffectCommand::Swarm(_) => "swarm", + EffectCommand::Sweep(_) => "sweep", + EffectCommand::Synthgrid(_) => "synthgrid", + EffectCommand::Thunderstorm(_) => "thunderstorm", + EffectCommand::Unstable(_) => "unstable", + EffectCommand::Vhstape(_) => "vhstape", + EffectCommand::Waves(_) => "waves", + EffectCommand::Wipe(_) => "wipe", + } + } + + /// Construct an effect from its registry name using struct/Clap defaults. + pub fn from_name(name: &str) -> Option> { + let cli = Cli::try_parse_from(["ttfx", name]).ok()?; + Some(cli.effect?.build_effect()) + } + + pub const NAMES: &'static [&'static str] = &[ + "beams", + "binarypath", + "blackhole", + "bouncyballs", + "bubbles", + "burn", + "colorshift", + "crumble", + "decrypt", + "errorcorrect", + "expand", + "fireworks", + "highlight", + "laseretch", + "matrix", + "middleout", + "orbittingvolley", + "overflow", + "pour", + "print", + "rain", + "randomsequence", + "rings", + "scattered", + "slice", + "slide", + "smoke", + "spotlights", + "spray", + "swarm", + "sweep", + "synthgrid", + "thunderstorm", + "unstable", + "vhstape", + "waves", + "wipe", + ]; +} diff --git a/vendor/ttfx/src/effects/orbittingvolley.rs b/vendor/ttfx/src/effects/orbittingvolley.rs new file mode 100644 index 0000000..c9f53bc --- /dev/null +++ b/vendor/ttfx/src/effects/orbittingvolley.rs @@ -0,0 +1,395 @@ +//! orbittingvolley, ported from effects/effect_orbittingvolley.py. +//! +//! The inner OrbittingVolleyIterator.Launcher class is the `Launcher` struct. +//! No observable set iteration beyond the engine-canonical active_characters +//! (docs/ordering-inventory.md); the effect consumes no RNG at all. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_easing, parse_gradient_direction, parse_gradient_steps, parse_non_negative_int, parse_non_negative_ratio, + parse_positive_float, parse_symbol, +}; +use crate::engine::animation::ExistingColorHandling; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, CoordColorMap, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct OrbittingVolleyConfig { + /// Symbol for the top launcher. + #[arg(long = "top-launcher-symbol", default_value = "█", value_parser = parse_symbol)] + pub top_launcher_symbol: String, + + /// Symbol for the right launcher. + #[arg(long = "right-launcher-symbol", default_value = "█", value_parser = parse_symbol)] + pub right_launcher_symbol: String, + + /// Symbol for the bottom launcher. + #[arg(long = "bottom-launcher-symbol", default_value = "█", value_parser = parse_symbol)] + pub bottom_launcher_symbol: String, + + /// Symbol for the left launcher. + #[arg(long = "left-launcher-symbol", default_value = "█", value_parser = parse_symbol)] + pub left_launcher_symbol: String, + + /// Orbitting speed of the launchers. + #[arg(long = "launcher-movement-speed", default_value_t = 0.8, value_parser = parse_positive_float)] + pub launcher_movement_speed: f64, + + /// Speed of the launched characters. + #[arg(long = "character-movement-speed", default_value_t = 1.5, value_parser = parse_positive_float)] + pub character_movement_speed: f64, + + /// Percent of total input characters each launcher will fire per volley. Lower limit of one character. + #[arg(long = "volley-size", default_value_t = 0.03, value_parser = parse_non_negative_ratio)] + pub volley_size: f64, + + /// Number of animation ticks to wait between volleys of characters. + #[arg(long = "launch-delay", default_value_t = 30, value_parser = parse_non_negative_int)] + pub launch_delay: i64, + + /// Easing function to use for launched character movement. + #[arg(long = "character-easing", default_value = "out_sine", value_parser = parse_easing)] + pub character_easing: Easing, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["FFA15C", "44D492"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "radial", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +/// OrbittingVolleyIterator.Launcher. +struct Launcher { + character: CharId, + magazine: Vec, +} + +pub struct OrbittingVolley { + config: OrbittingVolleyConfig, + character_final_color_map: HashMap, + launcher_gradient_coordinate_map: CoordColorMap, + final_gradient_last_color: Option, + launchers: Vec, + delay: i64, + complete: bool, +} + +impl OrbittingVolley { + pub fn new(config: OrbittingVolleyConfig) -> Self { + OrbittingVolley { + config, + character_final_color_map: HashMap::new(), + launcher_gradient_coordinate_map: CoordColorMap::default(), + final_gradient_last_color: None, + launchers: Vec::new(), + delay: 0, + complete: false, + } + } + + /// Launcher.build_paths (only called for the main launcher). + fn build_launcher_paths(&self, ctx: &mut EngineCtx, id: CharId) -> Result<(), EngineError> { + let waypoints = [ + Coord::new(ctx.terminal.canvas.left, ctx.terminal.canvas.top), + Coord::new(ctx.terminal.canvas.right, ctx.terminal.canvas.top), + ]; + let input_coord = ctx.terminal.arena[id.0 as usize].input_coord; + let waypoint_start_index = waypoints + .iter() + .position(|&c| c == input_coord) + .expect("launcher input coord not on perimeter waypoint list"); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let perimeter_path = ch + .motion + .new_path(self.config.launcher_movement_speed, None, Some(2), 0, false, "perimeter") + .map_err(EngineError::Other)?; + let path = ch.motion.paths.get_mut(&perimeter_path).unwrap(); + for waypoint in waypoints[waypoint_start_index..].iter().chain(waypoints[..waypoint_start_index].iter()) { + path.new_waypoint(*waypoint, None, "").map_err(EngineError::Other)?; + } + Ok(()) + } + + /// Launcher.launch. + fn launch(&mut self, ctx: &mut EngineCtx, launcher_index: usize) -> Option { + let launcher = &mut self.launchers[launcher_index]; + if launcher.magazine.is_empty() { + return None; + } + let next_char = launcher.magazine.remove(0); + let launcher_coord = ctx.terminal.arena[launcher.character.0 as usize].motion.current_coord; + ctx.terminal.arena[next_char.0 as usize].motion.set_coordinate(launcher_coord); + ctx.activate_path(self, next_char, "input_path"); + ctx.terminal.set_character_visibility(next_char, true); + Some(next_char) + } + + /// OrbittingVolleyIterator._set_launcher_coordinates. + fn set_launcher_coordinates(&mut self, ctx: &mut EngineCtx, parent_index: usize, child_index: usize) { + let canvas_top = ctx.terminal.canvas.top; + let canvas_bottom = ctx.terminal.canvas.bottom; + let canvas_left = ctx.terminal.canvas.left; + let canvas_right = ctx.terminal.canvas.right; + let parent_char = self.launchers[parent_index].character; + let child_char = self.launchers[child_index].character; + let parent_progress = + ctx.terminal.arena[parent_char.0 as usize].motion.current_coord.column as f64 / canvas_right as f64; + let child_input_coord = ctx.terminal.arena[child_char.0 as usize].input_coord; + if child_input_coord == Coord::new(canvas_right, canvas_top) { + let child_row = canvas_top - (canvas_top as f64 * parent_progress) as i64; + ctx.terminal.arena[child_char.0 as usize] + .motion + .set_coordinate(Coord::new(canvas_right, std::cmp::max(1, child_row))); + } else if child_input_coord == Coord::new(canvas_right, canvas_bottom) { + let child_column = canvas_right - (canvas_right as f64 * parent_progress) as i64; + ctx.terminal.arena[child_char.0 as usize] + .motion + .set_coordinate(Coord::new(std::cmp::max(1, child_column), canvas_bottom)); + } else if child_input_coord == Coord::new(canvas_left, canvas_bottom) { + let child_row = canvas_bottom + (canvas_top as f64 * parent_progress) as i64; + ctx.terminal.arena[child_char.0 as usize] + .motion + .set_coordinate(Coord::new(canvas_left, std::cmp::min(canvas_top, child_row))); + } + let current_coord = ctx.terminal.arena[child_char.0 as usize].motion.current_coord; + let color = self + .launcher_gradient_coordinate_map + .get(¤t_coord) + .expect("launcher coord outside gradient map") + .clone(); + let ch = &mut ctx.terminal.arena[child_char.0 as usize]; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + ch.animation.set_appearance( + &input_symbol, + uses_pre, + Some(&input_symbol.clone()), + Some(ColorPair::new(Some(color), None)), + ); + } +} + +impl EffectHooks for OrbittingVolley { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for OrbittingVolley { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_coordinate_map = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + self.launcher_gradient_coordinate_map = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.bottom, + ctx.terminal.canvas.top, + ctx.terminal.canvas.left, + ctx.terminal.canvas.right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + self.final_gradient_last_color = Some(final_gradient.spectrum.last().unwrap().clone()); + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_coord, input_symbol, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_coord, + ch.input_symbol.clone(), + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_coordinate_map.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors.clone()); + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let input_path = ch + .motion + .new_path( + self.config.character_movement_speed, + Some(self.config.character_easing), + Some(1), + 0, + false, + "input_path", + ) + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&input_path) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + } + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path("input_path".to_string()), + EventAction::SetLayer(0), + ) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.set_appearance(&input_symbol, uses_pre, Some(&input_symbol.clone()), Some(final_colors)); + } + + let launcher_specs = [ + ( + Coord::new(ctx.terminal.canvas.left, ctx.terminal.canvas.top), + self.config.top_launcher_symbol.clone(), + ), + ( + Coord::new(ctx.terminal.canvas.right, ctx.terminal.canvas.top), + self.config.right_launcher_symbol.clone(), + ), + ( + Coord::new(ctx.terminal.canvas.right, ctx.terminal.canvas.bottom), + self.config.bottom_launcher_symbol.clone(), + ), + ( + Coord::new(ctx.terminal.canvas.left, ctx.terminal.canvas.bottom), + self.config.left_launcher_symbol.clone(), + ), + ]; + for (coord, symbol) in launcher_specs { + let character = ctx.terminal.add_character(&symbol, coord); + ctx.terminal.arena[character.0 as usize].layer = 2; + ctx.terminal.set_character_visibility(character, true); + ctx.active_characters.insert(character); + self.launchers.push(Launcher { character, magazine: Vec::new() }); + } + let main_character = self.launchers[0].character; + { + let color = self.final_gradient_last_color.clone(); + let ch = &mut ctx.terminal.arena[main_character.0 as usize]; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + ch.animation.set_appearance( + &input_symbol, + uses_pre, + Some(&input_symbol.clone()), + Some(ColorPair::new(color, None)), + ); + } + self.build_launcher_paths(ctx, main_character)?; + ctx.activate_path(self, main_character, "perimeter"); + + let mut sorted_chars: Vec = Vec::new(); + for char_list in + ctx.terminal.get_characters_grouped(CharacterFilter::default(), CharacterGroup::CenterToOutside) + { + sorted_chars.extend(char_list); + } + for (index, character) in sorted_chars.into_iter().enumerate() { + let launcher_index = index % self.launchers.len(); + self.launchers[launcher_index].magazine.push(character); + } + self.delay = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.launchers.iter().any(|l| !l.magazine.is_empty()) || ctx.active_characters.len() > 1 { + let main_character = self.launchers[0].character; + if ctx.terminal.arena[main_character.0 as usize].motion.active_path.is_none() { + let first_waypoint_coord = ctx.terminal.arena[main_character.0 as usize] + .motion + .paths + .get("perimeter") + .expect("perimeter path missing") + .waypoints[0] + .coord; + ctx.terminal.arena[main_character.0 as usize].motion.set_coordinate(first_waypoint_coord); + ctx.activate_path(self, main_character, "perimeter"); + ctx.active_characters.insert(main_character); + } + { + let current_coord = ctx.terminal.arena[main_character.0 as usize].motion.current_coord; + let color = self + .launcher_gradient_coordinate_map + .get(¤t_coord) + .expect("main launcher coord outside gradient map") + .clone(); + let symbol = self.config.top_launcher_symbol.clone(); + let ch = &mut ctx.terminal.arena[main_character.0 as usize]; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + ch.animation.set_appearance( + &input_symbol, + uses_pre, + Some(&symbol), + Some(ColorPair::new(Some(color), None)), + ); + } + for child_index in 1..self.launchers.len() { + self.set_launcher_coordinates(ctx, 0, child_index); + } + if self.delay == 0 { + for launcher_index in 0..self.launchers.len() { + // max(int((volley_size * len(input_characters)) / 4), 1) — int() truncation + let characters_to_launch = std::cmp::max( + ((self.config.volley_size * ctx.terminal.input_characters.len() as f64) / 4.0) as i64, + 1, + ); + for _ in 0..characters_to_launch { + if let Some(next_char) = self.launch(ctx, launcher_index) { + ctx.active_characters.insert(next_char); + } + } + } + self.delay = self.config.launch_delay; + } else { + self.delay -= 1; + } + + ctx.update(self); + return Some(ctx.frame()); + } + if !self.complete { + self.complete = true; + for launcher_index in 0..self.launchers.len() { + let character = self.launchers[launcher_index].character; + ctx.terminal.set_character_visibility(character, false); + } + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/overflow.rs b/vendor/ttfx/src/effects/overflow.rs new file mode 100644 index 0000000..6f460ed --- /dev/null +++ b/vendor/ttfx/src/effects/overflow.rs @@ -0,0 +1,280 @@ +//! overflow, ported from effects/effect_overflow.py. + +use std::collections::{HashMap, VecDeque}; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_positive_int, parse_positive_int_range, +}; +use crate::engine::animation::ExistingColorHandling; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterGroup}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::pycompat::floor_div; + +#[derive(Args, Debug, Clone)] +pub struct OverflowConfig { + /// Space separated, unquoted, list of colors for the overflow gradient. + #[arg(long = "overflow-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["f2ebc0", "8dbfb3", "f2ebc0"])] + pub overflow_gradient_stops: Vec, + + /// Number of cycles to overflow the text. + #[arg(long = "overflow-cycles-range", default_value = "2-4", value_parser = parse_positive_int_range)] + pub overflow_cycles_range: (i64, i64), + + /// Speed of the overflow effect. + #[arg(long = "overflow-speed", default_value_t = 3, value_parser = parse_positive_int)] + pub overflow_speed: i64, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +/// OverflowIterator.Row. +struct Row { + characters: Vec, + final_: bool, +} + +impl Row { + /// Row.move_up. + fn move_up(&self, ctx: &mut EngineCtx) { + for &id in &self.characters { + let motion = &mut ctx.terminal.arena[id.0 as usize].motion; + let current = motion.current_coord; + motion.set_coordinate(Coord::new(current.column, current.row + 1)); + } + } + + /// Row.setup. + fn setup(&self, ctx: &mut EngineCtx) { + for &id in &self.characters { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let column = ch.input_coord.column; + ch.motion.set_coordinate(Coord::new(column, 0)); + } + } + + /// Row.set_color. + fn set_color(&self, ctx: &mut EngineCtx, fg_color: Option, bg_color: Option) { + for &id in &self.characters { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + ch.animation.set_appearance( + &input_symbol, + uses_pre, + Some(&input_symbol.clone()), + Some(ColorPair::new(fg_color.clone(), bg_color.clone())), + ); + } + } +} + +pub struct Overflow { + config: OverflowConfig, + pending_rows: VecDeque, + active_rows: Vec, + character_final_color_map: HashMap, + delay: i64, + overflow_gradient: Option, +} + +impl Overflow { + pub fn new(config: OverflowConfig) -> Self { + Overflow { + config, + pending_rows: VecDeque::new(), + active_rows: Vec::new(), + character_final_color_map: HashMap::new(), + delay: 0, + overflow_gradient: None, + } + } +} + +impl EffectHooks for Overflow { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Overflow { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let fills_filter = + CharacterFilter { inner_fill_chars: true, outer_fill_chars: true, ..Default::default() }; + let characters = ctx.terminal.get_characters( + &mut ctx.rng, + fills_filter, + crate::engine::terminal::CharacterSort::TopToBottomLeftToRight, + ); + for &id in &characters { + let coord = ctx.terminal.arena[id.0 as usize].input_coord; + let color = final_gradient_mapping + .get(&coord) + .cloned() + .unwrap_or_else(|| Color::from_hex("000000").unwrap()); + self.character_final_color_map.insert(id, color); + } + let (lower_range, upper_range) = self.config.overflow_cycles_range; + let mut rows = + ctx.terminal.get_characters_grouped(CharacterFilter::default(), CharacterGroup::RowTopToBottom); + if upper_range > 0 { + for _ in 0..ctx.rng.randint(lower_range, upper_range) { + ctx.rng.shuffle(&mut rows); + for row in &rows { + let mut copied_characters: Vec = Vec::new(); + // copy the character attributes to new characters + for &id in row { + let (symbol, coord, fg_seq, bg_seq, no_color, use_xterm, input_fg, input_bg) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_symbol.clone(), + ch.input_coord, + ch.input_ansi_fg_sequence.clone(), + ch.input_ansi_bg_sequence.clone(), + ch.animation.no_color, + ch.animation.use_xterm_colors, + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ) + }; + let copy_id = ctx.terminal.add_character(&symbol, coord); + let copy = &mut ctx.terminal.arena[copy_id.0 as usize]; + copy.animation.existing_color_handling = ctx.terminal.config.existing_color_handling; + copy.uses_input_preexisting_colors = true; + copy.input_ansi_fg_sequence = fg_seq; + copy.input_ansi_bg_sequence = bg_seq; + copy.animation.no_color = no_color; + copy.animation.use_xterm_colors = use_xterm; + copy.animation.input_fg_color = input_fg; + copy.animation.input_bg_color = input_bg; + copied_characters.push(copy_id); + } + self.pending_rows.push_back(Row { characters: copied_characters, final_: false }); + } + } + } + // add rows in correct order to the end of pending_rows + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + for row in ctx.terminal.get_characters_grouped(fills_filter, CharacterGroup::RowTopToBottom) { + for &id in &row { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let current_symbol = ch.animation.current_character_visual.symbol.clone(); + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + if dynamic { + let input_fg = ch.animation.input_fg_color.clone(); + let input_bg = ch.animation.input_bg_color.clone(); + if input_fg.is_some() || input_bg.is_some() { + ch.animation.set_appearance( + &input_symbol, + uses_pre, + Some(¤t_symbol), + Some(ColorPair::new(input_fg, input_bg)), + ); + } else { + ch.animation.set_appearance( + &input_symbol, + uses_pre, + Some(¤t_symbol), + Some(ColorPair::default()), + ); + } + } else { + let final_color = self.character_final_color_map.get(&id).unwrap().clone(); + ch.animation.set_appearance( + &input_symbol, + uses_pre, + Some(¤t_symbol), + Some(ColorPair::new(Some(final_color), None)), + ); + } + } + self.pending_rows.push_back(Row { characters: row, final_: true }); + } + self.delay = 0; + let steps = std::cmp::max( + floor_div( + ctx.terminal.canvas.top, + std::cmp::max(1, self.config.overflow_gradient_stops.len() as i64 - 1), + ), + 1, + ); + self.overflow_gradient = Some( + Gradient::with_steps(&self.config.overflow_gradient_stops, steps, false) + .map_err(EngineError::Other)?, + ); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.pending_rows.is_empty() { + if self.delay == 0 { + let spectrum = &self.overflow_gradient.as_ref().unwrap().spectrum; + for _ in 0..ctx.rng.randint(1, self.config.overflow_speed) { + if !self.pending_rows.is_empty() { + for row in &self.active_rows { + row.move_up(ctx); + if !row.final_ { + let head_row = + ctx.terminal.arena[row.characters[0].0 as usize].motion.current_coord.row; + let index = std::cmp::min(head_row, spectrum.len() as i64 - 1) as usize; + row.set_color(ctx, Some(spectrum[index].clone()), None); + } + } + let next_row = self.pending_rows.pop_front().unwrap(); + next_row.setup(ctx); + next_row.move_up(ctx); + if !next_row.final_ { + next_row.set_color(ctx, Some(spectrum[0].clone()), None); + } + for &id in &next_row.characters { + ctx.terminal.set_character_visibility(id, true); + } + self.active_rows.push(next_row); + } + } + self.delay = ctx.rng.randint(0, 3); + } else { + self.delay -= 1; + } + let canvas_top = ctx.terminal.canvas.top; + let arena = &ctx.terminal.arena; + self.active_rows + .retain(|row| arena[row.characters[0].0 as usize].motion.current_coord.row <= canvas_top); + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/pour.rs b/vendor/ttfx/src/effects/pour.rs new file mode 100644 index 0000000..72ce181 --- /dev/null +++ b/vendor/ttfx/src/effects/pour.rs @@ -0,0 +1,279 @@ +//! pour, ported from effects/effect_pour.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_easing, parse_gradient_direction, parse_gradient_steps, parse_non_negative_int, parse_positive_float_range, + parse_positive_int, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +/// PourIterator.PourDirection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PourDirection { + Up, + Down, + Left, + Right, +} + +fn parse_pour_direction(s: &str) -> Result { + Ok(match s { + "up" => PourDirection::Up, + "down" => PourDirection::Down, + "left" => PourDirection::Left, + "right" => PourDirection::Right, + _ => return Err(format!("invalid choice: '{s}' (choose from 'up', 'down', 'left', 'right')")), + }) +} + +#[derive(Args, Debug, Clone)] +pub struct PourConfig { + /// Direction the text will pour. + #[arg(long = "pour-direction", default_value = "down", value_parser = parse_pour_direction)] + pub pour_direction: PourDirection, + + /// Number of characters poured in per tick. Increase to speed up the effect. + #[arg(long = "pour-speed", default_value_t = 2, value_parser = parse_positive_int)] + pub pour_speed: i64, + + /// Movement speed range of the characters. + #[arg(long = "movement-speed-range", default_value = "0.4-0.6", value_parser = parse_positive_float_range)] + pub movement_speed_range: (f64, f64), + + /// Number of frames to wait between each character in the pour effect. + #[arg(long = "gap", default_value_t = 1, value_parser = parse_non_negative_int)] + pub gap: i64, + + /// Color of the characters before the gradient starts. + #[arg(long = "starting-color", default_value = "ffffff", value_parser = parse_color)] + pub starting_color: Color, + + /// Space separated, unquoted, list of colors for the character gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Number of frames to display each gradient step. + #[arg(long = "final-gradient-frames", default_value_t = 6)] + pub final_gradient_frames: i64, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, + + /// Easing function to use for character movement. + #[arg(long = "movement-easing", default_value = "in_quad", value_parser = parse_easing)] + pub movement_easing: Easing, +} + +pub struct Pour { + config: PourConfig, + pending_groups: Vec>, + character_final_color_map: HashMap, + current_group: Vec, + gap: i64, +} + +impl Pour { + pub fn new(config: PourConfig) -> Self { + Pour { + config, + pending_groups: Vec::new(), + character_final_color_map: HashMap::new(), + current_group: Vec::new(), + gap: 0, + } + } +} + +impl EffectHooks for Pour { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Pour { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let pour_direction = self.config.pour_direction; + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_fg, input_bg, input_coord) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone(), ch.input_coord) + }; + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors); + } + let grouping = match pour_direction { + PourDirection::Down => CharacterGroup::RowBottomToTop, + PourDirection::Up => CharacterGroup::RowTopToBottom, + PourDirection::Left => CharacterGroup::ColumnLeftToRight, + PourDirection::Right => CharacterGroup::ColumnRightToLeft, + }; + let groups = ctx.terminal.get_characters_grouped(CharacterFilter::default(), grouping); + for (i, group) in groups.iter().enumerate() { + for &id in group { + ctx.terminal.set_character_visibility(id, false); + let (input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_coord, ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + let start_coord = match pour_direction { + PourDirection::Down => Coord::new(input_coord.column, ctx.terminal.canvas.top), + PourDirection::Up => Coord::new(input_coord.column, ctx.terminal.canvas.bottom), + PourDirection::Left => Coord::new(ctx.terminal.canvas.right, input_coord.row), + PourDirection::Right => Coord::new(ctx.terminal.canvas.left, input_coord.row), + }; + ctx.terminal.arena[id.0 as usize].motion.set_coordinate(start_coord); + let speed = ctx.rng.uniform(self.config.movement_speed_range.0, self.config.movement_speed_range.1); + let input_coord_path = ctx.terminal.arena[id.0 as usize] + .motion + .new_path(speed, Some(self.config.movement_easing), None, 0, false, "") + .map_err(EngineError::Other)?; + ctx.terminal.arena[id.0 as usize] + .motion + .paths + .get_mut(&input_coord_path) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + ctx.activate_path(self, id, &input_coord_path); + + let pour_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + let final_colors = self.character_final_color_map.get(&id).unwrap().clone(); + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&pour_scn).unwrap(); + if dynamic { + let final_fg_color = final_colors.fg_color.clone(); + let final_bg_color = final_colors.bg_color.clone(); + let fg_gradient = match &final_fg_color { + Some(c) => Some( + Gradient::with_steps(&[self.config.starting_color.clone(), c.clone()], 10, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &final_bg_color { + Some(c) => Some( + Gradient::with_steps(&[self.config.starting_color.clone(), c.clone()], 10, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + if fg_gradient.is_some() || bg_gradient.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + self.config.final_gradient_frames, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + self.config.final_gradient_frames, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_fg_color = final_colors.fg_color.clone().expect("gradient mapping fg"); + let pour_gradient = Gradient::new( + &[self.config.starting_color.clone(), final_fg_color], + &self.config.final_gradient_steps, + false, + false, + ) + .map_err(EngineError::Other)?; + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + self.config.final_gradient_frames, + Some(&pour_gradient), + None, + ) + .map_err(EngineError::Other)?; + } + } + ctx.activate_scene(self, id, &pour_scn); + } + if i % 2 == 0 { + self.pending_groups.push(group.clone()); + } else { + let mut reversed = group.clone(); + reversed.reverse(); + self.pending_groups.push(reversed); + } + } + self.gap = 0; + self.current_group = self.pending_groups.remove(0); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.pending_groups.is_empty() || !ctx.active_characters.is_empty() || !self.current_group.is_empty() { + if self.current_group.is_empty() && !self.pending_groups.is_empty() { + self.current_group = self.pending_groups.remove(0); + } + if !self.current_group.is_empty() { + if self.gap == 0 { + for _ in 0..self.config.pour_speed { + if !self.current_group.is_empty() { + let next_character = self.current_group.remove(0); + ctx.terminal.set_character_visibility(next_character, true); + ctx.active_characters.insert(next_character); + } + } + self.gap = self.config.gap; + } else { + self.gap -= 1; + } + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/print_effect.rs b/vendor/ttfx/src/effects/print_effect.rs new file mode 100644 index 0000000..bff9f1c --- /dev/null +++ b/vendor/ttfx/src/effects/print_effect.rs @@ -0,0 +1,399 @@ +//! print, ported from effects/effect_print.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_easing, parse_gradient_direction, parse_gradient_steps, parse_positive_float, parse_positive_int, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterGroup}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct PrintConfig { + /// Speed of the print head when performing a carriage return. + #[arg(long = "print-head-return-speed", default_value_t = 1.5, value_parser = parse_positive_float)] + pub print_head_return_speed: f64, + + /// Speed of the print head when printing characters. + #[arg(long = "print-speed", default_value_t = 2, value_parser = parse_positive_int)] + pub print_speed: i64, + + /// Easing function to use for print head movement. + #[arg(long = "print-head-easing", default_value = "in_out_quad", value_parser = parse_easing)] + pub print_head_easing: Easing, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["02b8bd", "c1f0e3", "00ffa0"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +/// PrintIterator.Row (plain struct over CharIds; scene/coord setup happens in +/// Print::make_row so it can borrow the engine). +struct Row { + untyped_chars: Vec, + typed_chars: Vec, +} + +impl Row { + /// Row.move_up. + fn move_up(&self, ctx: &mut EngineCtx) { + for &id in &self.typed_chars { + let motion = &mut ctx.terminal.arena[id.0 as usize].motion; + let current = motion.current_coord; + motion.set_coordinate(Coord::new(current.column, current.row + 1)); + } + } + + /// Row.type_char. + fn type_char(&mut self) -> Option { + if self.untyped_chars.is_empty() { + return None; + } + let next_char = self.untyped_chars.remove(0); + self.typed_chars.push(next_char); + Some(next_char) + } +} + +const SET_INVISIBLE_CALLBACK: u32 = 0; + +pub struct Print { + config: PrintConfig, + pending_rows: Vec, + processed_rows: Vec, + typing_head: CharId, + character_final_color_map: HashMap, + current_row: Row, + typing: bool, + last_column: i64, +} + +impl Print { + pub fn new(config: PrintConfig) -> Self { + Print { + config, + pending_rows: Vec::new(), + processed_rows: Vec::new(), + typing_head: CharId(0), + character_final_color_map: HashMap::new(), + current_row: Row { untyped_chars: Vec::new(), typed_chars: Vec::new() }, + typing: false, + last_column: 0, + } + } + + /// PrintIterator.Row.__init__. + fn make_row(&mut self, ctx: &mut EngineCtx, characters: Vec) -> Result { + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let typing_head_color = Color::from_hex("ffffff").unwrap(); + let all_spaces = + characters.iter().all(|&id| ctx.terminal.arena[id.0 as usize].input_symbol == " "); + let characters: Vec = if all_spaces { + characters.into_iter().take(1).collect() + } else { + let right_extent = characters + .iter() + .copied() + .filter(|&id| !ctx.terminal.arena[id.0 as usize].is_fill_character) + .map(|id| ctx.terminal.arena[id.0 as usize].input_coord.column) + .max() + .expect("row has a non-fill character"); + characters + .into_iter() + .filter(|&id| ctx.terminal.arena[id.0 as usize].input_coord.column <= right_extent) + .collect() + }; + let mut untyped_chars: Vec = Vec::new(); + for id in characters { + let (input_symbol, input_column, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_symbol.clone(), ch.input_coord.column, ch.uses_input_preexisting_colors) + }; + let typed_animation = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.set_coordinate(Coord::new(input_column, 1)); + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + if dynamic { + let final_colors = self.character_final_color_map.get(&id).unwrap().clone(); + let fg_gradient = match &final_colors.fg_color { + Some(fg) => Some( + Gradient::with_steps(&[typing_head_color.clone(), fg.clone()], 5, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &final_colors.bg_color { + Some(bg) => Some( + Gradient::with_steps(&[typing_head_color.clone(), bg.clone()], 5, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + if fg_gradient.is_some() || bg_gradient.is_some() { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&typed_animation) + .unwrap() + .apply_gradient_to_symbols( + &[ + "█".to_string(), + "▓".to_string(), + "▒".to_string(), + "░".to_string(), + input_symbol.clone(), + ], + 3, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + let head_gradient = + Gradient::with_steps(&[typing_head_color.clone(), typing_head_color.clone()], 4, false) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&typed_animation).unwrap(); + scene + .apply_gradient_to_symbols( + &["█".to_string(), "▓".to_string(), "▒".to_string(), "░".to_string()], + 3, + Some(&head_gradient), + None, + ) + .map_err(EngineError::Other)?; + scene + .add_frame( + &input_symbol, + 3, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_fg = self + .character_final_color_map + .get(&id) + .unwrap() + .fg_color + .clone() + .expect("final fg color present"); + let color_gradient = Gradient::with_steps(&[typing_head_color.clone(), final_fg], 5, false) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&typed_animation) + .unwrap() + .apply_gradient_to_symbols( + &[ + "█".to_string(), + "▓".to_string(), + "▒".to_string(), + "░".to_string(), + input_symbol.clone(), + ], + 3, + Some(&color_gradient), + None, + ) + .map_err(EngineError::Other)?; + } + ctx.activate_scene(self, id, &typed_animation); + untyped_chars.push(id); + } + Ok(Row { untyped_chars, typed_chars: Vec::new() }) + } +} + +impl EffectHooks for Print { + fn dispatch_callback(&mut self, ctx: &mut EngineCtx, character: CharId, callback: &EffectCallback) { + if callback.id == SET_INVISIBLE_CALLBACK { + // EventHandler.Callback(self.terminal.set_character_visibility, False) + ctx.terminal.set_character_visibility(character, false); + } + } +} + +impl Effect for Print { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // PrintIterator.__init__: the typing head is added before build(). + self.typing_head = ctx.terminal.add_character("█", Coord::new(1, 1)); + + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let filter = CharacterFilter { inner_fill_chars: true, outer_fill_chars: true, ..Default::default() }; + let characters = ctx.terminal.get_characters( + &mut ctx.rng, + filter, + crate::engine::terminal::CharacterSort::TopToBottomLeftToRight, + ); + for &id in &characters { + let final_colors = { + let ch = &ctx.terminal.arena[id.0 as usize]; + if dynamic { + ColorPair::new(ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone()) + } else { + ColorPair::new( + Some( + final_gradient_mapping + .get(&ch.input_coord) + .cloned() + .unwrap_or_else(|| Color::from_hex("ffffff").unwrap()), + ), + None, + ) + } + }; + self.character_final_color_map.insert(id, final_colors); + } + let input_rows = ctx.terminal.get_characters_grouped(filter, CharacterGroup::RowTopToBottom); + for input_row in input_rows { + let row = self.make_row(ctx, input_row)?; + self.pending_rows.push(row); + } + self.current_row = self.pending_rows.remove(0); + self.typing = true; + self.last_column = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !ctx.active_characters.is_empty() || self.typing { + if ctx.terminal.arena[self.typing_head.0 as usize].motion.active_path.is_some() { + // print head is performing a carriage return + } else if !self.current_row.untyped_chars.is_empty() { + let count = + std::cmp::min(self.current_row.untyped_chars.len() as i64, self.config.print_speed); + for _ in 0..count { + if let Some(next_char) = self.current_row.type_char() { + ctx.terminal.set_character_visibility(next_char, true); + ctx.active_characters.insert(next_char); + self.last_column = ctx.terminal.arena[next_char.0 as usize].input_coord.column; + } + } + } else { + let finished_row = std::mem::replace( + &mut self.current_row, + Row { untyped_chars: Vec::new(), typed_chars: Vec::new() }, + ); + self.processed_rows.push(finished_row); + if !self.pending_rows.is_empty() { + for row in &self.processed_rows { + row.move_up(ctx); + } + self.current_row = self.pending_rows.remove(0); + let last_row_all_fill = self + .processed_rows + .last() + .unwrap() + .typed_chars + .iter() + .all(|&id| ctx.terminal.arena[id.0 as usize].is_fill_character); + let current_row_all_fill = self + .current_row + .untyped_chars + .iter() + .all(|&id| ctx.terminal.arena[id.0 as usize].is_fill_character); + if !last_row_all_fill && !current_row_all_fill { + let left_extent = self + .current_row + .untyped_chars + .iter() + .copied() + .filter(|&id| !ctx.terminal.arena[id.0 as usize].is_fill_character) + .map(|id| ctx.terminal.arena[id.0 as usize].input_coord.column) + .min() + .expect("row has a non-fill character"); + let text_right = ctx.terminal.canvas.text_right; + let arena = &ctx.terminal.arena; + self.current_row.untyped_chars.retain(|&id| { + let column = arena[id.0 as usize].input_coord.column; + left_extent <= column && column <= text_right + }); + } + { + let head = &mut ctx.terminal.arena[self.typing_head.0 as usize]; + head.motion.set_coordinate(Coord::new(self.last_column, 1)); + } + ctx.terminal.set_character_visibility(self.typing_head, true); + let target_column = ctx.terminal.arena + [self.current_row.untyped_chars[0].0 as usize] + .input_coord + .column; + { + let head = &mut ctx.terminal.arena[self.typing_head.0 as usize]; + head.motion.paths.clear(); + let path_id = head + .motion + .new_path( + self.config.print_head_return_speed, + Some(self.config.print_head_easing), + None, + 0, + false, + "carriage_return_path", + ) + .expect("fresh path table"); + head.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(Coord::new(target_column, 1), None, "") + .expect("fresh waypoint"); + } + let typing_head = self.typing_head; + ctx.activate_path(self, typing_head, "carriage_return_path"); + // contextlib.suppress(DuplicateEventRegistrationError): + // the same (event, path-id, callback) tuple re-registers + // every row and is rejected after the first. + let _ = ctx.register_event( + self.typing_head, + Event::PathComplete, + CallerKey::Path("carriage_return_path".to_string()), + EventAction::Callback(EffectCallback { id: SET_INVISIBLE_CALLBACK, args: Vec::new() }), + ); + ctx.active_characters.insert(self.typing_head); + } else { + self.typing = false; + } + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/rain.rs b/vendor/ttfx/src/effects/rain.rs new file mode 100644 index 0000000..57c0b2b --- /dev/null +++ b/vendor/ttfx/src/effects/rain.rs @@ -0,0 +1,251 @@ +//! rain, ported from effects/effect_rain.py. + +use std::collections::{BTreeMap, HashMap}; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_easing, parse_gradient_direction, parse_gradient_steps, parse_positive_float_range, parse_symbol, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct RainConfig { + /// List of colors for the rain drops. Colors are randomly chosen from the list. + #[arg(long = "rain-colors", num_args = 1.., value_parser = parse_color, + default_values = ["00315C", "004C8F", "0075DB", "3F91D9", "78B9F2", "9AC8F5", "B8D8F8", "E3EFFC"])] + pub rain_colors: Vec, + + /// Falling speed range of the rain drops. + #[arg(long = "movement-speed", default_value = "0.33-0.57", value_parser = parse_positive_float_range)] + pub movement_speed: (f64, f64), + + /// Space separated list of symbols to use for the rain drops. Symbols are randomly chosen from the list. + #[arg(long = "rain-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["o", ".", ",", "*", "|"])] + pub rain_symbols: Vec, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["488bff", "b2e7de", "57eaf7"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, + + /// Easing function to use for character movement. + #[arg(long = "movement-easing", default_value = "in_quart", value_parser = parse_easing)] + pub movement_easing: Easing, +} + +pub struct Rain { + config: RainConfig, + pending_chars: Vec, + group_by_row: BTreeMap>, + character_final_color_map: HashMap, +} + +impl Rain { + pub fn new(config: RainConfig) -> Self { + Rain { + config, + pending_chars: Vec::new(), + group_by_row: BTreeMap::new(), + character_final_color_map: HashMap::new(), + } + } +} + +impl EffectHooks for Rain { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Rain { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + let final_colors = { + let ch = &ctx.terminal.arena[id.0 as usize]; + if dynamic { + ColorPair::new(ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone()) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&ch.input_coord).unwrap().clone()), None) + } + }; + self.character_final_color_map.insert(id, final_colors); + } + + let canvas_top = ctx.terminal.canvas.top; + for id in characters { + let (input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_coord, ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + let raindrop_color = ctx.rng.choice(&self.config.rain_colors).clone(); + let rain_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + let rain_symbol = ctx.rng.choice(&self.config.rain_symbols).clone(); + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&rain_scn) + .unwrap() + .add_frame( + &rain_symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(Some(raindrop_color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + let fade_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + let final_colors = self.character_final_color_map.get(&id).unwrap().clone(); + if dynamic { + let fg_gradient = match &final_colors.fg_color { + Some(fg) => Some( + Gradient::with_steps(&[raindrop_color.clone(), fg.clone()], 7, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &final_colors.bg_color { + Some(bg) => Some( + Gradient::with_steps(&[raindrop_color.clone(), bg.clone()], 7, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&fade_scn).unwrap(); + if fg_gradient.is_some() || bg_gradient.is_some() { + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 3, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + scene + .add_frame( + &input_symbol, + 3, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_fg = final_colors.fg_color.clone().expect("gradient mapping fg"); + let raindrop_gradient = Gradient::with_steps(&[raindrop_color.clone(), final_fg], 7, false) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&fade_scn) + .unwrap() + .apply_gradient_to_symbols(&[input_symbol.clone()], 3, Some(&raindrop_gradient), None) + .map_err(EngineError::Other)?; + } + ctx.activate_scene(self, id, &rain_scn); + let speed = ctx.rng.uniform(self.config.movement_speed.0, self.config.movement_speed.1); + let input_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.set_coordinate(Coord::new(input_coord.column, canvas_top)); + let path_id = ch + .motion + .new_path(speed, Some(self.config.movement_easing), None, 0, false, "") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + path_id + }; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(input_path.clone()), + EventAction::ActivateScene(fade_scn), + ) + .map_err(EngineError::Other)?; + ctx.activate_path(self, id, &input_path); + self.pending_chars.push(id); + } + let mut sorted_chars = self.pending_chars.clone(); + sorted_chars.sort_by_key(|&id| ctx.terminal.arena[id.0 as usize].input_coord.row); + for id in sorted_chars { + let row = ctx.terminal.arena[id.0 as usize].input_coord.row; + self.group_by_row.entry(row).or_default().push(id); + } + self.pending_chars.clear(); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.group_by_row.is_empty() || !ctx.active_characters.is_empty() || !self.pending_chars.is_empty() { + if self.pending_chars.is_empty() && !self.group_by_row.is_empty() { + let min_row = *self.group_by_row.keys().next().unwrap(); + let group = self.group_by_row.remove(&min_row).unwrap(); + self.pending_chars.extend(group); + } + if !self.pending_chars.is_empty() { + for _ in 0..ctx.rng.randint(1, 2) { + if self.pending_chars.is_empty() { + break; + } + let index = ctx.rng.randint(0, self.pending_chars.len() as i64 - 1) as usize; + let next_character = self.pending_chars.remove(index); + ctx.terminal.set_character_visibility(next_character, true); + ctx.active_characters.insert(next_character); + } + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/random_sequence.rs b/vendor/ttfx/src/effects/random_sequence.rs new file mode 100644 index 0000000..404bede --- /dev/null +++ b/vendor/ttfx/src/effects/random_sequence.rs @@ -0,0 +1,194 @@ +//! randomsequence, ported from effects/effect_random_sequence.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::effects::common::{parse_gradient_direction, parse_gradient_steps, parse_positive_float}; +use crate::engine::animation::ExistingColorHandling; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +use crate::cli::parse_color; + +#[derive(Args, Debug, Clone)] +pub struct RandomSequenceConfig { + /// Speed of the animation as a percentage of the total number of characters to reveal in each tick. + #[arg(long, default_value_t = 0.007, value_parser = parse_positive_float)] + pub speed: f64, + + /// Space separated, unquoted, list of colors for the final gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Number of frames to display each gradient step. + #[arg(long = "final-gradient-frames", default_value_t = 8)] + pub final_gradient_frames: i64, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct RandomSequence { + config: RandomSequenceConfig, + pending_chars: Vec, + character_final_color_map: HashMap, + characters_per_tick: i64, +} + +const DYNAMIC_NEUTRAL_GRAY: &str = "808080"; + +impl RandomSequence { + pub fn new(config: RandomSequenceConfig) -> Self { + RandomSequence { + config, + pending_chars: Vec::new(), + character_final_color_map: HashMap::new(), + characters_per_tick: 1, + } + } +} + +impl EffectHooks for RandomSequence { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for RandomSequence { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // characters_per_tick = max(int(speed * len(input_characters)), 1) + self.characters_per_tick = + std::cmp::max((self.config.speed * ctx.terminal.input_characters.len() as f64) as i64, 1); + + let terminal_background_color = ctx.terminal.config.terminal_background_color.clone(); + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_fg, input_bg, input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.input_coord, + ch.input_symbol.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors.clone()); + ctx.terminal.set_character_visibility(id, false); + + let scene_id = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + let symbols = vec![input_symbol.clone()]; + let frames = self.config.final_gradient_frames; + { + let scene = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.scenes.get_mut(&scene_id).unwrap() + }; + if dynamic { + let final_fg = final_colors.fg_color.clone(); + let final_bg = final_colors.bg_color.clone(); + if final_fg.is_some() || final_bg.is_some() { + let fg_gradient = match &final_fg { + Some(c) => Some( + Gradient::with_steps(&[terminal_background_color.clone(), c.clone()], 7, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &final_bg { + Some(c) => Some( + Gradient::with_steps(&[terminal_background_color.clone(), c.clone()], 7, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + scene + .apply_gradient_to_symbols(&symbols, frames, fg_gradient.as_ref(), bg_gradient.as_ref()) + .map_err(EngineError::Other)?; + } else { + let neutral = Gradient::with_steps( + &[terminal_background_color.clone(), Color::from_hex(DYNAMIC_NEUTRAL_GRAY).unwrap()], + 7, + false, + ) + .map_err(EngineError::Other)?; + scene + .apply_gradient_to_symbols(&symbols, frames, Some(&neutral), None) + .map_err(EngineError::Other)?; + scene + .add_frame( + &input_symbol, + frames, + crate::engine::animation::VisualParams { + colors: Some(ColorPair::default()), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_fg = final_colors.fg_color.clone().expect("gradient mapping fg"); + let gradient = Gradient::with_steps(&[terminal_background_color.clone(), final_fg], 7, false) + .map_err(EngineError::Other)?; + scene + .apply_gradient_to_symbols(&symbols, frames, Some(&gradient), None) + .map_err(EngineError::Other)?; + } + } + ctx.activate_scene(self, id, &scene_id); + self.pending_chars.push(id); + } + ctx.rng.shuffle(&mut self.pending_chars); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.pending_chars.is_empty() || !ctx.active_characters.is_empty() { + for _ in 0..self.characters_per_tick { + if let Some(next_char) = self.pending_chars.pop() { + ctx.terminal.set_character_visibility(next_char, true); + ctx.active_characters.insert(next_char); + } + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/rings.rs b/vendor/ttfx/src/effects/rings.rs new file mode 100644 index 0000000..56b13c7 --- /dev/null +++ b/vendor/ttfx/src/effects/rings.rs @@ -0,0 +1,590 @@ +//! rings, ported from effects/effect_rings.py. +//! +//! The inner RingsIterator.Ring class is the `Ring` struct; Python Path/Scene +//! object references become path/scene id strings (Path equality is by id). +//! No observable set iteration beyond the engine-canonical active_characters +//! (docs/ordering-inventory.md). + +use std::collections::{HashMap, HashSet}; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_positive_float, parse_positive_float_range, + parse_positive_int, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::{self, Coord}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::pycompat::round_half_even; + +/// Callback id: terminal.set_character_visibility(character, False). +const CB_SET_INVISIBLE: u32 = 0; + +#[derive(Args, Debug, Clone)] +pub struct RingsConfig { + /// Space separated, unquoted, list of colors for the rings. + #[arg(long = "ring-colors", num_args = 1.., value_parser = parse_color, + default_values = ["ab48ff", "e7b2b2", "fffebd"])] + pub ring_colors: Vec, + + /// Distance between rings as a percent of the smallest canvas dimension. + #[arg(long = "ring-gap", default_value_t = 0.1, value_parser = parse_positive_float)] + pub ring_gap: f64, + + /// Number of frames for each cycle of the spin phase. + #[arg(long = "spin-duration", default_value_t = 200, value_parser = parse_positive_int)] + pub spin_duration: i64, + + /// Range of speeds for the rotation of the rings. The speed is randomly selected from this range for each ring. + #[arg(long = "spin-speed", default_value = "0.25-1.0", value_parser = parse_positive_float_range)] + pub spin_speed: (f64, f64), + + /// Number of frames spent in the dispersed state between spinning cycles. + #[arg(long = "disperse-duration", default_value_t = 200, value_parser = parse_positive_int)] + pub disperse_duration: i64, + + /// Number of times the animation will cycles between spinning rings and dispersed characters. + #[arg(long = "spin-disperse-cycles", default_value_t = 3, value_parser = parse_positive_int)] + pub spin_disperse_cycles: i64, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["ab48ff", "e7b2b2", "fffebd"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +/// RingsIterator.Ring. +struct Ring { + #[allow(dead_code)] + radius: i64, + #[allow(dead_code)] + origin: Coord, + counter_clockwise_coords: Vec, + clockwise_coords: Vec, + ring_gap: i64, + ring_color: Color, + characters: Vec, + /// Path ids (upstream holds Path objects; Path equality is by id). + character_last_ring_path: HashMap, + rotation_speed: f64, +} + +impl Ring { + /// Ring.__init__ — consumes one uniform() for rotation_speed. + fn new(ctx: &mut EngineCtx, config: &RingsConfig, radius: i64, origin: Coord, ring_coords: Vec, ring_gap: i64, ring_color: Color) -> Self { + let clockwise_coords: Vec = ring_coords.iter().rev().copied().collect(); + let rotation_speed = ctx.rng.uniform(config.spin_speed.0, config.spin_speed.1); + Ring { + radius, + origin, + counter_clockwise_coords: ring_coords, + clockwise_coords, + ring_gap, + ring_color, + characters: Vec::new(), + character_last_ring_path: HashMap::new(), + rotation_speed, + } + } + + /// Ring.make_disperse_waypoints. + fn make_disperse_waypoints(&self, ctx: &mut EngineCtx, id: CharId, origin: Coord) -> Result { + let disperse_coords = geometry::find_coords_in_rect(origin, self.ring_gap); + let mut waypoint_coords: Vec = Vec::with_capacity(5); + for _ in 0..5 { + let index = ctx.rng.randrange(0, disperse_coords.len() as i64) as usize; + waypoint_coords.push(disperse_coords[index]); + } + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.paths.remove("disperse"); + let path_id = ch + .motion + .new_path(0.14, None, None, 0, true, "disperse") + .map_err(EngineError::Other)?; + let path = ch.motion.paths.get_mut(&path_id).unwrap(); + for coord in waypoint_coords { + path.new_waypoint(coord, None, "").map_err(EngineError::Other)?; + } + Ok(path_id) + } +} + +/// dict phase strings from RingsIterator.__next__. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + Start, + Disperse, + Spin, + Final, + Complete, +} + +pub struct Rings { + config: RingsConfig, + rings: Vec, + non_ring_chars: Vec, + character_final_color_map: HashMap, + phase: Phase, + initial_disperse_complete: bool, + spin_time_remaining: i64, + disperse_time_remaining: i64, + cycles_remaining: i64, + initial_phase_time_remaining: i64, +} + +impl Rings { + pub fn new(config: RingsConfig) -> Self { + let spin_time_remaining = config.spin_duration; + let disperse_time_remaining = config.disperse_duration; + let cycles_remaining = config.spin_disperse_cycles; + Rings { + config, + rings: Vec::new(), + non_ring_chars: Vec::new(), + character_final_color_map: HashMap::new(), + phase: Phase::Start, + initial_disperse_complete: false, + spin_time_remaining, + disperse_time_remaining, + cycles_remaining, + initial_phase_time_remaining: 100, + } + } + + /// Ring.add_character (needs the effect-level color map, so lives here). + fn ring_add_character( + &mut self, + ctx: &mut EngineCtx, + ring: &mut Ring, + id: CharId, + clockwise: bool, + ) -> Result<(), EngineError> { + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let (input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + // make gradient scene + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let gradient_scn = ch.animation.new_scene(false, None, None, "gradient", uses_pre); + let scene = ch.animation.scenes.get_mut(&gradient_scn).unwrap(); + if dynamic { + let colors = self.character_final_color_map.get(&id).unwrap().clone(); + scene + .add_frame(&input_symbol, 1, VisualParams { colors: Some(colors), ..Default::default() }) + .map_err(EngineError::Other)?; + } else { + let final_fg_color = self + .character_final_color_map + .get(&id) + .unwrap() + .fg_color + .clone() + .expect("gradient mapping fg"); + let char_gradient = Gradient::with_steps(&[final_fg_color, ring.ring_color.clone()], 8, false) + .map_err(EngineError::Other)?; + scene + .apply_gradient_to_symbols(&[input_symbol.clone()], 3, Some(&char_gradient), None) + .map_err(EngineError::Other)?; + } + } + + // make rotation waypoints + let mut ring_paths: Vec = Vec::new(); + let character_starting_index = ring.characters.len(); + let coords = if clockwise { &ring.clockwise_coords } else { &ring.counter_clockwise_coords }; + let rotated: Vec = coords[character_starting_index..] + .iter() + .chain(coords[..character_starting_index].iter()) + .copied() + .collect(); + for coord in rotated { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let path_id = ch + .motion + .new_path(ring.rotation_speed, None, None, 0, false, &ring_paths.len().to_string()) + .map_err(EngineError::Other)?; + let path = ch.motion.paths.get_mut(&path_id).unwrap(); + let waypoint_id = path.waypoints.len().to_string(); + path.new_waypoint(coord, None, &waypoint_id).map_err(EngineError::Other)?; + ring_paths.push(path_id); + } + ring.character_last_ring_path.insert(id, ring_paths[0].clone()); + + // make disperse scene + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let disperse_scn = ch.animation.new_scene(false, None, None, "disperse", uses_pre); + let scene = ch.animation.scenes.get_mut(&disperse_scn).unwrap(); + if dynamic { + let colors = self.character_final_color_map.get(&id).unwrap().clone(); + scene + .add_frame(&input_symbol, 1, VisualParams { colors: Some(colors), ..Default::default() }) + .map_err(EngineError::Other)?; + } else { + let final_fg_color = self + .character_final_color_map + .get(&id) + .unwrap() + .fg_color + .clone() + .expect("gradient mapping fg"); + let disperse_gradient = Gradient::with_steps(&[ring.ring_color.clone(), final_fg_color], 8, false) + .map_err(EngineError::Other)?; + scene + .apply_gradient_to_symbols(&[input_symbol.clone()], 10, Some(&disperse_gradient), None) + .map_err(EngineError::Other)?; + } + } + ctx.chain_paths(id, &ring_paths, true).map_err(EngineError::Other)?; + ring.characters.push(id); + Ok(()) + } + + /// Ring.disperse. + fn ring_disperse(&mut self, ctx: &mut EngineCtx, ring: &mut Ring) -> Result<(), EngineError> { + let characters = ring.characters.clone(); + for id in characters { + let (active_path, current_coord) = { + let motion = &ctx.terminal.arena[id.0 as usize].motion; + (motion.active_path.clone(), motion.current_coord) + }; + let last = match active_path { + Some(path_id) => path_id.to_string(), + None => "0".to_string(), + }; + ring.character_last_ring_path.insert(id, last); + let disperse_path = ring.make_disperse_waypoints(ctx, id, current_coord)?; + ctx.activate_path(self, id, &disperse_path); + ctx.activate_scene(self, id, "disperse"); + } + Ok(()) + } + + /// Ring.spin. + fn ring_spin(&mut self, ctx: &mut EngineCtx, ring: &mut Ring) -> Result<(), EngineError> { + let characters = ring.characters.clone(); + for id in characters { + let last_ring_path = ring.character_last_ring_path.get(&id).unwrap().clone(); + let condense_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let first_waypoint_coord = ch + .motion + .paths + .get(&last_ring_path) + .expect("last ring path missing") + .waypoints[0] + .coord; + let path_id = ch.motion.new_path(0.1, None, None, 0, false, "").map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(first_waypoint_coord, None, "") + .map_err(EngineError::Other)?; + path_id + }; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(condense_path.clone()), + EventAction::ActivatePath(last_ring_path), + ) + .map_err(EngineError::Other)?; + ctx.activate_path(self, id, &condense_path); + ctx.activate_scene(self, id, "gradient"); + } + Ok(()) + } +} + +impl EffectHooks for Rings { + fn dispatch_callback(&mut self, ctx: &mut EngineCtx, character: CharId, callback: &EffectCallback) { + if callback.id == CB_SET_INVISIBLE { + ctx.terminal.set_character_visibility(character, false); + } + } +} + +impl Effect for Rings { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // ring_gap = int(max(round(min(top, right) * config.ring_gap), 1)) + let ring_gap = std::cmp::max( + round_half_even( + std::cmp::min(ctx.terminal.canvas.top, ctx.terminal.canvas.right) as f64 * self.config.ring_gap, + ), + 1, + ); + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + let mut pending_chars: Vec = Vec::new(); + for id in characters { + let (input_coord, input_symbol, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_coord, + ch.input_symbol.clone(), + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors.clone()); + let start_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .add_frame(&input_symbol, 1, VisualParams { colors: Some(final_colors), ..Default::default() }) + .map_err(EngineError::Other)?; + scene_id + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let home_path = ch + .motion + .new_path(0.8, Some(Easing::OutQuad), None, 0, false, "home") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&home_path) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + } + ctx.activate_scene(self, id, &start_scn); + ctx.terminal.set_character_visibility(id, true); + pending_chars.push(id); + } + + ctx.rng.shuffle(&mut pending_chars); + + // make rings + let mut rings: Vec = Vec::new(); + let center = ctx.terminal.canvas.center; + let radius_limit = std::cmp::max(ctx.terminal.canvas.right, ctx.terminal.canvas.top); + let mut radius = 1; + while radius < radius_limit { + let ring_coords = geometry::find_coords_on_circle(center, radius, 7 * radius, true); + // check if any part of the ring is in the canvas, if not, stop creating rings + let in_canvas_count = + ring_coords.iter().filter(|&&coord| ctx.terminal.canvas.coord_is_in_canvas(coord)).count(); + if (in_canvas_count as f64) / (ring_coords.len() as f64) < 0.25 { + break; + } + let ring_color = self.config.ring_colors[rings.len() % self.config.ring_colors.len()].clone(); + let ring = Ring::new(ctx, &self.config, radius, center, ring_coords, ring_gap, ring_color); + rings.push(ring); + radius += ring_gap; + } + + // assign characters to rings + let mut pending_iter = pending_chars.into_iter().collect::>(); + let mut ring_chars: HashSet = HashSet::new(); + for (ring_count, ring) in rings.iter_mut().enumerate() { + for _ in 0..ring.counter_clockwise_coords.len() { + if let Some(next_character) = pending_iter.pop_front() { + // set rings to rotate in opposite directions + let clockwise = ring_count % 2 == 1; + self.ring_add_character(ctx, ring, next_character, clockwise)?; + ring_chars.insert(next_character); + } + } + } + self.rings = rings; + + // make external waypoints for characters not in rings + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + if ring_chars.contains(&id) { + continue; + } + let external_coord = ctx.terminal.canvas.random_coord(&mut ctx.rng, true, false); + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let external_path = ch + .motion + .new_path(0.8, Some(Easing::OutSine), None, 0, false, "external") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&external_path) + .unwrap() + .new_waypoint(external_coord, None, "") + .map_err(EngineError::Other)?; + } + self.non_ring_chars.push(id); + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path("external".to_string()), + EventAction::Callback(EffectCallback { id: CB_SET_INVISIBLE, args: Vec::new() }), + ) + .map_err(EngineError::Other)?; + } + self.phase = Phase::Start; + self.initial_disperse_complete = false; + self.spin_time_remaining = self.config.spin_duration; + self.disperse_time_remaining = self.config.disperse_duration; + self.cycles_remaining = self.config.spin_disperse_cycles; + self.initial_phase_time_remaining = 100; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.phase == Phase::Complete { + return None; + } + match self.phase { + Phase::Start => { + if self.initial_phase_time_remaining == 0 { + self.phase = Phase::Disperse; + } else { + self.initial_phase_time_remaining -= 1; + } + } + Phase::Disperse => { + if !self.initial_disperse_complete { + self.initial_disperse_complete = true; + let mut rings = std::mem::take(&mut self.rings); + for ring in &mut rings { + let characters = ring.characters.clone(); + for id in characters { + let ring_start_coord = + ctx.terminal.arena[id.0 as usize].motion.paths.get("0").unwrap().waypoints[0].coord; + let disperse_path = ring + .make_disperse_waypoints(ctx, id, ring_start_coord) + .expect("make_disperse_waypoints failed"); + let initial_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let disperse_first_coord = + ch.motion.paths.get(&disperse_path).unwrap().waypoints[0].coord; + let path_id = ch + .motion + .new_path(0.3, Some(Easing::OutCubic), None, 0, false, "") + .expect("new_path failed"); + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(disperse_first_coord, None, "") + .expect("new_waypoint failed"); + path_id + }; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(initial_path.clone()), + EventAction::ActivatePath(disperse_path), + ) + .expect("register_event failed"); + ctx.activate_scene(self, id, "disperse"); + ctx.activate_path(self, id, &initial_path); + ctx.active_characters.insert(id); + } + } + self.rings = rings; + + let non_ring_chars = self.non_ring_chars.clone(); + for id in non_ring_chars { + ctx.activate_path(self, id, "external"); + ctx.active_characters.insert(id); + } + } else if self.disperse_time_remaining == 0 { + self.phase = Phase::Spin; + self.cycles_remaining -= 1; + self.spin_time_remaining = self.config.spin_duration; + let mut rings = std::mem::take(&mut self.rings); + for ring in &mut rings { + self.ring_spin(ctx, ring).expect("ring spin failed"); + } + self.rings = rings; + } else { + self.disperse_time_remaining -= 1; + } + } + Phase::Spin => { + if self.spin_time_remaining == 0 { + if self.cycles_remaining == 0 { + self.phase = Phase::Final; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + ctx.terminal.set_character_visibility(id, true); + ctx.activate_path(self, id, "home"); + ctx.active_characters.insert(id); + if ctx.terminal.arena[id.0 as usize].motion.paths.contains_key("external") { + continue; + } + ctx.activate_scene(self, id, "disperse"); + } + } else { + self.disperse_time_remaining = self.config.disperse_duration; + let mut rings = std::mem::take(&mut self.rings); + for ring in &mut rings { + self.ring_disperse(ctx, ring).expect("ring disperse failed"); + } + self.rings = rings; + self.phase = Phase::Disperse; + } + } else { + self.spin_time_remaining -= 1; + } + } + Phase::Final => { + if ctx.active_characters.is_empty() { + self.phase = Phase::Complete; + } + } + Phase::Complete => unreachable!(), + } + ctx.update(self); + Some(ctx.frame()) + } +} diff --git a/vendor/ttfx/src/effects/scattered.rs b/vendor/ttfx/src/effects/scattered.rs new file mode 100644 index 0000000..8bfe751 --- /dev/null +++ b/vendor/ttfx/src/effects/scattered.rs @@ -0,0 +1,194 @@ +//! scattered, ported from effects/effect_scattered.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_easing, parse_gradient_direction, parse_gradient_steps, parse_positive_float}; +use crate::engine::animation::{ExistingColorHandling, SyncMetric, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct ScatteredConfig { + /// Movement speed of the characters. + #[arg(long = "movement-speed", default_value_t = 0.5, value_parser = parse_positive_float)] + pub movement_speed: f64, + + /// Easing function to use for character movement. + #[arg(long = "movement-easing", default_value = "in_out_back", value_parser = parse_easing)] + pub movement_easing: Easing, + + /// Space separated, unquoted, list of colors for the character gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["ff9048", "ab9dff", "bdffea"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Number of frames to display each gradient step. + #[arg(long = "final-gradient-frames", default_value_t = 9)] + pub final_gradient_frames: i64, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Scattered { + config: ScatteredConfig, + pending_chars: Vec, + character_final_color_map: HashMap, + initial_hold_frames: i64, +} + +impl Scattered { + pub fn new(config: ScatteredConfig) -> Self { + Scattered { + config, + pending_chars: Vec::new(), + character_final_color_map: HashMap::new(), + initial_hold_frames: 0, + } + } +} + +impl EffectHooks for Scattered { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Scattered { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_fg, input_bg, input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.input_coord, + ch.input_symbol.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors.clone()); + let start_coord = if ctx.terminal.canvas.right < 2 || ctx.terminal.canvas.top < 2 { + Coord::new(1, 1) + } else { + ctx.terminal.canvas.random_coord(&mut ctx.rng, false, false) + }; + let input_coord_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.set_coordinate(start_coord); + let path_id = ch + .motion + .new_path(self.config.movement_speed, Some(self.config.movement_easing), None, 0, false, "") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + path_id + }; + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path(input_coord_path.clone()), + EventAction::SetLayer(1), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(input_coord_path.clone()), + EventAction::SetLayer(0), + ) + .map_err(EngineError::Other)?; + ctx.activate_path(self, id, &input_coord_path); + ctx.terminal.set_character_visibility(id, true); + let gradient_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, Some(SyncMetric::Distance), None, "", uses_pre) + }; + { + let scene = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.scenes.get_mut(&gradient_scn).unwrap() + }; + if dynamic { + scene + .add_frame( + &input_symbol, + self.config.final_gradient_frames, + VisualParams { colors: Some(final_colors.clone()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } else { + let final_fg_color = final_colors.fg_color.clone().expect("gradient mapping fg"); + let char_gradient = + Gradient::with_steps(&[final_gradient.spectrum[0].clone(), final_fg_color], 10, false) + .map_err(EngineError::Other)?; + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + self.config.final_gradient_frames, + Some(&char_gradient), + None, + ) + .map_err(EngineError::Other)?; + } + } + ctx.activate_scene(self, id, &gradient_scn); + ctx.active_characters.insert(id); + } + self.initial_hold_frames = 25; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.pending_chars.is_empty() || !ctx.active_characters.is_empty() { + if self.initial_hold_frames != 0 { + self.initial_hold_frames -= 1; + return Some(ctx.frame()); + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/slice.rs b/vendor/ttfx/src/effects/slice.rs new file mode 100644 index 0000000..a2823e2 --- /dev/null +++ b/vendor/ttfx/src/effects/slice.rs @@ -0,0 +1,267 @@ +//! slice, ported from effects/effect_slice.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_easing, parse_gradient_direction, parse_gradient_steps, parse_positive_float}; +use crate::engine::animation::ExistingColorHandling; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct SliceConfig { + /// Direction of the slice. + #[arg(long = "slice-direction", default_value = "vertical", + value_parser = ["vertical", "horizontal", "diagonal"])] + pub slice_direction: String, + + /// Movement speed of the characters. + #[arg(long = "movement-speed", default_value_t = 0.25, value_parser = parse_positive_float)] + pub movement_speed: f64, + + /// Easing function to use for character movement. + #[arg(long = "movement-easing", default_value = "in_out_expo", value_parser = parse_easing)] + pub movement_easing: Easing, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Slice { + config: SliceConfig, + character_final_color_map: HashMap, +} + +impl Slice { + pub fn new(config: SliceConfig) -> Self { + Slice { config, character_final_color_map: HashMap::new() } + } +} + +impl EffectHooks for Slice { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Slice { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_fg, input_bg, input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.input_coord, + ch.input_symbol.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors.clone()); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.set_appearance(&input_symbol, uses_pre, Some(&input_symbol), Some(final_colors)); + } + + // Per-character path setup shared by every branch: set the origin + // coordinate, create the input-coord path, and activate it. + macro_rules! send_to { + ($id:expr, $origin:expr) => {{ + let id = $id; + let origin: Coord = $origin; + let input_coord = ctx.terminal.arena[id.0 as usize].input_coord; + let input_coord_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.set_coordinate(origin); + let path_id = ch + .motion + .new_path(self.config.movement_speed, Some(self.config.movement_easing), None, 0, false, "") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + path_id + }; + ctx.activate_path(self, id, &input_coord_path); + }}; + } + + let canvas_top = ctx.terminal.canvas.top; + let canvas_bottom = ctx.terminal.canvas.bottom; + let canvas_left = ctx.terminal.canvas.left; + let canvas_right = ctx.terminal.canvas.right; + let text_center_column = ctx.terminal.canvas.text_center_column; + let text_center_row = ctx.terminal.canvas.text_center_row; + let text_left = ctx.terminal.canvas.text_left; + let text_right = ctx.terminal.canvas.text_right; + let text_top = ctx.terminal.canvas.text_top; + let text_bottom = ctx.terminal.canvas.text_bottom; + + if self.config.slice_direction == "vertical" { + let rows = + ctx.terminal.get_characters_grouped(CharacterFilter::default(), CharacterGroup::RowBottomToTop); + for row_index in 0..rows.len() { + let row = &rows[row_index]; + let left_half: Vec = row + .iter() + .copied() + .filter(|&id| ctx.terminal.arena[id.0 as usize].input_coord.column <= text_center_column) + .collect(); + for id in &left_half { + let column = ctx.terminal.arena[id.0 as usize].input_coord.column; + send_to!(*id, Coord::new(column, canvas_top + 1)); + } + let opposite_row = &rows[rows.len() - (row_index + 1)]; + let right_half: Vec = opposite_row + .iter() + .copied() + .filter(|&id| ctx.terminal.arena[id.0 as usize].input_coord.column > text_center_column) + .collect(); + for id in &right_half { + let column = ctx.terminal.arena[id.0 as usize].input_coord.column; + send_to!(*id, Coord::new(column, canvas_bottom - 1)); + } + ctx.active_characters.extend(left_half); + ctx.active_characters.extend(right_half); + } + } else if self.config.slice_direction == "horizontal" { + self.config.movement_speed *= 2.0; + let columns = ctx.terminal.get_characters_grouped( + CharacterFilter { + input_chars: true, + inner_fill_chars: true, + outer_fill_chars: true, + added_chars: false, + }, + CharacterGroup::ColumnRightToLeft, + ); + let mut trimmed_columns: Vec> = Vec::new(); + for column in &columns { + let new_column: Vec = column + .iter() + .copied() + .filter(|&id| { + let c = ctx.terminal.arena[id.0 as usize].input_coord; + (text_left <= c.column && c.column <= text_right) + && (text_bottom <= c.row && c.row <= text_top) + }) + .collect(); + if !new_column.is_empty() { + trimmed_columns.push(new_column); + } + } + let columns = trimmed_columns; + let mid_point = text_center_row; + for column_index in 0..columns.len() { + let column = &columns[column_index]; + let bottom_half: Vec = column + .iter() + .copied() + .filter(|&id| ctx.terminal.arena[id.0 as usize].input_coord.row <= mid_point) + .collect(); + for id in &bottom_half { + let row = ctx.terminal.arena[id.0 as usize].input_coord.row; + send_to!(*id, Coord::new(canvas_left - 1, row)); + } + let opposite_column = &columns[columns.len() - (column_index + 1)]; + let top_half: Vec = opposite_column + .iter() + .copied() + .filter(|&id| ctx.terminal.arena[id.0 as usize].input_coord.row > mid_point) + .collect(); + for id in &top_half { + let row = ctx.terminal.arena[id.0 as usize].input_coord.row; + send_to!(*id, Coord::new(canvas_right + 1, row)); + } + ctx.active_characters.extend(bottom_half); + ctx.active_characters.extend(top_half); + } + } else if self.config.slice_direction == "diagonal" { + let diagonals = ctx + .terminal + .get_characters_grouped(CharacterFilter::default(), CharacterGroup::DiagonalBottomLeftToTopRight); + let mut left: Vec> = diagonals[..diagonals.len() / 2].to_vec(); + let mut right: Vec> = diagonals[diagonals.len() / 2..].to_vec(); + while !left.is_empty() || !right.is_empty() { + if !left.is_empty() { + let left_group = left.remove(0); + let origin_coord = Coord::new( + ctx.terminal.arena[left_group[0].0 as usize].input_coord.column, + canvas_bottom - 1, + ); + for id in &left_group { + send_to!(*id, origin_coord); + } + ctx.active_characters.extend(left_group); + } + if !right.is_empty() { + let right_group = right.remove(0); + let origin_coord = Coord::new( + ctx.terminal.arena[right_group[right_group.len() - 1].0 as usize].input_coord.column, + canvas_top + 1, + ); + for id in &right_group { + send_to!(*id, origin_coord); + } + ctx.active_characters.extend(right_group); + } + } + } + let active: Vec = ctx.active_characters.iter().collect(); + for id in active { + ctx.terminal.set_character_visibility(id, true); + } + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !ctx.active_characters.is_empty() { + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/slide.rs b/vendor/ttfx/src/effects/slide.rs new file mode 100644 index 0000000..dfec25d --- /dev/null +++ b/vendor/ttfx/src/effects/slide.rs @@ -0,0 +1,309 @@ +//! slide, ported from effects/effect_slide.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_easing, parse_gradient_direction, parse_gradient_steps, parse_non_negative_int, parse_positive_float, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +/// typing.Literal["row", "column", "diagonal"]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SlideGrouping { + Row, + Column, + Diagonal, +} + +fn parse_slide_grouping(s: &str) -> Result { + Ok(match s { + "row" => SlideGrouping::Row, + "column" => SlideGrouping::Column, + "diagonal" => SlideGrouping::Diagonal, + _ => return Err(format!("invalid choice: '{s}' (choose from 'row', 'column', 'diagonal')")), + }) +} + +#[derive(Args, Debug, Clone)] +pub struct SlideConfig { + /// Speed of the characters. + #[arg(long = "movement-speed", default_value_t = 0.8, value_parser = parse_positive_float)] + pub movement_speed: f64, + + /// Direction to group characters. + #[arg(long = "grouping", default_value = "row", value_parser = parse_slide_grouping)] + pub grouping: SlideGrouping, + + /// Number of frames to wait before adding the next group of characters. + #[arg(long = "gap", default_value_t = 2, value_parser = parse_non_negative_int)] + pub gap: i64, + + /// Reverse the direction of the characters. + #[arg(long = "reverse-direction")] + pub reverse_direction: bool, + + /// Merge the character groups originating from either side of the terminal. + #[arg(long = "merge")] + pub merge: bool, + + /// Easing function to use for character movement. + #[arg(long = "movement-easing", default_value = "in_out_quad", value_parser = parse_easing)] + pub movement_easing: Easing, + + /// Space separated, unquoted, list of colors for the character gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["833ab4", "fd1d1d", "fcb045"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Number of frames to display each gradient step. + #[arg(long = "final-gradient-frames", default_value_t = 6)] + pub final_gradient_frames: i64, + + /// Direction of the gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Slide { + config: SlideConfig, + pending_groups: Vec>, + character_final_color_map: HashMap, + active_groups: Vec>, + current_gap: i64, +} + +impl Slide { + pub fn new(config: SlideConfig) -> Self { + Slide { + config, + pending_groups: Vec::new(), + character_final_color_map: HashMap::new(), + active_groups: Vec::new(), + current_gap: 0, + } + } +} + +impl EffectHooks for Slide { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Slide { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_fg, input_bg, input_coord) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone(), ch.input_coord) + }; + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors); + } + + let grouping = match self.config.grouping { + SlideGrouping::Row => CharacterGroup::RowTopToBottom, + SlideGrouping::Column => CharacterGroup::ColumnLeftToRight, + SlideGrouping::Diagonal => CharacterGroup::DiagonalTopLeftToBottomRight, + }; + let mut groups = ctx.terminal.get_characters_grouped(CharacterFilter::default(), grouping); + for group in &groups { + for &id in group { + let input_coord = ctx.terminal.arena[id.0 as usize].input_coord; + let motion = &mut ctx.terminal.arena[id.0 as usize].motion; + motion + .new_path(self.config.movement_speed, Some(self.config.movement_easing), None, 0, false, "input_path") + .map_err(EngineError::Other)?; + motion + .paths + .get_mut("input_path") + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + } + } + + for group_index in 0..groups.len() { + // Python's loop variable `group` keeps the original (pre-reversal) list. + let group = groups[group_index].clone(); + match self.config.grouping { + SlideGrouping::Row => { + let starting_column; + if self.config.merge && group_index % 2 == 0 { + starting_column = ctx.terminal.canvas.right + 1; + } else { + groups[group_index].reverse(); + starting_column = ctx.terminal.canvas.left - 1; + } + let starting_column = if self.config.reverse_direction && !self.config.merge { + groups[group_index].reverse(); + ctx.terminal.canvas.right + 1 + } else { + starting_column + }; + for i in 0..groups[group_index].len() { + let id = groups[group_index][i]; + let row = ctx.terminal.arena[id.0 as usize].input_coord.row; + ctx.terminal.arena[id.0 as usize].motion.set_coordinate(Coord::new(starting_column, row)); + } + } + SlideGrouping::Column => { + let starting_row; + if self.config.merge && group_index % 2 == 0 { + starting_row = ctx.terminal.canvas.bottom - 1; + } else { + groups[group_index].reverse(); + starting_row = ctx.terminal.canvas.top + 1; + } + let starting_row = if self.config.reverse_direction && !self.config.merge { + groups[group_index].reverse(); + ctx.terminal.canvas.bottom - 1 + } else { + starting_row + }; + for i in 0..groups[group_index].len() { + let id = groups[group_index][i]; + let column = ctx.terminal.arena[id.0 as usize].input_coord.column; + ctx.terminal.arena[id.0 as usize].motion.set_coordinate(Coord::new(column, starting_row)); + } + } + SlideGrouping::Diagonal => {} + } + if self.config.grouping == SlideGrouping::Diagonal { + let last_coord = ctx.terminal.arena[group.last().unwrap().0 as usize].input_coord; + let distance_from_outside_bottom = last_coord.row - (ctx.terminal.canvas.bottom - 1); + let mut starting_coord = Coord::new( + last_coord.column - distance_from_outside_bottom, + last_coord.row - distance_from_outside_bottom, + ); + if self.config.merge && group_index % 2 == 0 { + groups[group_index].reverse(); + let first_coord = ctx.terminal.arena[group[0].0 as usize].input_coord; + let distance_from_outside = (ctx.terminal.canvas.top + 1) - first_coord.row; + starting_coord = Coord::new( + first_coord.column + distance_from_outside, + first_coord.row + distance_from_outside, + ); + } + if self.config.reverse_direction && !self.config.merge { + groups[group_index].reverse(); + let first_coord = ctx.terminal.arena[group[0].0 as usize].input_coord; + let distance_from_outside = (ctx.terminal.canvas.top + 1) - first_coord.row; + starting_coord = Coord::new( + first_coord.column + distance_from_outside, + first_coord.row + distance_from_outside, + ); + } + for i in 0..groups[group_index].len() { + let id = groups[group_index][i]; + ctx.terminal.arena[id.0 as usize].motion.set_coordinate(starting_coord); + } + } + for &id in &group { + let (input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + let final_colors = self.character_final_color_map.get(&id).unwrap().clone(); + let gradient_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&gradient_scn).unwrap(); + if dynamic { + scene + .add_frame( + &input_symbol, + self.config.final_gradient_frames, + VisualParams { colors: Some(final_colors), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } else { + let final_fg_color = final_colors.fg_color.clone().expect("gradient mapping fg"); + let char_gradient = Gradient::with_steps( + &[self.config.final_gradient_stops[0].clone(), final_fg_color], + 10, + false, + ) + .map_err(EngineError::Other)?; + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + self.config.final_gradient_frames, + Some(&char_gradient), + None, + ) + .map_err(EngineError::Other)?; + } + } + ctx.activate_scene(self, id, &gradient_scn); + } + } + + self.pending_groups = groups; + self.active_groups = Vec::new(); + self.current_gap = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.pending_groups.is_empty() || !ctx.active_characters.is_empty() || !self.active_groups.is_empty() { + if self.current_gap == self.config.gap && !self.pending_groups.is_empty() { + self.active_groups.push(self.pending_groups.remove(0)); + self.current_gap = 0; + } else if !self.pending_groups.is_empty() { + self.current_gap += 1; + } + for group_index in 0..self.active_groups.len() { + if !self.active_groups[group_index].is_empty() { + let next_char = self.active_groups[group_index].remove(0); + ctx.terminal.set_character_visibility(next_char, true); + ctx.activate_path(self, next_char, "input_path"); + ctx.active_characters.insert(next_char); + } + } + self.active_groups.retain(|group| !group.is_empty()); + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/smoke.rs b/vendor/ttfx/src/effects/smoke.rs new file mode 100644 index 0000000..5761b4a --- /dev/null +++ b/vendor/ttfx/src/effects/smoke.rs @@ -0,0 +1,264 @@ +//! smoke, ported from effects/effect_smoke.py. +//! +//! RNG order mirrors SmokeIterator.__init__: PrimsWeighted construction +//! (random starting coord + one randint(0, 99) weight per character over +//! get_characters(inner+outer fill)), then the BreadthFirst starting-coord +//! draw, then build() (PrimsWeighted run to completion). __next__ consumes no +//! RNG. BreadthFirst's links-set traversal is the canonical ascending +//! character_id order (shim-patched; docs/ordering-inventory.md) — no new +//! observable set iterations in this effect. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_gradient_direction, parse_gradient_steps, parse_symbol}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::spanning_tree::{BreadthFirst, PrimsWeighted}; + +#[derive(Args, Debug, Clone)] +pub struct SmokeConfig { + /// Color of the text before being colorized by the smoke. + #[arg(long = "starting-color", default_value = "7A7A7A", value_parser = parse_color)] + pub starting_color: Color, + + /// Symbols to use for the smoke. Strings will be used in sequence to create an animation. + #[arg(long = "smoke-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["░", "▒", "▓", "▒", "░"])] + pub smoke_symbols: Vec, + + /// Space separated, unquoted, list of colors for the smoke gradient. + #[arg(long = "smoke-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["242424", "FFFFFF"])] + pub smoke_gradient_stops: Vec, + + /// If True, the entire canvas will be flooded. Otherwise the effect is limited to the text boundary. + #[arg(long = "use-whole-canvas", default_value_t = false)] + pub use_whole_canvas: bool, + + /// Space separated, unquoted, list of colors for the character gradient (applied across the canvas). + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Smoke { + config: SmokeConfig, + character_final_color_map: HashMap, + /// Option so next_frame can move it out of self while stepping needs ctx + /// and event dispatch needs &mut self. + fill_alg: Option, +} + +impl Smoke { + pub fn new(config: SmokeConfig) -> Self { + Smoke { config, character_final_color_map: HashMap::new(), fill_alg: None } + } +} + +impl EffectHooks for Smoke { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Smoke { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // SmokeIterator.__init__ order: PrimsWeighted (random starting coord + + // per-character weights), then the BreadthFirst starting character + // (random coord lookup; a failed lookup falls back to BreadthFirst's + // own draw, exactly like Python's `starting_char or ...`). + let limit_to_text_boundary = !self.config.use_whole_canvas; + let mut gen_alg = PrimsWeighted::new(ctx, None, limit_to_text_boundary).map_err(EngineError::Other)?; + let fill_start_coord = ctx.terminal.canvas.random_coord(&mut ctx.rng, false, limit_to_text_boundary); + let fill_start_char = ctx.terminal.get_character_by_input_coord(fill_start_coord); + let fill_alg = + BreadthFirst::new(ctx, fill_start_char, limit_to_text_boundary).map_err(EngineError::Other)?; + + // SmokeIterator.build() + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let blk = Color::from_hex("000000").unwrap(); + // Gradient(*smoke_gradient_stops, *final_gradient_stops[::-1], steps=(3, 4)) + let smoke_gradient_colors: Vec = self + .config + .smoke_gradient_stops + .iter() + .chain(self.config.final_gradient_stops.iter().rev()) + .cloned() + .collect(); + let smoke_gradient = + Gradient::new(&smoke_gradient_colors, &[3, 4], false, false).map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter { + input_chars: true, + inner_fill_chars: true, + outer_fill_chars: true, + added_chars: false, + }; + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + ctx.terminal.set_character_visibility(id, true); + let (input_coord, input_symbol, input_fg, input_bg, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_coord, + ch.input_symbol.clone(), + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let base_colors = if dynamic { + self.character_final_color_map.insert(id, ColorPair::new(input_fg, input_bg)); + ColorPair::new(Some(blk.clone()), None) + } else { + self.character_final_color_map.insert( + id, + ColorPair::new( + Some(final_gradient_mapping.get(&input_coord).cloned().unwrap_or_else(|| blk.clone())), + None, + ), + ); + ColorPair::new(Some(self.config.starting_color.clone()), None) + }; + let paint_chars = [input_symbol.clone()]; + let paint_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "paint", uses_pre) + }; + if dynamic { + let colors = self.character_final_color_map.get(&id).unwrap().clone(); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&paint_scn) + .unwrap() + .add_frame(&input_symbol, 5, VisualParams { colors: Some(colors), ..Default::default() }) + .map_err(EngineError::Other)?; + } else { + let final_fg_color = self + .character_final_color_map + .get(&id) + .unwrap() + .fg_color + .clone() + .expect("final fg color"); + // Gradient(*final_gradient_stops, final_fg_color, steps=5) + let paint_stops: Vec = self + .config + .final_gradient_stops + .iter() + .cloned() + .chain(std::iter::once(final_fg_color)) + .collect(); + let paint_gradient = Gradient::with_steps(&paint_stops, 5, false).map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&paint_scn) + .unwrap() + .apply_gradient_to_symbols(&paint_chars, 5, Some(&paint_gradient), None) + .map_err(EngineError::Other)?; + } + + let smoke_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "smoke", uses_pre) + }; + if dynamic { + let colors = self.character_final_color_map.get(&id).unwrap().clone(); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&smoke_scn).unwrap(); + for smoke_symbol in &self.config.smoke_symbols { + scene + .add_frame( + smoke_symbol, + 10, + VisualParams { colors: Some(colors.clone()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&smoke_scn) + .unwrap() + .apply_gradient_to_symbols(&self.config.smoke_symbols, 3, Some(&smoke_gradient), None) + .map_err(EngineError::Other)?; + } + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene(smoke_scn), + EventAction::ActivateScene(paint_scn), + ) + .map_err(EngineError::Other)?; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.set_appearance(&input_symbol, uses_pre, Some(&input_symbol), Some(base_colors)); + } + } + + while !gen_alg.complete { + gen_alg.step(ctx); + } + + // trigger effects on starting char since it will not be 'explored' + let starting_char = fill_alg.starting_char; + ctx.activate_scene(self, starting_char, "smoke"); + ctx.active_characters.insert(starting_char); + + self.fill_alg = Some(fill_alg); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + let mut fill_alg = self.fill_alg.take().expect("fill alg"); + let result = if !fill_alg.complete || !ctx.active_characters.is_empty() { + if !fill_alg.complete { + fill_alg.step(ctx); + for i in 0..fill_alg.explored_last_step.len() { + let id = fill_alg.explored_last_step[i]; + ctx.activate_scene(self, id, "smoke"); + ctx.active_characters.insert(id); + } + } + ctx.update(self); + Some(ctx.frame()) + } else { + None + }; + self.fill_alg = Some(fill_alg); + result + } +} diff --git a/vendor/ttfx/src/effects/spotlights.rs b/vendor/ttfx/src/effects/spotlights.rs new file mode 100644 index 0000000..848e166 --- /dev/null +++ b/vendor/ttfx/src/effects/spotlights.rs @@ -0,0 +1,371 @@ +//! spotlights, ported from effects/effect_spotlights.py. + +use std::collections::{BTreeSet, HashMap}; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_non_negative_float, parse_positive_float, + parse_positive_float_range, parse_positive_int, +}; +use crate::engine::animation::{Animation, ExistingColorHandling}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::{self, Coord}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::pycompat::floor_div; + +#[derive(Args, Debug, Clone)] +pub struct SpotlightsConfig { + /// Width of the beam of light as min(width, height) // n of the input text. Values less than 1 are raised to 1. + #[arg(long = "beam-width-ratio", default_value_t = 2.0, value_parser = parse_positive_float)] + pub beam_width_ratio: f64, + + /// Distance from the edge of the beam where the brightness begins to fall off, as a percentage of total beam width. + #[arg(long = "beam-falloff", default_value_t = 0.3, value_parser = parse_non_negative_float)] + pub beam_falloff: f64, + + /// Duration of the search phase, in frames, before the spotlights converge in the center. + #[arg(long = "search-duration", default_value_t = 550, value_parser = parse_positive_int)] + pub search_duration: i64, + + /// Range of speeds for the spotlights during the search phase. + #[arg(long = "search-speed-range", default_value = "0.35-0.75", value_parser = parse_positive_float_range)] + pub search_speed_range: (f64, f64), + + /// Number of spotlights to use. + #[arg(long = "spotlight-count", default_value_t = 3, value_parser = parse_positive_int)] + pub spotlight_count: i64, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["ab48ff", "e7b2b2", "fffebd"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Spotlights { + config: SpotlightsConfig, + illuminated_chars: BTreeSet, + character_color_map: HashMap, + spotlights: Vec, + illuminate_range: i64, + search_duration: i64, + searching: bool, + expanding: bool, + complete: bool, +} + +impl Spotlights { + pub fn new(config: SpotlightsConfig) -> Self { + Spotlights { + config, + illuminated_chars: BTreeSet::new(), + character_color_map: HashMap::new(), + spotlights: Vec::new(), + illuminate_range: 1, + search_duration: 0, + searching: true, + expanding: false, + complete: false, + } + } + + /// SpotlightsIterator._adjust_color_pair_brightness. + fn adjust_color_pair_brightness(colors: &ColorPair, brightness_factor: f64) -> ColorPair { + ColorPair::new( + colors.fg_color.as_ref().map(|fg| Animation::adjust_color_brightness(fg, brightness_factor)), + colors.bg_color.as_ref().map(|bg| Animation::adjust_color_brightness(bg, brightness_factor)), + ) + } + + /// SpotlightsIterator._has_input_colors. + fn has_input_colors(ctx: &EngineCtx, id: CharId) -> bool { + let ch = &ctx.terminal.arena[id.0 as usize]; + ch.animation.input_fg_color.is_some() || ch.animation.input_bg_color.is_some() + } + + /// SpotlightsIterator._is_spotlightable. + fn is_spotlightable(ctx: &EngineCtx, id: CharId) -> bool { + ctx.terminal.arena[id.0 as usize].input_symbol != " " || Self::has_input_colors(ctx, id) + } + + /// SpotlightsIterator._get_expand_color_override. + fn get_expand_color_override(&self, ctx: &EngineCtx, id: CharId) -> Option { + if ctx.terminal.config.existing_color_handling != ExistingColorHandling::Dynamic || !self.expanding { + return None; + } + let ch = &ctx.terminal.arena[id.0 as usize]; + if ch.animation.input_fg_color.is_none() && ch.animation.input_bg_color.is_some() { + return Some(ColorPair::new(None, ch.animation.input_bg_color.clone())); + } + if !Self::has_input_colors(ctx, id) { + return Some(ColorPair::default()); + } + None + } + + /// SpotlightsIterator.make_spotlights. + fn make_spotlights(&mut self, ctx: &mut EngineCtx, num_spotlights: i64) -> Result, EngineError> { + let mut spotlights: Vec = Vec::new(); + let minimum_distance = floor_div(ctx.terminal.canvas.right, 4); + for _ in 0..num_spotlights { + let spawn_coord = ctx.terminal.canvas.random_coord(&mut ctx.rng, true, false); + let spotlight = ctx.terminal.add_character("O", spawn_coord); + spotlights.push(spotlight); + + let mut spotlight_target_coords: Vec = Vec::new(); + let mut last_coord = ctx.terminal.canvas.random_coord(&mut ctx.rng, false, false); + spotlight_target_coords.push(last_coord); + for _ in 0..10 { + let next_coord = Self::find_coord_at_minimum_distance(ctx, last_coord, minimum_distance); + spotlight_target_coords.push(next_coord); + last_coord = next_coord; + } + + let mut paths: Vec = Vec::new(); + for coord in spotlight_target_coords { + let speed = ctx.rng.uniform(self.config.search_speed_range.0, self.config.search_speed_range.1); + let path_id = paths.len().to_string(); + let path_id = { + let ch = &mut ctx.terminal.arena[spotlight.0 as usize]; + ch.motion + .new_path(speed, Some(Easing::InOutQuad), None, 0, false, &path_id) + .map_err(EngineError::Other)? + }; + let bezier_control = ctx.terminal.canvas.random_coord(&mut ctx.rng, true, false); + { + let ch = &mut ctx.terminal.arena[spotlight.0 as usize]; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(coord, Some(vec![bezier_control]), "") + .map_err(EngineError::Other)?; + } + paths.push(path_id); + } + ctx.chain_paths(spotlight, &paths, true).map_err(EngineError::Other)?; + + let canvas_center = ctx.terminal.canvas.center; + let ch = &mut ctx.terminal.arena[spotlight.0 as usize]; + let center_path = ch + .motion + .new_path(0.5, Some(Easing::InOutSine), None, 0, false, "center") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(¢er_path) + .unwrap() + .new_waypoint(canvas_center, None, "") + .map_err(EngineError::Other)?; + } + Ok(spotlights) + } + + /// SpotlightsIterator.find_coord_at_minimum_distance. + fn find_coord_at_minimum_distance(ctx: &mut EngineCtx, origin_coord: Coord, minimum_distance: i64) -> Coord { + loop { + let coord = ctx.terminal.canvas.random_coord(&mut ctx.rng, false, false); + let distance = geometry::find_length_of_line(origin_coord, coord, false); + if distance >= minimum_distance as f64 { + return coord; + } + } + } + + /// SpotlightsIterator.illuminate_chars. + fn illuminate_chars(&mut self, ctx: &mut EngineCtx, range_: i64) { + let mut coords_in_range: Vec = Vec::new(); + for &spotlight in &self.spotlights { + let current_coord = ctx.terminal.arena[spotlight.0 as usize].motion.current_coord; + coords_in_range.extend(geometry::find_coords_in_circle(current_coord, range_)); + } + let mut chars_in_range: BTreeSet = BTreeSet::new(); + for coord in coords_in_range { + if let Some(id) = ctx.terminal.get_character_by_input_coord(coord) { + if Self::is_spotlightable(ctx, id) { + chars_in_range.insert(id); + } + } + } + let chars_no_longer_in_range: Vec = + self.illuminated_chars.difference(&chars_in_range).copied().collect(); + for id in chars_no_longer_in_range { + let expand_override = self.get_expand_color_override(ctx, id); + let colors = match expand_override { + None => self.character_color_map.get(&id).unwrap().1.clone(), + Some(overridden) => overridden, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + ch.animation.set_appearance(&input_symbol, uses_pre, Some(&input_symbol.clone()), Some(colors)); + } + + for &id in &chars_in_range { + let input_coord = ctx.terminal.arena[id.0 as usize].input_coord; + let distance = self + .spotlights + .iter() + .map(|&spotlight| { + let current_coord = ctx.terminal.arena[spotlight.0 as usize].motion.current_coord; + geometry::find_length_of_line(current_coord, input_coord, true) + }) + .fold(f64::INFINITY, f64::min); + + let adjusted_color = if distance > range_ as f64 * (1.0 - self.config.beam_falloff) { + let brightness_factor = (1.0 + - (distance - range_ as f64 * (1.0 - self.config.beam_falloff)) + / (range_ as f64 * self.config.beam_falloff)) + .max(0.2); + Self::adjust_color_pair_brightness( + &self.character_color_map.get(&id).unwrap().0, + brightness_factor, + ) + } else { + self.character_color_map.get(&id).unwrap().0.clone() + }; + let expand_override = self.get_expand_color_override(ctx, id); + let colors = match expand_override { + None => adjusted_color, + Some(overridden) => overridden, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + ch.animation.set_appearance(&input_symbol, uses_pre, Some(&input_symbol.clone()), Some(colors)); + } + self.illuminated_chars = chars_in_range; + } +} + +impl EffectHooks for Spotlights { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Spotlights { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // SpotlightsIterator.DYNAMIC_NEUTRAL_GRAY + let dynamic_neutral_gray = Color::from_hex("#808080").unwrap(); + self.spotlights = self.make_spotlights(ctx, self.config.spotlight_count)?; + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + let (input_coord, input_fg, input_bg) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_coord, ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone()) + }; + let (bright_pair, dark_pair); + if dynamic { + if input_fg.is_some() || input_bg.is_some() { + let mut bright_fg = input_fg.clone(); + if bright_fg.is_none() && input_bg.is_some() { + bright_fg = Some(dynamic_neutral_gray.clone()); + } + bright_pair = ColorPair::new(bright_fg.clone(), input_bg.clone()); + dark_pair = ColorPair::new( + bright_fg.as_ref().map(|fg| Animation::adjust_color_brightness(fg, 0.2)), + input_bg.as_ref().map(|bg| Animation::adjust_color_brightness(bg, 0.2)), + ); + } else { + bright_pair = ColorPair::new(Some(dynamic_neutral_gray.clone()), None); + dark_pair = ColorPair::new( + Some(Animation::adjust_color_brightness(&dynamic_neutral_gray, 0.2)), + None, + ); + } + } else { + let color_bright = final_gradient_mapping.get(&input_coord).unwrap().clone(); + dark_pair = ColorPair::new(Some(Animation::adjust_color_brightness(&color_bright, 0.2)), None); + bright_pair = ColorPair::new(Some(color_bright), None); + } + ctx.terminal.set_character_visibility(id, true); + self.character_color_map.insert(id, (bright_pair, dark_pair.clone())); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + ch.animation.set_appearance(&input_symbol, uses_pre, Some(&input_symbol.clone()), Some(dark_pair)); + } + let smallest_dimension = std::cmp::min(ctx.terminal.canvas.right, ctx.terminal.canvas.top); + // int(min(smallest // ratio, smallest)) — float floor division then truncation + self.illuminate_range = std::cmp::max( + (smallest_dimension as f64 / self.config.beam_width_ratio) + .floor() + .min(smallest_dimension as f64) as i64, + 1, + ); + self.search_duration = self.config.search_duration; + self.searching = true; + self.expanding = false; + self.complete = false; + for &spotlight in &self.spotlights.clone() { + ctx.activate_path(self, spotlight, "0"); + ctx.active_characters.insert(spotlight); + } + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.complete { + self.illuminate_chars(ctx, self.illuminate_range); + if self.searching { + self.search_duration -= 1; + if self.search_duration == 0 { + for &spotlight in &self.spotlights.clone() { + ctx.activate_path(self, spotlight, "center"); + } + self.searching = false; + } + } + if !self + .spotlights + .iter() + .any(|&spotlight| ctx.terminal.arena[spotlight.0 as usize].motion.active_path.is_some()) + { + while self.spotlights.len() > 1 { + self.spotlights.pop(); + } + self.expanding = true; + self.illuminate_range += 1; + let limit = (std::cmp::max(ctx.terminal.canvas.right, ctx.terminal.canvas.top) as f64 / 1.5).floor(); + if self.illuminate_range as f64 > limit { + self.complete = true; + } + } + + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/spray.rs b/vendor/ttfx/src/effects/spray.rs new file mode 100644 index 0000000..378725f --- /dev/null +++ b/vendor/ttfx/src/effects/spray.rs @@ -0,0 +1,241 @@ +//! spray, ported from effects/effect_spray.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_easing, parse_gradient_direction, parse_gradient_steps, parse_positive_float_range, parse_positive_ratio, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::pycompat::floor_div; + +/// SprayIterator.SprayPosition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SprayPosition { + N, + Ne, + E, + Se, + S, + Sw, + W, + Nw, + Center, +} + +fn parse_spray_position(s: &str) -> Result { + Ok(match s { + "n" => SprayPosition::N, + "ne" => SprayPosition::Ne, + "e" => SprayPosition::E, + "se" => SprayPosition::Se, + "s" => SprayPosition::S, + "sw" => SprayPosition::Sw, + "w" => SprayPosition::W, + "nw" => SprayPosition::Nw, + "center" => SprayPosition::Center, + _ => { + return Err(format!( + "invalid choice: '{s}' (choose from 'n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw', 'center')" + )) + } + }) +} + +#[derive(Args, Debug, Clone)] +pub struct SprayConfig { + /// Position for the spray origin. + #[arg(long = "spray-position", default_value = "e", value_parser = parse_spray_position)] + pub spray_position: SprayPosition, + + /// Number of characters to spray per tick as a percent of the total number of characters. + #[arg(long = "spray-volume", default_value_t = 0.005, value_parser = parse_positive_ratio)] + pub spray_volume: f64, + + /// Movement speed range of the characters. + #[arg(long = "movement-speed-range", default_value = "0.6-1.4", value_parser = parse_positive_float_range)] + pub movement_speed_range: (f64, f64), + + /// Easing function to use for character movement. + #[arg(long = "movement-easing", default_value = "out_expo", value_parser = parse_easing)] + pub movement_easing: Easing, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Spray { + config: SprayConfig, + pending_chars: Vec, + character_final_color_map: HashMap, + volume: i64, +} + +impl Spray { + pub fn new(config: SprayConfig) -> Self { + Spray { config, pending_chars: Vec::new(), character_final_color_map: HashMap::new(), volume: 1 } + } +} + +impl EffectHooks for Spray { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Spray { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + let final_colors = { + let ch = &ctx.terminal.arena[id.0 as usize]; + if dynamic { + ColorPair::new(ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone()) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&ch.input_coord).unwrap().clone()), None) + } + }; + self.character_final_color_map.insert(id, final_colors); + } + + let canvas = &ctx.terminal.canvas; + let spray_origin = match self.config.spray_position { + SprayPosition::Center => canvas.center, + SprayPosition::N => Coord::new(floor_div(canvas.right, 2), canvas.top), + SprayPosition::Nw => Coord::new(canvas.left, canvas.top), + SprayPosition::W => Coord::new(canvas.left, floor_div(canvas.top, 2)), + SprayPosition::Sw => Coord::new(canvas.left, canvas.bottom), + SprayPosition::S => Coord::new(floor_div(canvas.right, 2), canvas.bottom), + SprayPosition::Se => Coord::new(canvas.right - 1, canvas.bottom), + SprayPosition::E => Coord::new(canvas.right - 1, floor_div(canvas.top, 2)), + SprayPosition::Ne => Coord::new(canvas.right - 1, canvas.top), + }; + + for id in characters { + let (input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_coord, ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + let speed = ctx.rng.uniform(self.config.movement_speed_range.0, self.config.movement_speed_range.1); + let input_coord_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.set_coordinate(spray_origin); + let path_id = ch + .motion + .new_path(speed, Some(self.config.movement_easing), None, 0, false, "") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + path_id + }; + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path(input_coord_path.clone()), + EventAction::SetLayer(1), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(input_coord_path.clone()), + EventAction::SetLayer(0), + ) + .map_err(EngineError::Other)?; + + let droplet_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + let final_colors = self.character_final_color_map.get(&id).unwrap().clone(); + if dynamic { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&droplet_scn).unwrap(); + for _ in 0..7 { + scene + .add_frame( + &input_symbol, + 20, + VisualParams { colors: Some(final_colors.clone()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let start_color = ctx.rng.choice(&final_gradient.spectrum).clone(); + let final_fg = final_colors.fg_color.clone().expect("gradient mapping fg"); + let spray_gradient = + Gradient::with_steps(&[start_color, final_fg], 7, false).map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&droplet_scn) + .unwrap() + .apply_gradient_to_symbols(&[input_symbol.clone()], 20, Some(&spray_gradient), None) + .map_err(EngineError::Other)?; + } + ctx.activate_scene(self, id, &droplet_scn); + ctx.activate_path(self, id, &input_coord_path); + self.pending_chars.push(id); + } + ctx.rng.shuffle(&mut self.pending_chars); + self.volume = std::cmp::max((self.pending_chars.len() as f64 * self.config.spray_volume) as i64, 1); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.pending_chars.is_empty() || !ctx.active_characters.is_empty() { + if !self.pending_chars.is_empty() { + for _ in 0..ctx.rng.randint(1, self.volume) { + if let Some(next_character) = self.pending_chars.pop() { + ctx.terminal.set_character_visibility(next_character, true); + ctx.active_characters.insert(next_character); + } + } + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/swarm.rs b/vendor/ttfx/src/effects/swarm.rs new file mode 100644 index 0000000..0c56355 --- /dev/null +++ b/vendor/ttfx/src/effects/swarm.rs @@ -0,0 +1,471 @@ +//! swarm, ported from effects/effect_swarm.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_non_negative_ratio, parse_positive_int_range, +}; +use crate::engine::animation::{ExistingColorHandling, SyncMetric, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::{self, Coord}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::pycompat::{floor_div, round_half_even}; + +#[derive(Args, Debug, Clone)] +pub struct SwarmConfig { + /// Space separated, unquoted, list of colors for the swarms. + #[arg(long = "base-color", num_args = 1.., value_parser = parse_color, + default_values = ["31a0d4"])] + pub base_color: Vec, + + /// Color for the character flash. Characters flash when moving. + #[arg(long = "flash-color", default_value = "f2ea79", value_parser = parse_color)] + pub flash_color: Color, + + /// Percent of total characters in each swarm. + #[arg(long = "swarm-size", default_value_t = 0.1, value_parser = parse_non_negative_ratio)] + pub swarm_size: f64, + + /// Percent of characters in a swarm that move as a group. + #[arg(long = "swarm-coordination", default_value_t = 0.80, value_parser = parse_non_negative_ratio)] + pub swarm_coordination: f64, + + /// Range of the number of areas where characters will swarm. + #[arg(long = "swarm-area-count-range", default_value = "2-4", value_parser = parse_positive_int_range)] + pub swarm_area_count_range: (i64, i64), + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["31b900", "f0ff65"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "horizontal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Swarm { + config: SwarmConfig, + swarms: Vec>, + character_final_color_map: HashMap, + call_next: bool, + active_swarm_area: String, + current_swarm: Vec, +} + +impl Swarm { + pub fn new(config: SwarmConfig) -> Self { + Swarm { + config, + swarms: Vec::new(), + character_final_color_map: HashMap::new(), + call_next: true, + active_swarm_area: "0_swarm_area".to_string(), + current_swarm: Vec::new(), + } + } + + /// SwarmIterator.make_swarms. + fn make_swarms(&mut self, ctx: &mut EngineCtx, swarm_size: i64) { + let mut unswarmed_characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::BottomToTopRightToLeft) + }; + while !unswarmed_characters.is_empty() { + let mut new_swarm: Vec = Vec::new(); + for _ in 0..swarm_size { + if let Some(id) = unswarmed_characters.pop() { + new_swarm.push(id); + } else { + break; + } + } + self.swarms.push(new_swarm); + } + let final_swarm = self.swarms.pop().expect("make_swarms: no swarms"); + if (final_swarm.len() as i64) < floor_div(swarm_size, 2) { + self.swarms + .last_mut() + .expect("upstream IndexError: no preceding swarm to merge into") + .extend(final_swarm); + } else { + self.swarms.push(final_swarm); + } + } +} + +/// int(s[0]) on a path id string (effect_swarm.py's first-character parse). +fn first_char_digit(s: &str) -> i64 { + s.chars().next().and_then(|c| c.to_digit(10)).expect("path id must start with a digit") as i64 +} + +impl EffectHooks for Swarm { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Swarm { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // SwarmIterator.DYNAMIC_CLEAR_COLOR + let dynamic_clear_color = Color::from_hex("#ffffff").unwrap(); + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + let swarm_size: i64 = + std::cmp::max(round_half_even(characters.len() as f64 * self.config.swarm_size), 1); + self.make_swarms(ctx, swarm_size); + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + for &id in &characters { + let ch = &ctx.terminal.arena[id.0 as usize]; + let final_colors = if dynamic { + ColorPair::new(ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone()) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&ch.input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors); + } + let flash_list: Vec = (0..10).map(|_| self.config.flash_color.clone()).collect(); + let canvas_right = ctx.terminal.canvas.right; + let canvas_top = ctx.terminal.canvas.top; + // stands in for upstream's process-wide find_coords_on_circle lru_cache + let mut circle_cache: HashMap> = HashMap::new(); + for swarm_index in 0..self.swarms.len() { + let swarm = self.swarms[swarm_index].clone(); + let base = ctx.rng.choice(&self.config.base_color).clone(); + let swarm_gradient = Gradient::with_steps(&[base, self.config.flash_color.clone()], 7, false) + .map_err(EngineError::Other)?; + let swarm_gradient_mirror: Vec = swarm_gradient + .spectrum + .iter() + .cloned() + .chain(flash_list.iter().cloned()) + .chain(swarm_gradient.spectrum.iter().rev().cloned()) + .collect(); + // dict[Coord, list[Coord]] — insertion-ordered with key-overwrite-in-place + let mut swarm_area_coordinate_map: Vec<(Coord, Vec)> = Vec::new(); + let swarm_spawn = ctx.terminal.canvas.random_coord(&mut ctx.rng, true, false); + let mut swarm_areas: Vec = Vec::new(); + let swarm_area_count = + ctx.rng.randint(self.config.swarm_area_count_range.0, self.config.swarm_area_count_range.1); + // create areas where characters will swarm + let mut last_focus_coord = swarm_spawn; + let radius = std::cmp::max(floor_div(std::cmp::min(canvas_right, canvas_top), 2), 1); + while (swarm_areas.len() as i64) < swarm_area_count { + // Upstream find_coords_on_circle is lru_cached and swarm shuffles the + // RETURNED LIST IN PLACE, mutating the cache: a later call with the + // same focus coord returns the previously shuffled list, not the + // freshly computed one. Reproduce that observable quirk with an + // effect-local cache whose entries persist the shuffle mutation + // (the only in-process mutator of this cache is this loop). + let cached = circle_cache + .entry(last_focus_coord) + .or_insert_with(|| geometry::find_coords_on_circle(last_focus_coord, radius, 0, true)); + ctx.rng.shuffle(cached); + let potential_focus_coords = cached.clone(); + let mut next_focus_coord: Option = None; + for coord in &potential_focus_coords { + if ctx.terminal.canvas.coord_is_in_canvas(*coord) { + next_focus_coord = Some(*coord); + break; + } + } + let next_focus_coord = match next_focus_coord { + Some(coord) => coord, + None => ctx.terminal.canvas.random_coord(&mut ctx.rng, false, false), + }; + swarm_areas.push(next_focus_coord); + let area_coords = geometry::find_coords_in_circle( + last_focus_coord, + std::cmp::max(floor_div(std::cmp::min(canvas_right, canvas_top), 6), 1) * 2, + ); + if let Some(entry) = + swarm_area_coordinate_map.iter_mut().find(|(coord, _)| *coord == last_focus_coord) + { + entry.1 = area_coords; + } else { + swarm_area_coordinate_map.push((last_focus_coord, area_coords)); + } + last_focus_coord = next_focus_coord; + } + + // assign characters waypoints for swarm areas and inner waypoints within the swarm areas + for &id in &swarm { + let (input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_coord, ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + let flash_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.set_coordinate(swarm_spawn); + let scene_id = ch.animation.new_scene(false, Some(SyncMetric::Distance), None, "", uses_pre); + let scene = ch.animation.scenes.get_mut(&scene_id).unwrap(); + for step in &swarm_gradient_mirror { + scene + .add_frame( + &input_symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(Some(step.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + scene_id + }; + for (swarm_area_count, (_, swarm_area_coords)) in swarm_area_coordinate_map.iter().enumerate() { + let swarm_area_name = format!("{swarm_area_count}_swarm_area"); + let origin_waypoint_coord = *ctx.rng.choice(swarm_area_coords); + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let origin_path = ch + .motion + .new_path(0.4, Some(Easing::OutSine), None, 0, false, &swarm_area_name) + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&origin_path) + .unwrap() + .new_waypoint(origin_waypoint_coord, None, &swarm_area_name) + .map_err(EngineError::Other)?; + } + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path(swarm_area_name.clone()), + EventAction::ActivateScene(flash_scn.clone()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path(swarm_area_name.clone()), + EventAction::SetLayer(1), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(swarm_area_name.clone()), + EventAction::DeactivateScene(None), + ) + .map_err(EngineError::Other)?; + let mut inner_paths = 0; + let total_inner_paths = 2; + while inner_paths < total_inner_paths { + let next_coord = *ctx.rng.choice(swarm_area_coords); + inner_paths += 1; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let inner_path_id = ch.motion.paths.len().to_string(); + let inner_path = ch + .motion + .new_path(0.18, Some(Easing::InOutSine), None, 0, false, &inner_path_id) + .map_err(EngineError::Other)?; + let waypoint_id = ch.motion.paths.len().to_string(); + ch.motion + .paths + .get_mut(&inner_path) + .unwrap() + .new_waypoint(next_coord, None, &waypoint_id) + .map_err(EngineError::Other)?; + } + } + // create landing waypoint and scene + let input_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let path_id = ch + .motion + .new_path(0.45, Some(Easing::InOutQuad), None, 0, false, "") + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + path_id + }; + let input_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + let final_colors = self.character_final_color_map.get(&id).unwrap().clone(); + if dynamic { + if final_colors.fg_color.is_none() && final_colors.bg_color.is_none() { + let clear_gradient = Gradient::with_steps( + &[self.config.flash_color.clone(), dynamic_clear_color.clone()], + 10, + false, + ) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&input_scn).unwrap(); + for step in &clear_gradient.spectrum { + scene + .add_frame( + &input_symbol, + 3, + VisualParams { + colors: Some(ColorPair::new(Some(step.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + scene + .add_frame( + &input_symbol, + 3, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } else { + let fg_gradient = match &final_colors.fg_color { + Some(fg) => Some( + Gradient::with_steps(&[self.config.flash_color.clone(), fg.clone()], 10, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &final_colors.bg_color { + Some(bg) => Some( + Gradient::with_steps(&[self.config.flash_color.clone(), bg.clone()], 10, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&input_scn) + .unwrap() + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 3, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } + } else { + let final_fg = final_colors.fg_color.clone().expect("gradient mapping fg"); + let landing_gradient = + Gradient::with_steps(&[self.config.flash_color.clone(), final_fg], 10, false) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&input_scn).unwrap(); + for step in &landing_gradient.spectrum { + scene + .add_frame( + &input_symbol, + 3, + VisualParams { + colors: Some(ColorPair::new(Some(step.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(input_path.clone()), + EventAction::ActivateScene(input_scn), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(input_path.clone()), + EventAction::SetLayer(0), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path(input_path), + EventAction::ActivateScene(flash_scn), + ) + .map_err(EngineError::Other)?; + let all_paths: Vec = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ch.motion.paths.keys().map(|key| key.to_string()).collect() + }; + ctx.chain_paths(id, &all_paths, false).map_err(EngineError::Other)?; + } + } + self.call_next = true; + self.active_swarm_area = "0_swarm_area".to_string(); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.swarms.is_empty() || !ctx.active_characters.is_empty() { + if !self.swarms.is_empty() && self.call_next { + self.call_next = false; + self.current_swarm = self.swarms.pop().unwrap(); + self.active_swarm_area = "0_swarm_area".to_string(); + for &id in &self.current_swarm.clone() { + ctx.activate_path(self, id, "0_swarm_area"); + ctx.terminal.set_character_visibility(id, true); + ctx.active_characters.insert(id); + } + } + if ctx.active_characters.len() < self.current_swarm.len() { + // some of the characters have landed + self.call_next = true; + } + if !self.current_swarm.is_empty() { + for i in 0..self.current_swarm.len() { + let id = self.current_swarm[i]; + let active_path_id = ctx.terminal.arena[id.0 as usize].motion.active_path.clone(); + if let Some(path_id) = active_path_id { + if path_id.as_ref() != self.active_swarm_area + && path_id.contains("swarm_area") + && first_char_digit(&path_id) > first_char_digit(&self.active_swarm_area) + { + self.active_swarm_area = path_id.to_string(); + for &other in &self.current_swarm.clone() { + if other != id && ctx.rng.random() < self.config.swarm_coordination { + let area = self.active_swarm_area.clone(); + ctx.activate_path(self, other, &area); + } + } + break; + } + } + } + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/sweep.rs b/vendor/ttfx/src/effects/sweep.rs new file mode 100644 index 0000000..aa104d5 --- /dev/null +++ b/vendor/ttfx/src/effects/sweep.rs @@ -0,0 +1,257 @@ +//! sweep, ported from effects/effect_sweep.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_character_group, parse_gradient_direction, parse_gradient_steps, parse_symbol, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::easing::{Easing, SequenceEaser}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct SweepConfig { + /// Space separated list of symbols to use for the sweep shimmer. + #[arg(long = "sweep-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["█", "▓", "▒", "░"])] + pub sweep_symbols: Vec, + + /// Direction of the first sweep, revealing uncolored characters. + #[arg(long = "first-sweep-direction", default_value = "column_right_to_left", + value_parser = parse_character_group)] + pub first_sweep_direction: CharacterGroup, + + /// Direction of the second sweep, coloring the characters. + #[arg(long = "second-sweep-direction", default_value = "column_left_to_right", + value_parser = parse_character_group)] + pub second_sweep_direction: CharacterGroup, + + /// Space separated, unquoted, list of colors for the character gradient (applied from bottom to top). + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "ffffff"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["8"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Sweep { + config: SweepConfig, + character_final_color_map: HashMap, + dynamic_second_sweep_palette: Vec, + complete: bool, + first_phase: bool, + easer: Option>>, + groups_second_sweep: Vec>, +} + +impl Sweep { + pub fn new(config: SweepConfig) -> Self { + Sweep { + config, + character_final_color_map: HashMap::new(), + dynamic_second_sweep_palette: Vec::new(), + complete: false, + first_phase: true, + easer: None, + groups_second_sweep: Vec::new(), + } + } +} + +impl EffectHooks for Sweep { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Sweep { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_fg_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_fg_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let shades_of_gray: Vec = ["A0A0A0", "808080", "404040", "202020", "101010"] + .iter() + .map(|hex| Color::from_hex(hex).unwrap()) + .collect(); + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + if dynamic { + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let ch = &ctx.terminal.arena[id.0 as usize]; + if let Some(fg) = &ch.animation.input_fg_color { + self.dynamic_second_sweep_palette.push(fg.clone()); + } + if let Some(bg) = &ch.animation.input_bg_color { + self.dynamic_second_sweep_palette.push(bg.clone()); + } + } + if self.dynamic_second_sweep_palette.is_empty() { + self.dynamic_second_sweep_palette = final_fg_gradient.spectrum.clone(); + } + } + + let fills_filter = + CharacterFilter { inner_fill_chars: true, outer_fill_chars: true, ..Default::default() }; + let characters = + ctx.terminal.get_characters(&mut ctx.rng, fills_filter, CharacterSort::TopToBottomLeftToRight); + for id in characters { + let (is_fill, input_fg, input_bg, input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.is_fill_character, + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.input_coord, + ch.input_symbol.clone(), + ch.uses_input_preexisting_colors, + ) + }; + if !is_fill { + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors); + } + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "initial_sweep", uses_pre); + } + for symbol in &self.config.sweep_symbols { + let color = ctx.rng.choice(&shades_of_gray).clone(); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("initial_sweep") + .unwrap() + .add_frame( + symbol, + 5, + VisualParams { colors: Some(ColorPair::new(Some(color), None)), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("initial_sweep") + .unwrap() + .add_frame( + &input_symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(Some(Color::from_hex("#808080").unwrap()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + ch.animation.new_scene(false, None, None, "second_sweep", uses_pre); + } + for symbol in &self.config.sweep_symbols { + let color = if dynamic { + ctx.rng.choice(&self.dynamic_second_sweep_palette).clone() + } else { + ctx.rng.choice(&final_fg_gradient.spectrum).clone() + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("second_sweep") + .unwrap() + .add_frame( + symbol, + 5, + VisualParams { colors: Some(ColorPair::new(Some(color), None)), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + let final_colors = if !is_fill { + self.character_final_color_map.get(&id).unwrap().clone() + } else if dynamic { + ColorPair::default() + } else { + ColorPair::new(Some(Color::from_hex("000000").unwrap()), None) + }; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("second_sweep") + .unwrap() + .add_frame( + &input_symbol, + 1, + VisualParams { colors: Some(final_colors), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } + + let groups_first_sweep = + ctx.terminal.get_characters_grouped(fills_filter, self.config.first_sweep_direction); + self.easer = Some(SequenceEaser::new(groups_first_sweep, Easing::InOutCirc, 100)); + self.groups_second_sweep = + ctx.terminal.get_characters_grouped(fills_filter, self.config.second_sweep_direction); + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !ctx.active_characters.is_empty() || !self.complete { + let mut easer = self.easer.take().unwrap(); + let step = easer.step(); + for group in step.added { + for &id in group { + if self.first_phase { + ctx.terminal.set_character_visibility(id, true); + } + let scene_id = if self.first_phase { "initial_sweep" } else { "second_sweep" }; + ctx.activate_scene(self, id, scene_id); + } + for &id in group { + ctx.active_characters.insert(id); + } + } + let easer_complete = easer.is_complete(); + if easer_complete && self.first_phase { + easer.sequence = std::mem::take(&mut self.groups_second_sweep); + easer.reset(); + self.first_phase = false; + } else if easer_complete && !self.first_phase { + self.complete = true; + } + self.easer = Some(easer); + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/synthgrid.rs b/vendor/ttfx/src/effects/synthgrid.rs new file mode 100644 index 0000000..b667eb0 --- /dev/null +++ b/vendor/ttfx/src/effects/synthgrid.rs @@ -0,0 +1,546 @@ +//! synthgrid, ported from effects/effect_synthgrid.py. +//! +//! The module-level GridLine class is the `GridLine` struct; its constructor +//! lives on `Synthgrid` (make_grid_line) for EffectHooks access. Groups are +//! `(group_number, Vec)` tuples; the SCENE_COMPLETE-driven group +//! tracker is a Vec indexed by group_number, decremented via an effect +//! callback (upstream EventHandler.Callback(update_group_tracker, n)). +//! No observable set iteration beyond the engine-canonical active_characters +//! (docs/ordering-inventory.md). + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_positive_ratio, parse_symbol, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallbackValue, CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, CoordColorMap, Gradient, GradientDirection}; +use crate::utils::pycompat::floor_div; + +/// Callback id: update_group_tracker(group_number) — decrements the tracker. +const CB_UPDATE_GROUP_TRACKER: u32 = 0; + +#[derive(Args, Debug, Clone)] +pub struct SynthGridConfig { + /// Space separated, unquoted, list of colors for the grid gradient. + #[arg(long = "grid-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["CC00CC", "ffffff"])] + pub grid_gradient_stops: Vec, + + /// Space separated, unquoted, list of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. + #[arg(long = "grid-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub grid_gradient_steps: Vec, + + /// Direction of the gradient for the grid color. + #[arg(long = "grid-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub grid_gradient_direction: GradientDirection, + + /// Space separated, unquoted, list of colors for the text gradient. + #[arg(long = "text-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub text_gradient_stops: Vec, + + /// Space separated, unquoted, list of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. + #[arg(long = "text-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub text_gradient_steps: Vec, + + /// Direction of the gradient for the text color. + #[arg(long = "text-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub text_gradient_direction: GradientDirection, + + /// Symbol to use for grid row lines. + #[arg(long = "grid-row-symbol", default_value = "─", value_parser = parse_symbol)] + pub grid_row_symbol: String, + + /// Symbol to use for grid column lines. + #[arg(long = "grid-column-symbol", default_value = "│", value_parser = parse_symbol)] + pub grid_column_symbol: String, + + /// Space separated, unquoted, list of characters for the text generation animation. + #[arg(long = "text-generation-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["░", "▒", "▓"])] + pub text_generation_symbols: Vec, + + /// Maximum percentage of blocks to have active at any given time. For example, if set to 0.1, 10 percent of the blocks will be active at any given time. + #[arg(long = "max-active-blocks", default_value_t = 0.1, value_parser = parse_positive_ratio)] + pub max_active_blocks: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Direction { + Horizontal, + Vertical, +} + +/// GridLine (module-level class in effect_synthgrid.py). +struct GridLine { + direction: Direction, + #[allow(dead_code)] + characters: Vec, + collapsed_characters: Vec, + extended_characters: Vec, +} + +impl GridLine { + /// GridLine.extend. + fn extend(&mut self, ctx: &mut EngineCtx) { + let count = if self.direction == Direction::Horizontal { 3 } else { 1 }; + for _ in 0..count { + if !self.collapsed_characters.is_empty() { + let next_char = self.collapsed_characters.remove(0); + ctx.terminal.set_character_visibility(next_char, true); + self.extended_characters.push(next_char); + } + } + } + + /// GridLine.collapse. + fn collapse(&mut self, ctx: &mut EngineCtx) { + let count = if self.direction == Direction::Horizontal { 3 } else { 1 }; + if self.collapsed_characters.is_empty() { + self.extended_characters.reverse(); + } + for _ in 0..count { + if !self.extended_characters.is_empty() { + let next_char = self.extended_characters.remove(0); + ctx.terminal.set_character_visibility(next_char, false); + self.collapsed_characters.push(next_char); + } + } + } + + /// GridLine.is_extended. + fn is_extended(&self) -> bool { + self.collapsed_characters.is_empty() + } + + /// GridLine.is_collapsed. + fn is_collapsed(&self) -> bool { + self.extended_characters.is_empty() + } +} + +/// _phase strings from SynthGridIterator.__next__. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + GridExpand, + AddChars, + Collapse, + Complete, +} + +pub struct SynthGrid { + config: SynthGridConfig, + pending_groups: Vec<(i64, Vec)>, + grid_lines: Vec, + /// group_tracker dict, indexed by group_number (keys are 0..n in order). + group_tracker: Vec, + character_final_color_map: HashMap, + phase: Phase, + total_group_count: usize, + active_groups: i64, +} + +impl SynthGrid { + pub fn new(config: SynthGridConfig) -> Self { + SynthGrid { + config, + pending_groups: Vec::new(), + grid_lines: Vec::new(), + group_tracker: Vec::new(), + character_final_color_map: HashMap::new(), + phase: Phase::GridExpand, + total_group_count: 0, + active_groups: 0, + } + } + + /// SynthGridIterator.find_even_gap. + fn find_even_gap(dimension: i64) -> i64 { + let dimension = dimension - 2; + if dimension <= 0 { + return 0; + } + // [i for i in range(dimension, 4, -1) if dimension % i <= 1] + let mut potential_gaps: Vec = Vec::new(); + let mut i = dimension; + while i > 4 { + if dimension % i <= 1 { + potential_gaps.push(i); + } + i -= 1; + } + if potential_gaps.is_empty() { + return 4; + } + // min(potential_gaps, key=lambda x: abs(x - dimension // 5)) — first minimum wins + let target = floor_div(dimension, 5); + let mut best = potential_gaps[0]; + let mut best_key = (potential_gaps[0] - target).abs(); + for &gap in &potential_gaps[1..] { + let key = (gap - target).abs(); + if key < best_key { + best = gap; + best_key = key; + } + } + best + } + + /// GridLine.__init__ (needs EffectHooks for activate_scene, so lives here). + fn make_grid_line( + &mut self, + ctx: &mut EngineCtx, + origin: Coord, + direction: Direction, + grid_gradient_mapping: &CoordColorMap, + ) -> Result { + let grid_symbol = match direction { + Direction::Horizontal => self.config.grid_row_symbol.clone(), + Direction::Vertical => self.config.grid_column_symbol.clone(), + }; + let mut characters: Vec = Vec::new(); + let coords: Vec = match direction { + Direction::Horizontal => (ctx.terminal.canvas.left..=ctx.terminal.canvas.right) + .map(|column_index| Coord::new(column_index, origin.row)) + .collect(), + Direction::Vertical => (ctx.terminal.canvas.bottom..ctx.terminal.canvas.top) + .map(|row_index| Coord::new(origin.column, row_index)) + .collect(), + }; + for coord in coords { + let effect_char = ctx.terminal.add_character(&grid_symbol, Coord::new(0, 0)); + let grid_scn = { + let ch = &mut ctx.terminal.arena[effect_char.0 as usize]; + let uses_pre = ch.uses_input_preexisting_colors; + let scene_id = ch.animation.new_scene(false, None, None, "", uses_pre); + let fg = grid_gradient_mapping.get(&coord).expect("grid gradient mapping missing coord").clone(); + ch.animation + .scenes + .get_mut(&scene_id) + .unwrap() + .add_frame( + &grid_symbol, + 1, + VisualParams { colors: Some(ColorPair::new(Some(fg), None)), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + scene_id + }; + ctx.activate_scene(self, effect_char, &grid_scn); + let ch = &mut ctx.terminal.arena[effect_char.0 as usize]; + ch.layer = 2; + ch.motion.set_coordinate(coord); + characters.push(effect_char); + } + let collapsed_characters = characters.clone(); + Ok(GridLine { direction, characters, collapsed_characters, extended_characters: Vec::new() }) + } +} + +impl EffectHooks for SynthGrid { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, callback: &EffectCallback) { + if callback.id == CB_UPDATE_GROUP_TRACKER { + if let CallbackValue::Int(group_number) = callback.args[0] { + self.group_tracker[group_number as usize] -= 1; + } + } + } +} + +impl Effect for SynthGrid { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let grid_gradient = + Gradient::new(&self.config.grid_gradient_stops, &self.config.grid_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let grid_gradient_mapping = grid_gradient + .build_coordinate_color_mapping( + 1, + ctx.terminal.canvas.top, + 1, + ctx.terminal.canvas.right, + self.config.grid_gradient_direction, + ) + .map_err(EngineError::Other)?; + let text_gradient = + Gradient::new(&self.config.text_gradient_stops, &self.config.text_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let text_gradient_mapping = text_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.text_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = + ctx.terminal.get_characters(&mut ctx.rng, CharacterFilter::default(), CharacterSort::TopToBottomLeftToRight); + for id in characters { + let ch = &ctx.terminal.arena[id.0 as usize]; + let colors = if dynamic { + ColorPair::new(ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone()) + } else if ch.input_symbol != " " { + ColorPair::new( + Some(text_gradient_mapping.get(&ch.input_coord).expect("text gradient mapping").clone()), + None, + ) + } else { + ColorPair::default() + }; + self.character_final_color_map.insert(id, colors); + } + + let (canvas_left, canvas_right, canvas_bottom, canvas_top) = { + let c = &ctx.terminal.canvas; + (c.left, c.right, c.bottom, c.top) + }; + let line = self.make_grid_line( + ctx, + Coord::new(canvas_left, canvas_bottom), + Direction::Horizontal, + &grid_gradient_mapping, + )?; + self.grid_lines.push(line); + let line = self.make_grid_line( + ctx, + Coord::new(canvas_left, canvas_top), + Direction::Horizontal, + &grid_gradient_mapping, + )?; + self.grid_lines.push(line); + let line = self.make_grid_line( + ctx, + Coord::new(canvas_left, canvas_bottom), + Direction::Vertical, + &grid_gradient_mapping, + )?; + self.grid_lines.push(line); + let line = self.make_grid_line( + ctx, + Coord::new(canvas_right, canvas_bottom), + Direction::Vertical, + &grid_gradient_mapping, + )?; + self.grid_lines.push(line); + + let mut column_indexes: Vec = Vec::new(); + let mut row_indexes: Vec = Vec::new(); + let (row_gap, column_gap) = if canvas_top > 2 * canvas_right { + let row_gap = Self::find_even_gap(canvas_top) + 1; + (row_gap, row_gap * 2) + } else { + let column_gap = Self::find_even_gap(canvas_right) + 1; + (floor_div(column_gap, 2), column_gap) + }; + + // range(bottom + row_gap, top, max(row_gap, 1)) + let row_step = std::cmp::max(row_gap, 1); + let mut row_index = canvas_bottom + row_gap; + while row_index < canvas_top { + if canvas_top - row_index >= 2 { + row_indexes.push(row_index); + let line = self.make_grid_line( + ctx, + Coord::new(canvas_left, row_index), + Direction::Horizontal, + &grid_gradient_mapping, + )?; + self.grid_lines.push(line); + } + row_index += row_step; + } + // range(left + column_gap, right, max(column_gap, 1)) + let column_step = std::cmp::max(column_gap, 1); + let mut column_index = canvas_left + column_gap; + while column_index < canvas_right { + if canvas_right - column_index >= 2 { + column_indexes.push(column_index); + let line = self.make_grid_line( + ctx, + Coord::new(column_index, canvas_bottom), + Direction::Vertical, + &grid_gradient_mapping, + )?; + self.grid_lines.push(line); + } + column_index += column_step; + } + row_indexes.push(canvas_top + 1); + column_indexes.push(canvas_right + 1); + let mut prev_row_index = 1i64; + for &row_index_value in &row_indexes { + // row_index is reassigned inside the column loop (noqa: PLW2901 + // upstream) and the mutated value flows into prev_row_index. + let mut row_index = row_index_value; + let mut prev_column_index = 1i64; + for &column_index in &column_indexes { + let mut coords_in_block: Vec = Vec::new(); + if row_index == canvas_top { + // make sure the top row is included + row_index += 1; + } + for row in prev_row_index..row_index { + for column in prev_column_index..column_index { + coords_in_block.push(Coord::new(column, row)); + } + } + let mut characters_in_block: Vec = Vec::new(); + for coord in &coords_in_block { + if let Some(&id) = ctx.terminal.character_by_input_coord.get(coord) { + characters_in_block.push(id); + } + } + if !characters_in_block.is_empty() { + self.pending_groups.push((self.pending_groups.len() as i64, characters_in_block)); + } + prev_column_index = column_index; + } + prev_row_index = row_index; + } + + self.group_tracker = vec![0; self.pending_groups.len()]; + let pending_groups = std::mem::take(&mut self.pending_groups); + for (group_number, group) in &pending_groups { + for &character in group { + let (input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[character.0 as usize]; + (ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + let dissolve_scn = + ctx.terminal.arena[character.0 as usize].animation.new_scene(false, None, None, "", uses_pre); + let frame_count = ctx.rng.randint(15, 30); + for _ in 0..frame_count { + let symbol = ctx.rng.choice(&self.config.text_generation_symbols).clone(); + let fg = ctx.rng.choice(&text_gradient.spectrum).clone(); + ctx.terminal.arena[character.0 as usize] + .animation + .scenes + .get_mut(&dissolve_scn) + .unwrap() + .add_frame( + &symbol, + 2, + VisualParams { colors: Some(ColorPair::new(Some(fg), None)), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + let final_colors = self.character_final_color_map.get(&character).cloned().unwrap_or_default(); + ctx.terminal.arena[character.0 as usize] + .animation + .scenes + .get_mut(&dissolve_scn) + .unwrap() + .add_frame( + &input_symbol, + 1, + VisualParams { colors: Some(final_colors), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + ctx.activate_scene(self, character, &dissolve_scn); + ctx.register_event( + character, + Event::SceneComplete, + CallerKey::Scene(dissolve_scn.clone()), + EventAction::Callback(EffectCallback { + id: CB_UPDATE_GROUP_TRACKER, + args: vec![CallbackValue::Int(*group_number)], + }), + ) + .map_err(EngineError::Other)?; + } + } + self.pending_groups = pending_groups; + ctx.rng.shuffle(&mut self.pending_groups); + self.phase = Phase::GridExpand; + self.total_group_count = self.pending_groups.len(); + if self.total_group_count == 0 { + let characters = ctx.terminal.get_characters( + &mut ctx.rng, + CharacterFilter::default(), + CharacterSort::TopToBottomLeftToRight, + ); + for character in characters { + ctx.terminal.set_character_visibility(character, true); + ctx.active_characters.insert(character); + } + } + self.active_groups = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.pending_groups.is_empty() || !ctx.active_characters.is_empty() || self.phase != Phase::Complete { + match self.phase { + Phase::GridExpand => { + if !self.grid_lines.iter().all(|grid_line| grid_line.is_extended()) { + let mut grid_lines = std::mem::take(&mut self.grid_lines); + for grid_line in &mut grid_lines { + if !grid_line.is_extended() { + grid_line.extend(ctx); + } + } + self.grid_lines = grid_lines; + } else { + self.phase = Phase::AddChars; + } + } + Phase::AddChars => { + if !self.pending_groups.is_empty() + && (self.active_groups as f64) + < self.total_group_count as f64 * self.config.max_active_blocks + { + let (group_number, next_group) = self.pending_groups.remove(0); + for &ch in &next_group { + ctx.terminal.set_character_visibility(ch, true); + ctx.active_characters.insert(ch); + self.group_tracker[group_number as usize] += 1; + } + } + if self.pending_groups.is_empty() + && ctx.active_characters.is_empty() + && self.active_groups == 0 + { + self.phase = Phase::Collapse; + } + } + Phase::Collapse => { + if !self.grid_lines.iter().all(|grid_line| grid_line.is_collapsed()) { + let mut grid_lines = std::mem::take(&mut self.grid_lines); + for grid_line in &mut grid_lines { + if !grid_line.is_collapsed() { + grid_line.collapse(ctx); + } + } + self.grid_lines = grid_lines; + } else { + self.phase = Phase::Complete; + } + } + Phase::Complete => {} + } + ctx.update(self); + self.active_groups = 0; + for &active_count in &self.group_tracker { + if active_count != 0 { + self.active_groups += 1; + } + } + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/thunderstorm.rs b/vendor/ttfx/src/effects/thunderstorm.rs new file mode 100644 index 0000000..36273b5 --- /dev/null +++ b/vendor/ttfx/src/effects/thunderstorm.rs @@ -0,0 +1,922 @@ +//! thunderstorm, ported from effects/effect_thunderstorm.py. +//! +//! Two ParticlePools (rain + sparks) plus a manually managed strike-character +//! pool (available/pending/active lists). The storm budget reads the injected +//! monotonic clock at exactly the upstream points (__init__:203, fade_complete +//! :390, __next__:719 — plan.md §4.7). Upstream's three character color maps +//! (character_final/visible/storm_color_map) are written but never read, so +//! they are not stored here. `self.flashing` is likewise write-only upstream. +//! No observable set iteration beyond the engine-canonical active_characters +//! (docs/ordering-inventory.md). + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_gradient_direction, parse_gradient_steps, parse_positive_int, parse_symbol}; +use crate::engine::animation::{Animation, ExistingColorHandling, Scene, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx, NoopHooks}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::particles::{ParticlePool, ParticleReset}; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::pycompat::floor_div; + +/// fade_complete (effect_thunderstorm.py:388): phase -> storm, restart clock. +const CB_FADE_COMPLETE: u32 = 0; +/// ThunderstormIterator.hide_character. +const CB_HIDE_CHARACTER: u32 = 1; +/// ThunderstormIterator.make_char_glow. +const CB_MAKE_CHAR_GLOW: u32 = 2; +/// ThunderstormIterator.return_strike_to_pool. +const CB_RETURN_STRIKE_TO_POOL: u32 = 3; +/// ThunderstormIterator.set_strike_in_progress_false. +const CB_SET_STRIKE_IN_PROGRESS_FALSE: u32 = 4; +/// rain_pool.reclaim_on_event closure (particles.py reclaim). +const CB_RECLAIM_RAIN: u32 = 5; +/// spark_pool.reclaim_on_event closure. +const CB_RECLAIM_SPARK: u32 = 6; + +#[derive(Args, Debug, Clone)] +pub struct ThunderstormConfig { + /// Color for the lightning strike. + #[arg(long = "lightning-color", default_value = "68A3E8", value_parser = parse_color)] + pub lightning_color: Color, + + /// Color for the text when glowing after a lightning strike. + #[arg(long = "glowing-text-color", default_value = "EF5411", value_parser = parse_color)] + pub glowing_text_color: Color, + + /// Number of frames to display each color in the post-lightning text glow cooling gradient. Increase to slow down the cooling animation. + #[arg(long = "text-glow-time", default_value_t = 6, value_parser = parse_positive_int)] + pub text_glow_time: i64, + + /// Symbols to use for the raindrops. + #[arg(long = "raindrop-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["\\", ".", ","])] + pub raindrop_symbols: Vec, + + /// Symbols to use for the lightning impact sparks. + #[arg(long = "spark-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["*", ".", "'"])] + pub spark_symbols: Vec, + + /// Color for the spark glow after a lightning strike. + #[arg(long = "spark-glow-color", default_value = "ff4d00", value_parser = parse_color)] + pub spark_glow_color: Color, + + /// Number of frames to display each color in the post-lightning spark cooling gradient. Increase to slow down the cooling animation. + #[arg(long = "spark-glow-time", default_value_t = 18, value_parser = parse_positive_int)] + pub spark_glow_time: i64, + + /// Duration, in seconds, the storm will occur. + #[arg(long = "storm-time", default_value_t = 12, value_parser = parse_positive_int)] + pub storm_time: i64, + + /// Space separated, unquoted, list of colors for the character gradient (applied across the canvas). + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Number of frames to display each gradient step. Increase to slow down the gradient animation. + #[arg(long = "final-gradient-frames", default_value_t = 3, value_parser = parse_positive_int)] + pub final_gradient_frames: i64, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +/// self.phase string states. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + PreStorm, + Waiting, + Storm, + Complete, +} + +pub struct Thunderstorm { + config: ThunderstormConfig, + delay: i64, + strike_progression_delay: i64, + rain_pool: ParticlePool, + pending_strike_chars: Vec, + available_strike_chars: Vec, + active_strike_chars: Vec, + spark_pool: ParticlePool, + /// Gradient captured by the spark initializer closure (build_spark_pool). + spark_gradient: Gradient, + pending_glow_chars: Vec, + strike_in_progress: bool, + strike_branch_chance: f64, + phase: Phase, + storm_start_time: f64, +} + +/// ThunderstormIterator._adjust_color_pair_brightness. +fn adjust_color_pair_brightness(colors: &ColorPair, brightness: f64) -> ColorPair { + ColorPair::new( + colors.fg_color.as_ref().map(|c| Animation::adjust_color_brightness(c, brightness)), + colors.bg_color.as_ref().map(|c| Animation::adjust_color_brightness(c, brightness)), + ) +} + +/// ThunderstormIterator._add_color_pair_gradient_frames. Faithful quirk: when +/// both endpoint colors exist the gradient list has steps+1 entries but only +/// `range(steps)` of them are emitted as frames. +fn add_color_pair_gradient_frames( + scene: &mut Scene, + symbol: &str, + start_colors: &ColorPair, + end_colors: &ColorPair, + steps: i64, + duration: i64, +) -> Result<(), String> { + let fg_steps: Vec> = if start_colors.fg_color.is_some() && end_colors.fg_color.is_some() { + Gradient::with_steps( + &[start_colors.fg_color.clone().unwrap(), end_colors.fg_color.clone().unwrap()], + steps, + false, + )? + .spectrum + .into_iter() + .map(Some) + .collect() + } else { + let filler = if end_colors.fg_color.is_some() { + end_colors.fg_color.clone() + } else { + start_colors.fg_color.clone() + }; + vec![filler; steps as usize] + }; + let bg_steps: Vec> = if start_colors.bg_color.is_some() && end_colors.bg_color.is_some() { + Gradient::with_steps( + &[start_colors.bg_color.clone().unwrap(), end_colors.bg_color.clone().unwrap()], + steps, + false, + )? + .spectrum + .into_iter() + .map(Some) + .collect() + } else { + let filler = if end_colors.bg_color.is_some() { + end_colors.bg_color.clone() + } else { + start_colors.bg_color.clone() + }; + vec![filler; steps as usize] + }; + for index in 0..steps as usize { + scene.add_frame( + symbol, + duration, + VisualParams { + colors: Some(ColorPair::new(fg_steps[index].clone(), bg_steps[index].clone())), + ..Default::default() + }, + )?; + } + Ok(()) +} + +/// build_rain_pool's initialize_raindrop. +fn initialize_raindrop(ctx: &mut EngineCtx, id: CharId) { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.layer = 1; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + ch.animation.set_appearance( + &input_symbol, + uses_pre, + Some(&input_symbol), + Some(ColorPair::new(Some(Color::from_hex("aaaaff").unwrap()), None)), + ); +} + +/// build_spark_pool's _build_spark_characters. +fn initialize_spark(ctx: &mut EngineCtx, id: CharId, spark_gradient: &Gradient, spark_glow_time: i64) { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.layer = 2; + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + let spark_scn = ch.animation.new_scene(false, None, Some(Easing::InCirc), "glow", uses_pre); + let scene = ch.animation.scenes.get_mut(&spark_scn).unwrap(); + for color in &spark_gradient.spectrum { + scene + .add_frame( + &input_symbol, + spark_glow_time, + VisualParams { colors: Some(ColorPair::new(Some(color.clone()), None)), ..Default::default() }, + ) + .expect("spark glow frame failed"); + } +} + +/// ThunderstormIterator._setup_raindrop. +fn setup_raindrop(ctx: &mut EngineCtx, id: CharId) { + let origin = ctx.terminal.arena[id.0 as usize].motion.current_coord; + let speed = ctx.rng.uniform(0.5, 1.5); + let canvas_top = ctx.terminal.canvas.top; + let canvas_bottom = ctx.terminal.canvas.bottom; + let fall_path = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let path_id = ch.motion.new_path(speed, None, None, 0, false, "").expect("rain new_path failed"); + ch.motion + .paths + .get_mut(&path_id) + .unwrap() + .new_waypoint(Coord::new(origin.column + canvas_top + 1, canvas_bottom - 1), None, "") + .expect("rain new_waypoint failed"); + path_id + }; + // rain_pool.reclaim_on_event(rain_char, fall_path, event=PATH_COMPLETE) + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path(fall_path.clone()), + EventAction::Callback(EffectCallback { id: CB_RECLAIM_RAIN, args: Vec::new() }), + ) + .expect("rain reclaim registration failed"); + // PATH_ACTIVATED has no registered handlers on the raindrop (events were + // cleared this emit; only PATH_COMPLETE is registered), so NoopHooks is + // observably identical to dispatching through the effect. + ctx.activate_path(&mut NoopHooks, id, &fall_path); +} + +/// ThunderstormIterator._setup_sparks_for_impact. +fn setup_sparks_for_impact(ctx: &mut EngineCtx, id: CharId) { + let impact_coord = ctx.terminal.arena[id.0 as usize].motion.current_coord; + let speed = ctx.rng.uniform(0.1, 0.25); + let spark_path = ctx.terminal.arena[id.0 as usize] + .motion + .new_path(speed, Some(Easing::OutQuint), None, 30, false, "") + .expect("spark new_path failed"); + let offset = ctx.rng.randint(4, 20) * *ctx.rng.choice(&[1i64, -1]); + let spark_target = Coord::new(impact_coord.column + offset, ctx.terminal.canvas.bottom); + let bezier_column = impact_coord.column - floor_div(impact_coord.column - spark_target.column, 2); + let bezier_row = ctx.rng.randint(1, ctx.terminal.canvas.top); + ctx.terminal.arena[id.0 as usize] + .motion + .paths + .get_mut(&spark_path) + .unwrap() + .new_waypoint(spark_target, Some(vec![Coord::new(bezier_column, bezier_row)]), "") + .expect("spark new_waypoint failed"); + // spark_pool.reclaim_on_event(spark_char, "glow", event=SCENE_COMPLETE) + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene("glow".to_string()), + EventAction::Callback(EffectCallback { id: CB_RECLAIM_SPARK, args: Vec::new() }), + ) + .expect("spark reclaim registration failed"); + // SCENE_ACTIVATED / PATH_ACTIVATED have no registered handlers here + // (events were cleared this emit; only SCENE_COMPLETE is registered). + ctx.activate_scene(&mut NoopHooks, id, "glow"); + ctx.activate_path(&mut NoopHooks, id, &spark_path); +} + +impl Thunderstorm { + pub fn new(config: ThunderstormConfig) -> Self { + // build_rain_pool / build_spark_pool construction (preallocation runs + // in build(), where ctx is available, in upstream __init__ order). + let rain_pool = + ParticlePool::new(config.raindrop_symbols.clone(), None, None).expect("raindrop symbols validated by clap"); + let spark_pool = ParticlePool::new(config.spark_symbols.clone(), Some(2000), None) + .expect("spark symbols validated by clap"); + Thunderstorm { + config, + delay: 0, + strike_progression_delay: 0, + rain_pool, + pending_strike_chars: Vec::new(), + available_strike_chars: Vec::new(), + active_strike_chars: Vec::new(), + spark_pool, + spark_gradient: Gradient { spectrum: Vec::new() }, + pending_glow_chars: Vec::new(), + strike_in_progress: false, + strike_branch_chance: 0.05, + phase: Phase::PreStorm, + storm_start_time: 0.0, + } + } + + /// ThunderstormIterator.build_strike_characters. + fn build_strike_characters(&mut self, ctx: &mut EngineCtx, count: usize) { + for _ in 0..count { + let strike_char = ctx.terminal.add_character("|", Coord::new(1, 1)); + self.available_strike_chars.push(strike_char); + } + } + + /// ThunderstormIterator.get_next_strike_char. + fn get_next_strike_char(&mut self, ctx: &mut EngineCtx) -> CharId { + if self.available_strike_chars.is_empty() { + self.build_strike_characters(ctx, 20); + } + let strike_char = self.available_strike_chars.pop().unwrap(); + let ch = &mut ctx.terminal.arena[strike_char.0 as usize]; + ch.animation.scenes.clear(); + ch.event_handler.clear(); + strike_char + } + + /// ThunderstormIterator.setup_lightning_strike (recursive branching). + fn setup_lightning_strike(&mut self, ctx: &mut EngineCtx, branch_neighbor: Option) { + let mut branch_neighbor = branch_neighbor; + let (mut column, mut row); + if let Some(neighbor) = branch_neighbor { + let coord = ctx.terminal.arena[neighbor.0 as usize].motion.current_coord; + column = coord.column; + row = coord.row; + } else { + column = ctx.rng.randint(1, ctx.terminal.canvas.right); + row = ctx.terminal.canvas.top; + } + + while row >= ctx.terminal.canvas.bottom { + if self.available_strike_chars.is_empty() { + self.build_strike_characters(ctx, 20); + } + let symbol: &str; + if let Some(neighbor) = branch_neighbor { + // Strike characters are all created with input_symbol "|", so + // the "/" and "\\" arms are unreachable upstream too — + // transcribed faithfully anyway. + let neighbor_symbol = ctx.terminal.arena[neighbor.0 as usize].input_symbol.clone(); + if neighbor_symbol == "/" { + column += 1; + symbol = *ctx.rng.choice(&["|", "\\"]); + } else if neighbor_symbol == "\\" { + column -= 1; + symbol = *ctx.rng.choice(&["|", "/"]); + } else { + let delta = *ctx.rng.choice(&[-1i64, 1]); + column += delta; + symbol = if delta == 1 { "\\" } else { "/" }; + } + } else { + symbol = *ctx.rng.choice(&["\\", "/", "|"]); + } + + let strike_char = self.get_next_strike_char(ctx); + { + let ch = &mut ctx.terminal.arena[strike_char.0 as usize]; + ch.motion.set_coordinate(Coord::new(column, row)); + let input_symbol = ch.input_symbol.clone(); + let uses_pre = ch.uses_input_preexisting_colors; + ch.animation.set_appearance( + &input_symbol, + uses_pre, + Some(symbol), + Some(ColorPair::new(Some(self.config.lightning_color.clone()), None)), + ); + } + row -= 1; + if symbol == "\\" { + column += 1; + } else if symbol == "/" { + column -= 1; + } + + self.pending_strike_chars.push(strike_char); + // random.random() is always drawn (left operand of `and`). + if ctx.rng.random() < self.strike_branch_chance && branch_neighbor.is_none() { + self.strike_branch_chance -= 0.01; + self.setup_lightning_strike(ctx, Some(strike_char)); + } + branch_neighbor = None; + } + self.strike_branch_chance = 0.05; + } + + /// ThunderstormIterator.lightning_strike. + fn lightning_strike(&mut self, ctx: &mut EngineCtx) { + self.setup_lightning_strike(ctx, None); + let strike_base_color = self.config.lightning_color.clone(); + let strike_flash_color = Animation::adjust_color_brightness(&strike_base_color, 1.7); + let strike_gradient = Gradient::with_steps(&[strike_base_color.clone(), strike_flash_color], 7, true) + .expect("strike gradient failed"); + let fade_gradient = Gradient::with_steps( + &[strike_base_color, ctx.terminal.config.terminal_background_color.clone()], + 6, + false, + ) + .expect("strike fade gradient failed"); + let layer = 1; + let flash_ease = Easing::CubicBezier(0.0, 1.6, 1.0, ctx.rng.uniform(-0.6, 0.4)); + for &strike_char in &self.pending_strike_chars { + let symbol = + ctx.terminal.arena[strike_char.0 as usize].animation.current_character_visual.symbol.clone(); + { + let ch = &mut ctx.terminal.arena[strike_char.0 as usize]; + let uses_pre = ch.uses_input_preexisting_colors; + let flash_scn = ch.animation.new_scene(false, None, Some(flash_ease), "flash", uses_pre); + let scene = ch.animation.scenes.get_mut(&flash_scn).unwrap(); + for color in &strike_gradient.spectrum { + scene + .add_frame( + &symbol, + 6, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .expect("flash frame failed"); + } + let fade_scn = ch.animation.new_scene(false, None, None, "fade", uses_pre); + let scene = ch.animation.scenes.get_mut(&fade_scn).unwrap(); + for color in &fade_gradient.spectrum { + scene + .add_frame( + &symbol, + 2, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .expect("fade frame failed"); + } + ch.layer = layer; + } + ctx.register_event( + strike_char, + Event::SceneComplete, + CallerKey::Scene("flash".to_string()), + EventAction::ActivateScene("fade".to_string()), + ) + .expect("flash->fade registration failed"); + ctx.register_event( + strike_char, + Event::SceneComplete, + CallerKey::Scene("fade".to_string()), + EventAction::Callback(EffectCallback { id: CB_HIDE_CHARACTER, args: Vec::new() }), + ) + .expect("hide registration failed"); + ctx.register_event( + strike_char, + Event::SceneComplete, + CallerKey::Scene("fade".to_string()), + EventAction::Callback(EffectCallback { id: CB_MAKE_CHAR_GLOW, args: Vec::new() }), + ) + .expect("glow registration failed"); + ctx.register_event( + strike_char, + Event::SceneComplete, + CallerKey::Scene("fade".to_string()), + EventAction::Callback(EffectCallback { id: CB_RETURN_STRIKE_TO_POOL, args: Vec::new() }), + ) + .expect("return registration failed"); + } + + let text_chars = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in text_chars { + ctx.terminal.arena[id.0 as usize] + .animation + .scenes + .get_mut("flash") + .expect("text flash scene missing") + .ease = Some(flash_ease); + } + } + + /// ThunderstormIterator.step_lightning_strike. + fn step_lightning_strike(&mut self, ctx: &mut EngineCtx) { + if self.strike_progression_delay != 0 { + self.strike_progression_delay -= 1; + return; + } + if !self.pending_strike_chars.is_empty() { + let batch = ctx.rng.randint(1, 3); + for _ in 0..batch { + if self.pending_strike_chars.is_empty() { + break; + } + let next_strike_char = self.pending_strike_chars.remove(0); + self.active_strike_chars.push(next_strike_char); + ctx.terminal.set_character_visibility(next_strike_char, true); + self.strike_progression_delay = 1; + + // if the last strike_char was activated, activate the sparks + // and setup the post-fade callback to indicate the strike has + // ended + if self.pending_strike_chars.is_empty() { + let spark_gradient = self.spark_gradient.clone(); + let spark_glow_time = self.config.spark_glow_time; + let spark_count = ctx.rng.randint(12, 18); + for _ in 0..spark_count { + let origin = ctx.terminal.arena[self.active_strike_chars.last().unwrap().0 as usize] + .motion + .current_coord; + let _ = self.spark_pool.emit( + ctx, + origin, + None, + true, + ParticleReset { clear_events: true, ..Default::default() }, + |ctx, particle| initialize_spark(ctx, particle, &spark_gradient, spark_glow_time), + setup_sparks_for_impact, + ); + } + ctx.register_event( + next_strike_char, + Event::SceneComplete, + CallerKey::Scene("fade".to_string()), + EventAction::Callback(EffectCallback { + id: CB_SET_STRIKE_IN_PROGRESS_FALSE, + args: Vec::new(), + }), + ) + .expect("strike-done registration failed"); + + // activate the flash scene on all strike chars and text + let strikes = std::mem::take(&mut self.active_strike_chars); + for &strike_char in &strikes { + ctx.activate_scene(self, strike_char, "flash"); + ctx.active_characters.insert(strike_char); + } + // (take() above already performed active_strike_chars.clear()) + + let text_chars = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in text_chars { + ctx.activate_scene(self, id, "flash"); + ctx.active_characters.insert(id); + } + } + } + } + } + + /// ThunderstormIterator.rain. + fn rain(&mut self, ctx: &mut EngineCtx) { + if self.delay != 0 { + self.delay -= 1; + return; + } + let count = ctx.rng.randint(1, 6); + for _ in 0..count { + let spawn_column = ctx.rng.randint(1 - ctx.terminal.canvas.top, ctx.terminal.canvas.right); + let origin = Coord::new(spawn_column - 1, ctx.terminal.canvas.top + 1); + let _ = self.rain_pool.emit( + ctx, + origin, + None, + true, + ParticleReset { clear_events: true, ..Default::default() }, + initialize_raindrop, + setup_raindrop, + ); + } + self.delay = ctx.rng.randint(1, 7); + } + + /// ThunderstormIterator.pre_storm_text_fade. + fn pre_storm_text_fade(&mut self, ctx: &mut EngineCtx) { + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + ctx.activate_scene(self, id, "fade"); + ctx.active_characters.insert(id); + } + } + + /// ThunderstormIterator.post_storm_text_fade_in. + fn post_storm_text_fade_in(&mut self, ctx: &mut EngineCtx) { + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + ctx.activate_scene(self, id, "unfade"); + ctx.active_characters.insert(id); + } + } +} + +impl EffectHooks for Thunderstorm { + fn dispatch_callback(&mut self, ctx: &mut EngineCtx, character: CharId, callback: &EffectCallback) { + match callback.id { + CB_FADE_COMPLETE => { + self.phase = Phase::Storm; + self.storm_start_time = ctx.clock.now_monotonic(); + } + CB_HIDE_CHARACTER => ctx.terminal.set_character_visibility(character, false), + CB_MAKE_CHAR_GLOW => { + let coord = ctx.terminal.arena[character.0 as usize].motion.current_coord; + if let Some(input_char) = ctx.terminal.get_character_by_input_coord(coord) { + if ctx.terminal.arena[input_char.0 as usize].is_visible { + ctx.activate_scene(self, input_char, "glow"); + self.pending_glow_chars.push(input_char); + } + } + } + CB_RETURN_STRIKE_TO_POOL => self.available_strike_chars.push(character), + CB_SET_STRIKE_IN_PROGRESS_FALSE => self.strike_in_progress = false, + CB_RECLAIM_RAIN => self.rain_pool.reclaim(ctx, character, true, true), + CB_RECLAIM_SPARK => self.spark_pool.reclaim(ctx, character, true, true), + _ => {} + } + } +} + +impl Effect for Thunderstorm { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + // __init__ preamble (effect_thunderstorm.py:188-204): pools are + // preallocated before build() runs, and the storm clock is read once. + self.rain_pool.preallocate(ctx, 50, initialize_raindrop).map_err(EngineError::Other)?; + self.spark_gradient = Gradient::with_steps( + &[self.config.spark_glow_color.clone(), ctx.terminal.config.terminal_background_color.clone()], + 7, + false, + ) + .map_err(EngineError::Other)?; + { + let spark_gradient = self.spark_gradient.clone(); + let spark_glow_time = self.config.spark_glow_time; + self.spark_pool + .preallocate(ctx, 200, |ctx, particle| { + initialize_spark(ctx, particle, &spark_gradient, spark_glow_time) + }) + .map_err(EngineError::Other)?; + } + self.storm_start_time = ctx.clock.now_monotonic(); + + // build() body + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + self.build_strike_characters(ctx, 200); + + // setup scenes on text characters + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let dynamic_neutral_gray = Color::from_hex("808080").unwrap(); + let all_chars = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &all_chars { + let (input_symbol, input_coord, uses_pre, input_fg, input_bg) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.input_symbol.clone(), + ch.input_coord, + ch.uses_input_preexisting_colors, + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ) + }; + let (visible_colors, restore_colors) = if dynamic { + let visible = ColorPair::new( + Some(input_fg.clone().unwrap_or_else(|| dynamic_neutral_gray.clone())), + input_bg.clone(), + ); + let restore = ColorPair::new(input_fg.clone(), input_bg.clone()); + (visible, restore) + } else { + let visible = + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None); + (visible.clone(), visible) + }; + let storm_colors = adjust_color_pair_brightness(&visible_colors, 0.5); + // upstream stores visible/storm/restore in three maps that are + // never read back; not stored here. + + // post-strike glow and cool scene + let glow_fg_gradient = Gradient::with_steps( + &[self.config.glowing_text_color.clone(), storm_colors.fg_color.clone().expect("storm fg")], + 7, + false, + ) + .map_err(EngineError::Other)?; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let glow_scn = ch.animation.new_scene(false, None, None, "glow", uses_pre); + let scene = ch.animation.scenes.get_mut(&glow_scn).unwrap(); + for color in &glow_fg_gradient.spectrum { + scene + .add_frame( + &input_symbol, + self.config.text_glow_time, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), storm_colors.bg_color.clone())), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + if dynamic { + scene + .add_frame( + &input_symbol, + self.config.text_glow_time, + VisualParams { colors: Some(storm_colors.clone()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } + + // fade before storm scene + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let fade_scn = ch.animation.new_scene(false, None, None, "fade", uses_pre); + if dynamic { + let scene = ch.animation.scenes.get_mut(&fade_scn).unwrap(); + add_color_pair_gradient_frames(scene, &input_symbol, &visible_colors, &storm_colors, 7, 12) + .map_err(EngineError::Other)?; + scene + .add_frame( + &input_symbol, + 12, + VisualParams { colors: Some(storm_colors.clone()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } else { + let fade_gradient = Gradient::with_steps( + &[ + visible_colors.fg_color.clone().expect("visible fg"), + storm_colors.fg_color.clone().expect("storm fg"), + ], + 7, + false, + ) + .map_err(EngineError::Other)?; + let scene = ch.animation.scenes.get_mut(&fade_scn).unwrap(); + for color in &fade_gradient.spectrum { + scene + .add_frame( + &input_symbol, + 12, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + } + + // unfade scene + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let unfade_scn = ch.animation.new_scene(false, None, None, "unfade", uses_pre); + if dynamic { + let scene = ch.animation.scenes.get_mut(&unfade_scn).unwrap(); + add_color_pair_gradient_frames(scene, &input_symbol, &storm_colors, &visible_colors, 7, 12) + .map_err(EngineError::Other)?; + scene + .add_frame( + &input_symbol, + 12, + VisualParams { colors: Some(visible_colors.clone()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + if restore_colors != visible_colors { + scene + .add_frame( + &input_symbol, + 12, + VisualParams { colors: Some(restore_colors.clone()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let unfade_gradient: Vec = Gradient::with_steps( + &[ + visible_colors.fg_color.clone().expect("visible fg"), + storm_colors.fg_color.clone().expect("storm fg"), + ], + 7, + false, + ) + .map_err(EngineError::Other)? + .spectrum + .into_iter() + .rev() + .collect(); + let scene = ch.animation.scenes.get_mut(&unfade_scn).unwrap(); + for color in &unfade_gradient { + scene + .add_frame( + &input_symbol, + 12, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + } + + // lightning flash scene + let lightning_flash_color = + Animation::adjust_color_brightness(visible_colors.fg_color.as_ref().expect("visible fg"), 1.7); + let flash_gradient = Gradient::with_steps( + &[storm_colors.fg_color.clone().expect("storm fg"), lightning_flash_color], + 7, + true, + ) + .map_err(EngineError::Other)?; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let strike_scn = ch.animation.new_scene(false, None, None, "flash", uses_pre); + let scene = ch.animation.scenes.get_mut(&strike_scn).unwrap(); + for color in &flash_gradient.spectrum { + scene + .add_frame( + &input_symbol, + 6, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), storm_colors.bg_color.clone())), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + + ctx.terminal.set_character_visibility(id, true); + } + + // reference character callback: signals the pre-storm fade completed + let reference_char = all_chars[0]; + ctx.register_event( + reference_char, + Event::SceneComplete, + CallerKey::Scene("fade".to_string()), + EventAction::Callback(EffectCallback { id: CB_FADE_COMPLETE, args: Vec::new() }), + ) + .map_err(EngineError::Other)?; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if ctx.active_characters.is_empty() && self.phase == Phase::Complete { + return None; + } + match self.phase { + Phase::PreStorm => { + self.pre_storm_text_fade(ctx); + self.phase = Phase::Waiting; + } + Phase::Storm => { + self.rain(ctx); + if !self.strike_in_progress && ctx.rng.random() < 0.008 { + self.strike_in_progress = true; + self.lightning_strike(ctx); + } + if self.strike_in_progress { + self.step_lightning_strike(ctx); + } + + for &glow_char in &self.pending_glow_chars { + ctx.active_characters.insert(glow_char); + } + self.pending_glow_chars.clear(); + if ctx.clock.now_monotonic() - self.storm_start_time >= self.config.storm_time as f64 + && !self.strike_in_progress + { + self.post_storm_text_fade_in(ctx); + self.phase = Phase::Complete; + } + } + Phase::Waiting | Phase::Complete => {} + } + ctx.update(self); + Some(ctx.frame()) + } +} diff --git a/vendor/ttfx/src/effects/unstable.rs b/vendor/ttfx/src/effects/unstable.rs new file mode 100644 index 0000000..3e8ae79 --- /dev/null +++ b/vendor/ttfx/src/effects/unstable.rs @@ -0,0 +1,479 @@ +//! unstable, ported from effects/effect_unstable.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_easing, parse_gradient_direction, parse_gradient_steps, parse_positive_float}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct UnstableConfig { + /// Color transitioned to as the characters become unstable. + #[arg(long = "unstable-color", default_value = "ff9200", value_parser = parse_color)] + pub unstable_color: Color, + + /// Easing function to use for character movement during the explosion. + #[arg(long = "explosion-ease", default_value = "out_expo", value_parser = parse_easing)] + pub explosion_ease: Easing, + + /// Speed of characters during explosion. + #[arg(long = "explosion-speed", default_value_t = 1.0, value_parser = parse_positive_float)] + pub explosion_speed: f64, + + /// Easing function to use for character reassembly. + #[arg(long = "reassembly-ease", default_value = "out_expo", value_parser = parse_easing)] + pub reassembly_ease: Easing, + + /// Speed of characters during reassembly. + #[arg(long = "reassembly-speed", default_value_t = 1.0, value_parser = parse_positive_float)] + pub reassembly_speed: f64, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["8A008A", "00D1FF", "FFFFFF"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + Rumble, + Explosion, + Reassembly, +} + +const DYNAMIC_NEUTRAL_GRAY: &str = "808080"; + +pub struct Unstable { + config: UnstableConfig, + jumbled_coords: HashMap, + character_final_color_map: HashMap, + character_start_color_map: HashMap, + explosion_hold_time: i64, + phase: Phase, + max_rumble_steps: i64, + current_rumble_steps: i64, + rumble_mod_delay: i64, +} + +impl Unstable { + pub fn new(config: UnstableConfig) -> Self { + Unstable { + config, + jumbled_coords: HashMap::new(), + character_final_color_map: HashMap::new(), + character_start_color_map: HashMap::new(), + explosion_hold_time: 30, + phase: Phase::Rumble, + max_rumble_steps: 150, + current_rumble_steps: 0, + rumble_mod_delay: 18, + } + } +} + +fn neutral_gray() -> Color { + Color::from_hex(DYNAMIC_NEUTRAL_GRAY).unwrap() +} + +impl EffectHooks for Unstable { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Unstable { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + let ch = &ctx.terminal.arena[id.0 as usize]; + let (start_colors, final_colors) = if dynamic { + let start_fg_color = + ch.animation.input_fg_color.clone().unwrap_or_else(neutral_gray); + ( + ColorPair::new(Some(start_fg_color), ch.animation.input_bg_color.clone()), + ColorPair::new(ch.animation.input_fg_color.clone(), ch.animation.input_bg_color.clone()), + ) + } else { + let start = ColorPair::new( + Some(final_gradient_mapping.get(&ch.input_coord).unwrap().clone()), + None, + ); + (start.clone(), start) + }; + self.character_start_color_map.insert(id, start_colors); + self.character_final_color_map.insert(id, final_colors); + } + let mut character_coords: Vec = + characters.iter().map(|&id| ctx.terminal.arena[id.0 as usize].input_coord).collect(); + for &id in &characters { + let pos = ctx.rng.randint(0, 3); + let (col, row) = match pos { + 0 => { + let col = ctx.terminal.canvas.left; + let row = ctx.terminal.canvas.random_row(&mut ctx.rng, false); + (col, row) + } + 1 => { + let col = ctx.terminal.canvas.right; + let row = ctx.terminal.canvas.random_row(&mut ctx.rng, false); + (col, row) + } + 2 => { + let col = ctx.terminal.canvas.random_column(&mut ctx.rng, false); + let row = ctx.terminal.canvas.bottom; + (col, row) + } + _ => { + let col = ctx.terminal.canvas.random_column(&mut ctx.rng, false); + let row = ctx.terminal.canvas.top; + (col, row) + } + }; + let jumbled_coord = + character_coords.remove(ctx.rng.randint(0, character_coords.len() as i64 - 1) as usize); + self.jumbled_coords.insert(id, jumbled_coord); + let (input_symbol, input_coord, uses_pre) = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.set_coordinate(jumbled_coord); + let explosion_path = ch + .motion + .new_path( + self.config.explosion_speed, + Some(self.config.explosion_ease), + None, + 0, + false, + "explosion", + ) + .map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&explosion_path) + .unwrap() + .new_waypoint(Coord::new(col, row), None, "") + .map_err(EngineError::Other)?; + let reassembly_path = ch + .motion + .new_path( + self.config.reassembly_speed, + Some(self.config.reassembly_ease), + None, + 0, + false, + "reassembly", + ) + .map_err(EngineError::Other)?; + let input_coord = ch.input_coord; + ch.motion + .paths + .get_mut(&reassembly_path) + .unwrap() + .new_waypoint(input_coord, None, "") + .map_err(EngineError::Other)?; + ch.animation.new_scene(false, None, None, "rumble", uses_pre_of(ch)); + (ch.input_symbol.clone(), input_coord, uses_pre_of(ch)) + }; + let _ = input_coord; + if dynamic { + let start_pair = self.character_start_color_map.get(&id).unwrap().clone(); + let start_fg_color = start_pair.fg_color.clone().unwrap_or_else(neutral_gray); + let start_bg_color = start_pair.bg_color.clone(); + let fg_gradient = + Gradient::with_steps(&[start_fg_color, self.config.unstable_color.clone()], 12, false) + .map_err(EngineError::Other)?; + let bg_gradient = match start_bg_color { + Some(bg) => Some( + Gradient::with_steps(&[bg, self.config.unstable_color.clone()], 12, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("rumble") + .unwrap() + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 10, + Some(&fg_gradient), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + } else { + let final_fg_color = self + .character_final_color_map + .get(&id) + .unwrap() + .fg_color + .clone() + .unwrap_or_else(neutral_gray); + let unstable_gradient = + Gradient::with_steps(&[final_fg_color, self.config.unstable_color.clone()], 12, false) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("rumble") + .unwrap() + .apply_gradient_to_symbols(&[input_symbol.clone()], 10, Some(&unstable_gradient), None) + .map_err(EngineError::Other)?; + } + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "final", uses_pre); + } + if dynamic { + let final_pair = self.character_final_color_map.get(&id).unwrap().clone(); + let final_fg_color = final_pair.fg_color.clone(); + let final_bg_color = final_pair.bg_color.clone(); + if final_fg_color.is_none() && final_bg_color.is_none() { + let fg_gradient = + Gradient::with_steps(&[self.config.unstable_color.clone(), neutral_gray()], 12, false) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut("final").unwrap(); + scene + .apply_gradient_to_symbols(&[input_symbol.clone()], 3, Some(&fg_gradient), None) + .map_err(EngineError::Other)?; + scene + .add_frame( + &input_symbol, + 3, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } else { + let fg_gradient = match &final_fg_color { + Some(fg) => Some( + Gradient::with_steps(&[self.config.unstable_color.clone(), fg.clone()], 12, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &final_bg_color { + Some(bg) => Some( + Gradient::with_steps(&[self.config.unstable_color.clone(), bg.clone()], 12, false) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut("final").unwrap(); + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 3, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + if final_fg_color.is_none() { + scene + .add_frame( + &input_symbol, + 3, + VisualParams { + colors: Some(ColorPair::new(None, final_bg_color)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + } else { + let final_fg_color = self + .character_final_color_map + .get(&id) + .unwrap() + .fg_color + .clone() + .unwrap_or_else(neutral_gray); + let final_color = + Gradient::with_steps(&[self.config.unstable_color.clone(), final_fg_color], 12, false) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("final") + .unwrap() + .apply_gradient_to_symbols(&[input_symbol.clone()], 3, Some(&final_color), None) + .map_err(EngineError::Other)?; + } + ctx.activate_scene(self, id, "rumble"); + if dynamic { + let start_pair = self.character_start_color_map.get(&id).unwrap().clone(); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let input_symbol = ch.input_symbol.clone(); + let uses = ch.uses_input_preexisting_colors; + ch.animation.set_appearance( + &input_symbol, + uses, + Some(&input_symbol.clone()), + Some(start_pair), + ); + } + ctx.terminal.set_character_visibility(id, true); + } + self.explosion_hold_time = 30; + self.phase = Phase::Rumble; + self.max_rumble_steps = 150; + self.current_rumble_steps = 0; + self.rumble_mod_delay = 18; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + let mut next_frame: Option = None; + if self.phase == Phase::Rumble { + if self.current_rumble_steps < self.max_rumble_steps { + if self.current_rumble_steps > 30 && self.current_rumble_steps % self.rumble_mod_delay == 0 { + let row_offset = *ctx.rng.choice(&[-1i64, 0, 1]); + let column_offset = *ctx.rng.choice(&[-1i64, 0, 1]); + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + { + let motion = &mut ctx.terminal.arena[id.0 as usize].motion; + let current = motion.current_coord; + motion.set_coordinate(Coord::new( + current.column + column_offset, + current.row + row_offset, + )); + } + ctx.step_animation(self, id); + } + next_frame = Some(ctx.frame()); + for &id in &characters { + let jumbled = *self.jumbled_coords.get(&id).unwrap(); + ctx.terminal.arena[id.0 as usize].motion.set_coordinate(jumbled); + } + self.rumble_mod_delay -= 1; + self.rumble_mod_delay = self.rumble_mod_delay.max(1); + } else { + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + ctx.step_animation(self, id); + } + next_frame = Some(ctx.frame()); + } + self.current_rumble_steps += 1; + } else { + self.phase = Phase::Explosion; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + ctx.activate_path(self, id, "explosion"); + } + ctx.active_characters.clear(); + ctx.active_characters.extend(characters); + } + } + + if self.phase == Phase::Explosion { + if !ctx.active_characters.is_empty() { + // Upstream iterates the active_characters set (effect_unstable.py:332); + // canonical order is ascending character_id (shim patched to match). + let snapshot: Vec = ctx.active_characters.iter().collect(); + for id in snapshot { + ctx.tick(self, id); + } + let retained: Vec = ctx + .active_characters + .iter() + .filter(|&id| { + let ch = &ctx.terminal.arena[id.0 as usize]; + let explosion_target = ch.motion.paths.get("explosion").unwrap().waypoints[0].coord; + ch.motion.current_coord != explosion_target + }) + .collect(); + ctx.active_characters.clear(); + ctx.active_characters.extend(retained); + next_frame = Some(ctx.frame()); + } else if self.explosion_hold_time != 0 { + // upstream ticks the (empty) active set here: no-op + self.explosion_hold_time -= 1; + next_frame = Some(ctx.frame()); + } else { + self.phase = Phase::Reassembly; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for &id in &characters { + ctx.activate_scene(self, id, "final"); + ctx.active_characters.insert(id); + ctx.activate_path(self, id, "reassembly"); + } + } + } + + if self.phase == Phase::Reassembly && !ctx.active_characters.is_empty() { + // Upstream iterates the active_characters set (effect_unstable.py:354); + // canonical order is ascending character_id (shim patched to match). + let snapshot: Vec = ctx.active_characters.iter().collect(); + for id in snapshot { + ctx.tick(self, id); + } + let retained: Vec = ctx + .active_characters + .iter() + .filter(|&id| { + let ch = &ctx.terminal.arena[id.0 as usize]; + let reassembly_target = ch.motion.paths.get("reassembly").unwrap().waypoints[0].coord; + ch.motion.current_coord != reassembly_target || !ch.animation.active_scene_is_complete() + }) + .collect(); + ctx.active_characters.clear(); + ctx.active_characters.extend(retained); + next_frame = Some(ctx.frame()); + } + + next_frame + } +} + +fn uses_pre_of(ch: &crate::engine::character::EffectCharacter) -> bool { + ch.uses_input_preexisting_colors +} diff --git a/vendor/ttfx/src/effects/vhstape.rs b/vendor/ttfx/src/effects/vhstape.rs new file mode 100644 index 0000000..a3a0300 --- /dev/null +++ b/vendor/ttfx/src/effects/vhstape.rs @@ -0,0 +1,620 @@ +//! vhstape, ported from effects/effect_vhstape.py. +//! +//! The inner VHSTapeIterator.Line class is the `Line` struct; lines live in a +//! Vec indexed by row_index (the upstream `lines` dict has keys 0..n-1 in +//! insertion order), and the wave/glitch line lists hold indices (upstream +//! holds Line objects compared by identity). The glitch budget counts frames +//! (`_glitching_steps_elapsed`) — no clock reads. No observable set iteration +//! beyond the engine-canonical active_characters (docs/ordering-inventory.md). + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_non_negative_ratio, parse_positive_int, +}; +use crate::engine::animation::{ExistingColorHandling, SyncMetric, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; +use crate::utils::pycompat::round_half_even; + +#[derive(Args, Debug, Clone)] +pub struct VhsTapeConfig { + /// Space separated, unquoted, list of colors for the characters when a single line is glitching. Colors are applied in order as an animation. + #[arg(long = "glitch-line-colors", num_args = 1.., value_parser = parse_color, + default_values = ["ffffff", "ff0000", "00ff00", "0000ff", "ffffff"])] + pub glitch_line_colors: Vec, + + /// Space separated, unquoted, list of colors for the characters in lines that are part of the glitch wave. Colors are applied in order as an animation. + #[arg(long = "glitch-wave-colors", num_args = 1.., value_parser = parse_color, + default_values = ["ffffff", "ff0000", "00ff00", "0000ff", "ffffff"])] + pub glitch_wave_colors: Vec, + + /// Space separated, unquoted, list of colors for the characters during the noise phase. + #[arg(long = "noise-colors", num_args = 1.., value_parser = parse_color, + default_values = ["1e1e1f", "3c3b3d", "6d6c70", "a2a1a6", "cbc9cf", "ffffff"])] + pub noise_colors: Vec, + + /// Chance that a line will glitch on any given frame. + #[arg(long = "glitch-line-chance", default_value_t = 0.05, value_parser = parse_non_negative_ratio)] + pub glitch_line_chance: f64, + + /// Chance that all characters will experience noise on any given frame. + #[arg(long = "noise-chance", default_value_t = 0.004, value_parser = parse_non_negative_ratio)] + pub noise_chance: f64, + + /// Total time, frames, that the glitching phase will last. + #[arg(long = "total-glitch-time", default_value_t = 600, value_parser = parse_positive_int)] + pub total_glitch_time: i64, + + /// Space separated, unquoted, list of colors for the final color gradient. If only one color is provided, the characters will be displayed in that color. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["ab48ff", "e7b2b2", "fffebd"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +/// VHSTapeIterator.Line state (methods live on VhsTape for hooks access). +struct Line { + characters: Vec, +} + +/// __next__ phase strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + Glitching, + Noise, + Redraw, + Complete, +} + +pub struct VhsTape { + config: VhsTapeConfig, + lines: Vec, + /// Upstream `active_glitch_wave_top: int | None`. + active_glitch_wave_top: Option, + /// Indices into `lines` (upstream holds Line objects, identity-compared). + active_glitch_wave_lines: Vec, + active_glitch_lines: Vec, + character_stable_color_map: HashMap, + character_final_color_map: HashMap, + glitching_steps_elapsed: i64, + phase: Phase, + to_redraw: Vec, + redrawing: bool, +} + +impl VhsTape { + pub fn new(config: VhsTapeConfig) -> Self { + VhsTape { + config, + lines: Vec::new(), + active_glitch_wave_top: None, + active_glitch_wave_lines: Vec::new(), + active_glitch_lines: Vec::new(), + character_stable_color_map: HashMap::new(), + character_final_color_map: HashMap::new(), + glitching_steps_elapsed: 0, + phase: Phase::Glitching, + to_redraw: Vec::new(), + redrawing: false, + } + } + + /// Line.build_line_effects — one offset/direction/hold_time draw per line, + /// then paths, scenes (snow draws 25x2, final_snow 30x2), and events per + /// character. + fn build_line_effects(&mut self, ctx: &mut EngineCtx, characters: &[CharId]) -> Result<(), EngineError> { + let glitch_line_colors = self.config.glitch_line_colors.clone(); + let snow_chars = ["#", "*", ".", ":"]; + let noise_colors = self.config.noise_colors.clone(); + let offset = ctx.rng.randint(4, 25); + let direction = *ctx.rng.choice(&[-1i64, 1]); + let hold_time = ctx.rng.randint(1, 50); + for &id in characters { + let (input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + (ch.input_coord, ch.input_symbol.clone(), ch.uses_input_preexisting_colors) + }; + let stable_colors = self.character_stable_color_map.get(&id).unwrap().clone(); + let final_colors = self.character_final_color_map.get(&id).unwrap().clone(); + // make glitch and restore waypoints + glitch wave waypoints + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let glitch_path = + ch.motion.new_path(2.0, None, None, hold_time, false, "glitch").map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&glitch_path) + .unwrap() + .new_waypoint( + Coord::new(input_coord.column + offset * direction, input_coord.row), + None, + "glitch", + ) + .map_err(EngineError::Other)?; + let restore_path = + ch.motion.new_path(2.0, None, None, 0, false, "restore").map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&restore_path) + .unwrap() + .new_waypoint(input_coord, None, "restore") + .map_err(EngineError::Other)?; + let mid_path = + ch.motion.new_path(2.0, None, None, 0, false, "glitch_wave_mid").map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&mid_path) + .unwrap() + .new_waypoint(Coord::new(input_coord.column + 8, input_coord.row), None, "glitch_wave_mid") + .map_err(EngineError::Other)?; + let end_path = + ch.motion.new_path(2.0, None, None, 0, false, "glitch_wave_end").map_err(EngineError::Other)?; + ch.motion + .paths + .get_mut(&end_path) + .unwrap() + .new_waypoint(Coord::new(input_coord.column + 14, input_coord.row), None, "glitch_wave_end") + .map_err(EngineError::Other)?; + } + // make glitch scenes + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let base_scn = ch.animation.new_scene(false, None, None, "base", uses_pre); + ch.animation + .scenes + .get_mut(&base_scn) + .unwrap() + .add_frame( + &input_symbol, + 1, + VisualParams { colors: Some(stable_colors.clone()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + let fwd_scn = + ch.animation.new_scene(false, Some(SyncMetric::Step), None, "rgb_glitch_fwd", uses_pre); + let scene = ch.animation.scenes.get_mut(&fwd_scn).unwrap(); + for color in &glitch_line_colors { + scene + .add_frame( + &input_symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + let bwd_scn = + ch.animation.new_scene(false, Some(SyncMetric::Step), None, "rgb_glitch_bwd", uses_pre); + let scene = ch.animation.scenes.get_mut(&bwd_scn).unwrap(); + for color in glitch_line_colors.iter().rev() { + scene + .add_frame( + &input_symbol, + 1, + VisualParams { + colors: Some(ColorPair::new(Some(color.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + ch.animation.new_scene(false, None, None, "snow", uses_pre); + } + for _ in 0..25 { + let symbol = *ctx.rng.choice(&snow_chars); + let color = ctx.rng.choice(&noise_colors).clone(); + ctx.terminal.arena[id.0 as usize] + .animation + .scenes + .get_mut("snow") + .unwrap() + .add_frame( + symbol, + 2, + VisualParams { colors: Some(ColorPair::new(Some(color), None)), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut("snow") + .unwrap() + .add_frame( + &input_symbol, + 1, + VisualParams { colors: Some(stable_colors.clone()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + ch.animation.new_scene(false, None, None, "final_snow", uses_pre); + let redraw_scn = ch.animation.new_scene(false, None, None, "final_redraw", uses_pre); + let scene = ch.animation.scenes.get_mut(&redraw_scn).unwrap(); + scene + .add_frame( + "█", + 6, + VisualParams { + colors: Some(ColorPair::new(Some(Color::from_hex("ffffff").unwrap()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + scene + .add_frame(&input_symbol, 1, VisualParams { colors: Some(final_colors), ..Default::default() }) + .map_err(EngineError::Other)?; + } + for _ in 0..30 { + let symbol = *ctx.rng.choice(&snow_chars); + let color = ctx.rng.choice(&noise_colors).clone(); + ctx.terminal.arena[id.0 as usize] + .animation + .scenes + .get_mut("final_snow") + .unwrap() + .add_frame( + symbol, + 2, + VisualParams { colors: Some(ColorPair::new(Some(color), None)), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + // register events + ctx.register_event( + id, + Event::PathComplete, + CallerKey::Path("glitch".to_string()), + EventAction::ActivatePath("restore".to_string()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path("glitch".to_string()), + EventAction::ActivateScene("rgb_glitch_fwd".to_string()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path("restore".to_string()), + EventAction::ActivateScene("rgb_glitch_bwd".to_string()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path("glitch_wave_mid".to_string()), + EventAction::ActivateScene("rgb_glitch_fwd".to_string()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::PathActivated, + CallerKey::Path("glitch_wave_end".to_string()), + EventAction::ActivateScene("rgb_glitch_fwd".to_string()), + ) + .map_err(EngineError::Other)?; + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene("rgb_glitch_bwd".to_string()), + EventAction::ActivateScene("base".to_string()), + ) + .map_err(EngineError::Other)?; + } + Ok(()) + } + + /// Line.snow. + fn line_snow(&mut self, ctx: &mut EngineCtx, idx: usize) { + let characters = self.lines[idx].characters.clone(); + for id in characters { + ctx.activate_scene(self, id, "snow"); + } + } + + /// Line.set_hold_time. + fn line_set_hold_time(&mut self, ctx: &mut EngineCtx, idx: usize, hold_time: i64) { + for &id in &self.lines[idx].characters { + ctx.terminal.arena[id.0 as usize].motion.paths.get_mut("glitch").expect("glitch path").hold_time = + hold_time; + } + } + + /// Line.glitch. + fn line_glitch(&mut self, ctx: &mut EngineCtx, idx: usize, final_: bool) { + let characters = self.lines[idx].characters.clone(); + for id in characters { + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + if final_ { + ch.motion.paths.get_mut("glitch").expect("glitch path").hold_time = 0; + ch.motion.paths.get_mut("restore").expect("restore path").hold_time = 0; + } + } + let glitch_speed = 40.0 / ctx.rng.randint(20, 40) as f64; + let restore_speed = 40.0 / ctx.rng.randint(20, 40) as f64; + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.paths.get_mut("glitch").expect("glitch path").speed = glitch_speed; + ch.motion.paths.get_mut("restore").expect("restore path").speed = restore_speed; + } + ctx.activate_path(self, id, "glitch"); + } + } + + /// Line.restore. + fn line_restore(&mut self, ctx: &mut EngineCtx, idx: usize) { + let characters = self.lines[idx].characters.clone(); + for id in characters { + let restore_speed = 40.0 / ctx.rng.randint(20, 40) as f64; + ctx.terminal.arena[id.0 as usize].motion.paths.get_mut("restore").expect("restore path").speed = + restore_speed; + ctx.activate_path(self, id, "restore"); + } + } + + /// Line.activate_path. + fn line_activate_path(&mut self, ctx: &mut EngineCtx, idx: usize, path_id: &str) { + let characters = self.lines[idx].characters.clone(); + for id in characters { + ctx.activate_path(self, id, path_id); + } + } + + /// Line.line_movement_complete. + fn line_movement_complete(&self, ctx: &EngineCtx, idx: usize) -> bool { + self.lines[idx].characters.iter().all(|&id| ctx.terminal.arena[id.0 as usize].motion.movement_is_complete()) + } + + fn insert_line_characters(&self, ctx: &mut EngineCtx, idx: usize) { + for &id in &self.lines[idx].characters { + ctx.active_characters.insert(id); + } + } + + /// VHSTapeIterator.glitch_wave. + fn glitch_wave(&mut self, ctx: &mut EngineCtx) { + // Python falsy check: None (0 is unreachable after the max(2, ...) clamp + // but kept falsy for exactness). + if matches!(self.active_glitch_wave_top, None | Some(0)) { + if ctx.terminal.canvas.text_height >= 3 { + // choose a wave top index in the top half of the canvas or at least 3 rows up + let lower = std::cmp::max(3, round_half_even(ctx.terminal.canvas.text_height as f64 * 0.5)); + self.active_glitch_wave_top = Some( + ctx.terminal.canvas.text_bottom + ctx.rng.randint(lower, ctx.terminal.canvas.text_height), + ); + } else { + // not enough room for a wave + return; + } + } + + // if all lines have completed movement, proceed to move/restore wave + let all_complete = + self.active_glitch_wave_lines.iter().all(|&idx| self.line_movement_complete(ctx, idx)); + if all_complete { + if !self.active_glitch_wave_lines.is_empty() { + // only move 30% of the time — the OUTER conditional's condition is + // evaluated first (second textual random() call in the source) + let should_move = ctx.rng.random() < 0.3; + let wave_top_delta = + if should_move { if ctx.rng.random() < 0.3 { 1 } else { -1 } } else { 0 }; + let mut top = self.active_glitch_wave_top.unwrap() + wave_top_delta; + // clamp wave top to canvas + top = std::cmp::max(2, std::cmp::min(top, ctx.terminal.canvas.text_top)); + self.active_glitch_wave_top = Some(top); + } + // get the lines for the wave + let wave_top = self.active_glitch_wave_top.unwrap(); + let mut new_wave_lines: Vec = Vec::new(); + for line_index in (wave_top - 2)..=wave_top { + let adjusted_line_index = line_index - (ctx.terminal.canvas.text_bottom - 1); + if adjusted_line_index >= 0 && (adjusted_line_index as usize) < self.lines.len() { + new_wave_lines.push(adjusted_line_index as usize); + } + } + + // restore any lines that are no longer part of the wave + let old_wave_lines = std::mem::take(&mut self.active_glitch_wave_lines); + for &idx in &old_wave_lines { + if !new_wave_lines.contains(&idx) { + self.line_restore(ctx, idx); + self.insert_line_characters(ctx, idx); + } + } + self.active_glitch_wave_lines = new_wave_lines; + + if wave_top < ctx.terminal.canvas.text_bottom + 2 { + // wave at bottom, restore lines + let wave_lines = std::mem::take(&mut self.active_glitch_wave_lines); + for &idx in &wave_lines { + self.line_restore(ctx, idx); + self.insert_line_characters(ctx, idx); + } + self.active_glitch_wave_top = None; + } else { + let path_ids = ["glitch_wave_mid", "glitch_wave_end", "glitch_wave_mid"]; + let wave_lines = self.active_glitch_wave_lines.clone(); + for (idx, path_id) in wave_lines.iter().zip(path_ids.iter()) { + self.line_activate_path(ctx, *idx, path_id); + self.insert_line_characters(ctx, *idx); + } + } + } + } +} + +impl EffectHooks for VhsTape { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for VhsTape { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let ch = &ctx.terminal.arena[id.0 as usize]; + if dynamic { + let input_fg = ch.animation.input_fg_color.clone(); + let input_bg = ch.animation.input_bg_color.clone(); + // DYNAMIC_NEUTRAL_GRAY + let stable_fg = input_fg.clone().or_else(|| Some(Color::from_hex("808080").unwrap())); + self.character_stable_color_map.insert(id, ColorPair::new(stable_fg, input_bg.clone())); + self.character_final_color_map.insert(id, ColorPair::new(input_fg, input_bg)); + } else { + let gradient_color = final_gradient_mapping.get(&ch.input_coord).unwrap().clone(); + let stable_colors = ColorPair::new(Some(gradient_color), None); + self.character_stable_color_map.insert(id, stable_colors.clone()); + self.character_final_color_map.insert(id, stable_colors); + } + } + let rows = ctx + .terminal + .get_characters_grouped(CharacterFilter::default(), CharacterGroup::RowBottomToTop); + for characters in rows { + self.build_line_effects(ctx, &characters)?; + self.lines.push(Line { characters }); + } + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + ctx.terminal.set_character_visibility(id, true); + ctx.activate_scene(self, id, "base"); + } + self.glitching_steps_elapsed = 0; + self.phase = Phase::Glitching; + self.to_redraw = (0..self.lines.len()).collect(); + self.redrawing = false; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.phase == Phase::Complete && ctx.active_characters.is_empty() { + return None; + } + match self.phase { + Phase::Glitching => { + // Check if all active glitch wave lines have completed their movement, if so move the wave + if self.active_glitch_wave_lines.is_empty() + || self.active_glitch_wave_lines.iter().all(|&idx| self.line_movement_complete(ctx, idx)) + { + self.glitch_wave(ctx); + } + // Remove completed glitch lines from active glitch lines + let glitch_lines = std::mem::take(&mut self.active_glitch_lines); + self.active_glitch_lines = + glitch_lines.into_iter().filter(|&idx| !self.line_movement_complete(ctx, idx)).collect(); + // Randomly add new glitch lines + if ctx.rng.random() < self.config.glitch_line_chance && self.active_glitch_lines.len() < 3 { + let glitch_line = ctx.rng.choice_index(self.lines.len()); + if !self.active_glitch_wave_lines.contains(&glitch_line) + && !self.active_glitch_lines.contains(&glitch_line) + { + let hold_time = ctx.rng.randint(20, 75); + self.line_set_hold_time(ctx, glitch_line, hold_time); + self.active_glitch_lines.push(glitch_line); + self.line_glitch(ctx, glitch_line, false); + self.insert_line_characters(ctx, glitch_line); + } + } + // Randomly add noise to all lines + if ctx.rng.random() < self.config.noise_chance { + for idx in 0..self.lines.len() { + self.line_snow(ctx, idx); + if !self.active_glitch_wave_lines.contains(&idx) + && !self.active_glitch_lines.contains(&idx) + { + self.insert_line_characters(ctx, idx); + } + } + } + self.glitching_steps_elapsed += 1; + // Check if glitching time has reached the total glitch time + if self.glitching_steps_elapsed >= self.config.total_glitch_time { + // Restore glitch wave lines + let wave_lines = self.active_glitch_wave_lines.clone(); + for idx in wave_lines { + self.line_restore(ctx, idx); + } + // Restore glitch lines + let glitch_lines = self.active_glitch_lines.clone(); + for idx in glitch_lines { + self.line_restore(ctx, idx); + } + self.phase = Phase::Noise; + } + } + Phase::Noise => { + // Activate final snow animation for all characters + if ctx.active_characters.is_empty() { + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + ctx.activate_scene(self, id, "final_snow"); + ctx.active_characters.insert(id); + } + self.phase = Phase::Redraw; + } + } + Phase::Redraw => { + // Redraw lines one by one + if self.redrawing || ctx.active_characters.is_empty() { + self.redrawing = true; + if let Some(next_line) = self.to_redraw.pop() { + let characters = self.lines[next_line].characters.clone(); + for id in characters { + ctx.activate_scene(self, id, "final_redraw"); + ctx.active_characters.insert(id); + } + } else { + self.phase = Phase::Complete; + } + } + } + Phase::Complete => {} + } + ctx.update(self); + Some(ctx.frame()) + } +} diff --git a/vendor/ttfx/src/effects/waves.rs b/vendor/ttfx/src/effects/waves.rs new file mode 100644 index 0000000..e7ac7ae --- /dev/null +++ b/vendor/ttfx/src/effects/waves.rs @@ -0,0 +1,282 @@ +//! waves, ported from effects/effect_waves.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_easing, parse_gradient_direction, parse_gradient_steps, parse_positive_int, parse_symbol, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::easing::Easing; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +/// waves --wave-direction choices (a subset of CharacterGroup, upstream grouping_map). +fn parse_wave_direction(s: &str) -> Result { + Ok(match s { + "column_left_to_right" => CharacterGroup::ColumnLeftToRight, + "column_right_to_left" => CharacterGroup::ColumnRightToLeft, + "row_top_to_bottom" => CharacterGroup::RowTopToBottom, + "row_bottom_to_top" => CharacterGroup::RowBottomToTop, + "center_to_outside" => CharacterGroup::CenterToOutside, + "outside_to_center" => CharacterGroup::OutsideToCenter, + _ => return Err(format!("invalid wave direction: '{s}'")), + }) +} + +#[derive(Args, Debug, Clone)] +pub struct WavesConfig { + /// Symbols to use for the wave animation. + #[arg(long = "wave-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁"])] + pub wave_symbols: Vec, + + /// Space separated, unquoted, list of colors for the character gradient (applied across the canvas). + #[arg(long = "wave-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["f0ff65", "ffb102", "31a0d4", "ffb102", "f0ff65"])] + pub wave_gradient_stops: Vec, + + /// Space separated, unquoted, list of the number of gradient steps to use. + #[arg(long = "wave-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["6"])] + pub wave_gradient_steps: Vec, + + /// Number of waves to generate. n > 0. + #[arg(long = "wave-count", default_value_t = 7, value_parser = parse_positive_int)] + pub wave_count: i64, + + /// The number of frames for each step of the wave. + #[arg(long = "wave-length", default_value_t = 2, value_parser = parse_positive_int)] + pub wave_length: i64, + + /// Direction of the wave. + #[arg(long = "wave-direction", default_value = "column_left_to_right", value_parser = parse_wave_direction)] + pub wave_direction: CharacterGroup, + + /// Easing function to use for wave travel. + #[arg(long = "wave-easing", default_value = "in_out_sine", value_parser = parse_easing)] + pub wave_easing: Easing, + + /// Space separated, unquoted, list of colors for the final color gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["ffb102", "31a0d4", "f0ff65"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Waves { + config: WavesConfig, + pending_columns: Vec>, + character_final_color_map: HashMap, +} + +impl Waves { + pub fn new(config: WavesConfig) -> Self { + Waves { config, pending_columns: Vec::new(), character_final_color_map: HashMap::new() } + } +} + +impl EffectHooks for Waves { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Waves { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let wave_gradient = + Gradient::new(&self.config.wave_gradient_stops, &self.config.wave_gradient_steps, false, false) + .map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_fg, input_bg, input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.input_coord, + ch.input_symbol.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors.clone()); + + let wave_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene_id = ch.animation.new_scene(false, None, Some(self.config.wave_easing), "", uses_pre); + let scene = ch.animation.scenes.get_mut(&scene_id).unwrap(); + for _ in 0..self.config.wave_count { + scene + .apply_gradient_to_symbols( + &self.config.wave_symbols, + self.config.wave_length, + Some(&wave_gradient), + None, + ) + .map_err(EngineError::Other)?; + } + scene_id + }; + let final_scn = { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "", uses_pre) + }; + if dynamic { + let final_fg_color = final_colors.fg_color.clone(); + let final_bg_color = final_colors.bg_color.clone(); + if final_fg_color.is_none() && final_bg_color.is_none() { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation + .scenes + .get_mut(&final_scn) + .unwrap() + .add_frame( + &input_symbol, + 10, + VisualParams { colors: Some(ColorPair::default()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } else { + let fg_gradient = match &final_fg_color { + Some(c) => Some( + Gradient::new( + &[wave_gradient.spectrum.last().unwrap().clone(), c.clone()], + &self.config.final_gradient_steps, + false, + false, + ) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let bg_gradient = match &final_bg_color { + Some(c) => Some( + Gradient::new( + &[wave_gradient.spectrum.last().unwrap().clone(), c.clone()], + &self.config.final_gradient_steps, + false, + false, + ) + .map_err(EngineError::Other)?, + ), + None => None, + }; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&final_scn).unwrap(); + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + 10, + fg_gradient.as_ref(), + bg_gradient.as_ref(), + ) + .map_err(EngineError::Other)?; + if final_fg_color.is_none() { + scene + .add_frame( + &input_symbol, + 10, + VisualParams { + colors: Some(ColorPair::new(None, final_bg_color.clone())), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + } else { + let final_fg_color = + final_colors.fg_color.clone().expect("gradient mapping fg"); + let final_scene_gradient = Gradient::new( + &[wave_gradient.spectrum.last().unwrap().clone(), final_fg_color], + &self.config.final_gradient_steps, + false, + false, + ) + .map_err(EngineError::Other)?; + let ch = &mut ctx.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.get_mut(&final_scn).unwrap(); + for step in &final_scene_gradient.spectrum { + scene + .add_frame( + &input_symbol, + 10, + VisualParams { + colors: Some(ColorPair::new(Some(step.clone()), None)), + ..Default::default() + }, + ) + .map_err(EngineError::Other)?; + } + } + ctx.register_event( + id, + Event::SceneComplete, + CallerKey::Scene(wave_scn.clone()), + EventAction::ActivateScene(final_scn), + ) + .map_err(EngineError::Other)?; + ctx.activate_scene(self, id, &wave_scn); + if dynamic { + let final_colors = self.character_final_color_map.get(&id).unwrap().clone(); + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.set_appearance(&input_symbol, uses_pre, Some(&input_symbol), Some(final_colors)); + } + } + + for column in ctx.terminal.get_characters_grouped(CharacterFilter::default(), self.config.wave_direction) { + self.pending_columns.push(column); + } + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if !self.pending_columns.is_empty() || !ctx.active_characters.is_empty() { + if !self.pending_columns.is_empty() { + let next_column = self.pending_columns.remove(0); + for id in next_column { + ctx.terminal.set_character_visibility(id, true); + ctx.active_characters.insert(id); + } + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/effects/wipe.rs b/vendor/ttfx/src/effects/wipe.rs new file mode 100644 index 0000000..d25bebf --- /dev/null +++ b/vendor/ttfx/src/effects/wipe.rs @@ -0,0 +1,187 @@ +//! wipe, ported from effects/effect_wipe.py. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_character_group, parse_easing, parse_gradient_direction, parse_gradient_steps, parse_non_negative_int, +}; +use crate::engine::animation::{ExistingColorHandling, VisualParams}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterGroup, CharacterSort}; +use crate::utils::easing::{Easing, SequenceEaser}; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct WipeConfig { + /// Direction the text will wipe. + #[arg(long = "wipe-direction", default_value = "diagonal_top_left_to_bottom_right", + value_parser = parse_character_group)] + pub wipe_direction: CharacterGroup, + + /// Number of frames to wait before adding the next character group. + #[arg(long = "wipe-delay", default_value_t = 0, value_parser = parse_non_negative_int)] + pub wipe_delay: i64, + + /// Easing function to use for the wipe effect. + #[arg(long = "wipe-ease", default_value = "in_out_circ", value_parser = parse_easing)] + pub wipe_ease: Easing, + + /// Space separated, unquoted, list of colors for the wipe gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["833ab4", "fd1d1d", "fcb045"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Number of frames to display each gradient step. + #[arg(long = "final-gradient-frames", default_value_t = 3)] + pub final_gradient_frames: i64, + + /// Direction of the final gradient. + #[arg(long = "final-gradient-direction", default_value = "vertical", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct Wipe { + config: WipeConfig, + character_final_color_map: HashMap, + easer: Option>>, + wipe_delay: i64, +} + +impl Wipe { + pub fn new(config: WipeConfig) -> Self { + let wipe_delay = config.wipe_delay; + Wipe { config, character_final_color_map: HashMap::new(), easer: None, wipe_delay } + } +} + +impl EffectHooks for Wipe { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Wipe { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let groups = ctx.terminal.get_characters_grouped(CharacterFilter::default(), self.config.wipe_direction); + self.easer = Some(SequenceEaser::new(groups, self.config.wipe_ease, 100)); + + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_gradient_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = { + let filter = CharacterFilter::default(); + ctx.terminal.get_characters(&mut ctx.rng, filter, CharacterSort::TopToBottomLeftToRight) + }; + for id in characters { + let (input_fg, input_bg, input_coord, input_symbol, uses_pre) = { + let ch = &ctx.terminal.arena[id.0 as usize]; + ( + ch.animation.input_fg_color.clone(), + ch.animation.input_bg_color.clone(), + ch.input_coord, + ch.input_symbol.clone(), + ch.uses_input_preexisting_colors, + ) + }; + let final_colors = if dynamic { + ColorPair::new(input_fg, input_bg) + } else { + ColorPair::new(Some(final_gradient_mapping.get(&input_coord).unwrap().clone()), None) + }; + self.character_final_color_map.insert(id, final_colors.clone()); + + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.animation.new_scene(false, None, None, "wipe", uses_pre); + let scene = ch.animation.scenes.get_mut("wipe").unwrap(); + if dynamic { + let frame_count: i64 = self.config.final_gradient_steps.iter().sum::() + 1; + for _ in 0..frame_count { + scene + .add_frame( + &input_symbol, + self.config.final_gradient_frames, + VisualParams { colors: Some(final_colors.clone()), ..Default::default() }, + ) + .map_err(EngineError::Other)?; + } + } else { + let final_fg = final_colors.fg_color.clone().expect("gradient mapping fg"); + let wipe_gradient = Gradient::new( + &[final_gradient.spectrum[0].clone(), final_fg], + &self.config.final_gradient_steps, + false, + false, + ) + .map_err(EngineError::Other)?; + scene + .apply_gradient_to_symbols( + &[input_symbol.clone()], + self.config.final_gradient_frames, + Some(&wipe_gradient), + None, + ) + .map_err(EngineError::Other)?; + } + } + } + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + let easer_complete = self.easer.as_ref().unwrap().is_complete(); + if !ctx.active_characters.is_empty() || !easer_complete { + if self.wipe_delay == 0 { + let mut easer = self.easer.take().unwrap(); + let step = easer.step(); + for group in step.added { + for &id in group { + ctx.activate_scene(self, id, "wipe"); + ctx.terminal.set_character_visibility(id, true); + ctx.active_characters.insert(id); + } + } + for group in step.removed { + for &id in group { + ctx.deactivate_scene(id, None); + ctx.terminal.arena[id.0 as usize] + .animation + .scenes + .get_mut("wipe") + .unwrap() + .reset_scene(); + ctx.terminal.set_character_visibility(id, false); + } + } + self.easer = Some(easer); + self.wipe_delay = self.config.wipe_delay; + } else { + self.wipe_delay -= 1; + } + ctx.update(self); + return Some(ctx.frame()); + } + None + } +} diff --git a/vendor/ttfx/src/engine/active_characters.rs b/vendor/ttfx/src/engine/active_characters.rs new file mode 100644 index 0000000..89d67cc --- /dev/null +++ b/vendor/ttfx/src/engine/active_characters.rs @@ -0,0 +1,363 @@ +//! Dense ordered set for active arena characters. +//! +//! `CharId` is a dense arena index, so a packed bitmap avoids the allocation +//! and pointer chasing of a tree while its set bits naturally iterate in +//! ascending id order. + +use std::iter::FromIterator; + +use crate::engine::character::CharId; + +const WORD_BITS: usize = u64::BITS as usize; +const PROMOTE_LEN: usize = 128; +const DEMOTE_LEN: usize = 64; + +#[derive(Clone, Default)] +pub struct ActiveCharacters { + sparse: Vec, + words: Vec, + len: usize, + dense: bool, +} + +impl ActiveCharacters { + #[inline] + pub const fn new() -> Self { + Self { + sparse: Vec::new(), + words: Vec::new(), + len: 0, + dense: false, + } + } + + #[inline] + pub fn len(&self) -> usize { + self.len + } + + #[inline] + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + #[inline] + pub fn clear(&mut self) { + self.sparse.clear(); + self.words.clear(); + self.len = 0; + self.dense = false; + } + + #[inline] + pub fn contains(&self, id: &CharId) -> bool { + if !self.dense { + return self.sparse.binary_search(id).is_ok(); + } + let index = id.0 as usize; + self.words + .get(index / WORD_BITS) + .is_some_and(|word| word & (1 << (index % WORD_BITS)) != 0) + } + + #[inline] + pub fn insert(&mut self, id: CharId) -> bool { + if !self.dense { + let Err(index) = self.sparse.binary_search(&id) else { + return false; + }; + self.sparse.insert(index, id); + self.len += 1; + if self.len == PROMOTE_LEN { + self.promote(); + } + return true; + } + let index = id.0 as usize; + let word_index = index / WORD_BITS; + if word_index >= self.words.len() { + self.words.resize(word_index + 1, 0); + } + let bit = 1 << (index % WORD_BITS); + let word = &mut self.words[word_index]; + if *word & bit != 0 { + return false; + } + *word |= bit; + self.len += 1; + true + } + + #[inline] + pub fn remove(&mut self, id: &CharId) -> bool { + if !self.dense { + let Ok(index) = self.sparse.binary_search(id) else { + return false; + }; + self.sparse.remove(index); + self.len -= 1; + return true; + } + let index = id.0 as usize; + let word_index = index / WORD_BITS; + let Some(word) = self.words.get_mut(word_index) else { + return false; + }; + let bit = 1 << (index % WORD_BITS); + if *word & bit == 0 { + return false; + } + *word &= !bit; + self.len -= 1; + if word_index + 1 == self.words.len() { + while self.words.last() == Some(&0) { + self.words.pop(); + } + } + if self.len == DEMOTE_LEN { + self.demote(); + } + true + } + + #[inline] + pub fn iter(&self) -> Iter<'_> { + Iter { + inner: if self.dense { + IterInner::Dense { + words: self.words.iter().enumerate(), + remaining: 0, + word_index: 0, + } + } else { + IterInner::Sparse(self.sparse.iter()) + }, + len: self.len, + } + } + + /// Retains elements in the same ascending order in which `BTreeSet` + /// invokes its predicate. + pub fn retain(&mut self, mut keep: impl FnMut(&CharId) -> bool) { + if !self.dense { + self.sparse.retain(|id| keep(id)); + self.len = self.sparse.len(); + return; + } + let mut removed = 0; + for (word_index, word) in self.words.iter_mut().enumerate() { + let mut candidates = *word; + while candidates != 0 { + let bit_index = candidates.trailing_zeros() as usize; + let bit = 1 << bit_index; + let id = CharId((word_index * WORD_BITS + bit_index) as u32); + if !keep(&id) { + *word &= !bit; + removed += 1; + } + candidates &= candidates - 1; + } + } + self.len -= removed; + while self.words.last() == Some(&0) { + self.words.pop(); + } + if self.len <= DEMOTE_LEN { + self.demote(); + } + } + + fn promote(&mut self) { + let word_len = self + .sparse + .last() + .map_or(0, |id| id.0 as usize / WORD_BITS + 1); + self.words.clear(); + self.words.resize(word_len, 0); + for id in self.sparse.drain(..) { + let index = id.0 as usize; + self.words[index / WORD_BITS] |= 1 << (index % WORD_BITS); + } + self.dense = true; + } + + fn demote(&mut self) { + self.sparse.clear(); + self.sparse.reserve(self.len); + for (word_index, &word) in self.words.iter().enumerate() { + let mut remaining = word; + while remaining != 0 { + let bit_index = remaining.trailing_zeros() as usize; + self.sparse + .push(CharId((word_index * WORD_BITS + bit_index) as u32)); + remaining &= remaining - 1; + } + } + self.words.clear(); + self.dense = false; + } +} + +pub struct Iter<'a> { + inner: IterInner<'a>, + len: usize, +} + +enum IterInner<'a> { + Sparse(std::slice::Iter<'a, CharId>), + Dense { + words: std::iter::Enumerate>, + remaining: u64, + word_index: usize, + }, +} + +impl Iterator for Iter<'_> { + type Item = CharId; + + #[inline] + fn next(&mut self) -> Option { + let id = match &mut self.inner { + IterInner::Sparse(ids) => ids.next().copied(), + IterInner::Dense { + words, + remaining, + word_index, + } => loop { + if *remaining != 0 { + let bit_index = remaining.trailing_zeros() as usize; + *remaining &= *remaining - 1; + break Some(CharId((*word_index * WORD_BITS + bit_index) as u32)); + } + let Some((next_word_index, &word)) = words.next() else { + break None; + }; + *word_index = next_word_index; + *remaining = word; + }, + }; + if id.is_some() { + self.len -= 1; + } + id + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (self.len, Some(self.len)) + } +} + +impl ExactSizeIterator for Iter<'_> {} + +impl std::iter::FusedIterator for Iter<'_> {} + +impl std::fmt::Debug for ActiveCharacters { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_set().entries(self.iter()).finish() + } +} + +impl PartialEq for ActiveCharacters { + fn eq(&self, other: &Self) -> bool { + self.len == other.len && self.iter().eq(other.iter()) + } +} + +impl Eq for ActiveCharacters {} + +impl<'a> IntoIterator for &'a ActiveCharacters { + type Item = CharId; + type IntoIter = Iter<'a>; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +impl Extend for ActiveCharacters { + fn extend>(&mut self, iter: T) { + for id in iter { + self.insert(id); + } + } +} + +impl FromIterator for ActiveCharacters { + fn from_iter>(iter: T) -> Self { + let mut characters = Self::new(); + characters.extend(iter); + characters + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn preserves_order_and_set_semantics_across_word_boundaries() { + let mut active: ActiveCharacters = + [CharId(130), CharId(1), CharId(64), CharId(63), CharId(1)] + .into_iter() + .collect(); + assert_eq!(active.len(), 4); + assert_eq!( + active.iter().collect::>(), + [CharId(1), CharId(63), CharId(64), CharId(130)] + ); + assert!(active.contains(&CharId(64))); + assert!(!active.insert(CharId(64))); + assert!(active.remove(&CharId(130))); + assert!(!active.remove(&CharId(130))); + + let mut visited = Vec::new(); + active.retain(|id| { + visited.push(*id); + id.0 % 2 == 1 + }); + assert_eq!(visited, [CharId(1), CharId(63), CharId(64)]); + assert_eq!(active.iter().collect::>(), [CharId(1), CharId(63)]); + + active.clear(); + assert!(active.is_empty()); + assert_eq!(active.iter().len(), 0); + } + + #[test] + fn matches_btree_set_through_mixed_operations() { + let mut active = ActiveCharacters::new(); + let mut reference = BTreeSet::new(); + let mut state = 0x9e37_79b9_u32; + + for step in 0..10_000_u32 { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let id = CharId(state % 4096); + match state >> 29 { + 0..=3 => assert_eq!(active.insert(id), reference.insert(id)), + 4..=5 => assert_eq!(active.remove(&id), reference.remove(&id)), + 6 => assert_eq!(active.contains(&id), reference.contains(&id)), + _ => { + let modulus = step % 7 + 2; + active.retain(|id| id.0 % modulus != 0); + reference.retain(|id| id.0 % modulus != 0); + } + } + + if step % 97 == 0 { + assert_eq!(active.len(), reference.len()); + assert_eq!(active.is_empty(), reference.is_empty()); + assert_eq!( + active.iter().collect::>(), + reference.iter().copied().collect::>() + ); + } + if step % 997 == 0 { + active.clear(); + reference.clear(); + } + } + } +} diff --git a/vendor/ttfx/src/engine/animation.rs b/vendor/ttfx/src/engine/animation.rs new file mode 100644 index 0000000..b0eeec8 --- /dev/null +++ b/vendor/ttfx/src/engine/animation.rs @@ -0,0 +1,639 @@ +//! CharacterVisual, Frame, Scene, and Animation, ported from engine/animation.py. +//! Scene/Animation stepping that fires events lives on EngineCtx (ctx.rs); +//! everything here is state plus event-free logic. + +use std::collections::VecDeque; +use std::rc::Rc; + +use crate::utils::ansi::{self, ColorCode}; +use crate::utils::easing::Easing; +use crate::utils::graphics::{Color, ColorPair, Gradient}; +use crate::utils::hexterm; +use crate::utils::ordered_map::OrderedMap; + +/// Handling of preexisting SGR colors in the input (TerminalConfig option). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExistingColorHandling { + Always, + Dynamic, + Ignore, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncMetric { + Distance, + Step, +} + +thread_local! { + /// Reused assembly buffer for CharacterVisual::new's SGR string. + static FORMAT_SCRATCH: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; +} + +/// Inline capacity for a formatted symbol. A 24-bit foreground and background +/// pair plus a reset is 42 bytes, so all but pathological styling fits. +const INLINE_SYMBOL_CAPACITY: usize = 63; + +/// The precomputed ANSI string for one cell, stored inline when it fits. +/// +/// The frame writer emits one of these per visible cell — millions of times +/// over a run — and a `str` copy of a couple of dozen bytes is dominated by the +/// memcpy call itself. An inline buffer of fixed size lets the writer copy the +/// whole block unconditionally and then advance by the real length. +#[derive(Debug, Clone)] +pub enum FormattedSymbol { + Inline { bytes: [u8; INLINE_SYMBOL_CAPACITY], len: u8 }, + Heap(Box), +} + +impl FormattedSymbol { + fn new(text: &str) -> Self { + if text.len() <= INLINE_SYMBOL_CAPACITY { + let mut bytes = [0u8; INLINE_SYMBOL_CAPACITY]; + bytes[..text.len()].copy_from_slice(text.as_bytes()); + FormattedSymbol::Inline { bytes, len: text.len() as u8 } + } else { + FormattedSymbol::Heap(text.into()) + } + } + + #[inline] + pub fn as_str(&self) -> &str { + match self { + FormattedSymbol::Inline { bytes, len } => { + // SAFETY: built from a &str prefix, so the range is valid UTF-8. + unsafe { std::str::from_utf8_unchecked(&bytes[..*len as usize]) } + } + FormattedSymbol::Heap(text) => text, + } + } + + /// Append to a UTF-8 byte buffer, copying the whole inline block in one go. + #[inline] + pub fn append_to(&self, out: &mut Vec) { + match self { + FormattedSymbol::Inline { bytes, len } => { + if out.len() + INLINE_SYMBOL_CAPACITY > out.capacity() { + out.reserve(INLINE_SYMBOL_CAPACITY); + } + let start = out.len(); + // SAFETY: the reserve above guarantees room for the whole block; + // only the first `len` bytes are published as initialized. + unsafe { + std::ptr::copy_nonoverlapping( + bytes.as_ptr(), + out.as_mut_ptr().add(start), + INLINE_SYMBOL_CAPACITY, + ); + out.set_len(start + *len as usize); + } + } + FormattedSymbol::Heap(text) => out.extend_from_slice(text.as_bytes()), + } + } +} + +impl PartialEq for FormattedSymbol { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } +} + +/// animation.CharacterVisual with the formatted ANSI string precomputed. +#[derive(Debug, Clone, PartialEq)] +pub struct CharacterVisual { + pub symbol: String, + pub bold: bool, + pub dim: bool, // stored but never emitted, faithfully + pub italic: bool, + pub underline: bool, + pub blink: bool, + pub reverse: bool, + pub hidden: bool, + pub strike: bool, + pub colors: Option, + pub fg_color_code: Option, + pub bg_color_code: Option, + pub formatted_symbol: FormattedSymbol, +} + +#[derive(Debug, Clone, Default)] +pub struct VisualParams { + pub bold: bool, + pub dim: bool, + pub italic: bool, + pub underline: bool, + pub blink: bool, + pub reverse: bool, + pub hidden: bool, + pub strike: bool, + pub colors: Option, + pub fg_color_code: Option, + pub bg_color_code: Option, +} + +impl CharacterVisual { + pub fn new(symbol: &str, p: VisualParams) -> Self { + let mut vis = CharacterVisual { + symbol: symbol.to_string(), + bold: p.bold, + dim: p.dim, + italic: p.italic, + underline: p.underline, + blink: p.blink, + reverse: p.reverse, + hidden: p.hidden, + strike: p.strike, + colors: p.colors, + fg_color_code: p.fg_color_code, + bg_color_code: p.bg_color_code, + formatted_symbol: FormattedSymbol::Inline { bytes: [0; INLINE_SYMBOL_CAPACITY], len: 0 }, + }; + // Effects rebuild visuals every frame, so the SGR string is assembled in + // a reused scratch buffer rather than a fresh allocation per visual. + FORMAT_SCRATCH.with(|scratch| { + let mut scratch = scratch.borrow_mut(); + scratch.clear(); + vis.format_symbol_into(&mut scratch); + vis.formatted_symbol = FormattedSymbol::new(&scratch); + }); + vis + } + + pub fn plain(symbol: &str) -> Self { + CharacterVisual::new(symbol, VisualParams::default()) + } + + /// SGR emission in upstream's fixed order; `dim` intentionally omitted; + /// bare symbol when nothing applies. + fn format_symbol_into(&self, fmt: &mut String) { + if self.bold { + fmt.push_str(ansi::BOLD); + } + if self.italic { + fmt.push_str(ansi::ITALIC); + } + if self.underline { + fmt.push_str(ansi::UNDERLINE); + } + if self.blink { + fmt.push_str(ansi::BLINK); + } + if self.reverse { + fmt.push_str(ansi::REVERSE); + } + if self.hidden { + fmt.push_str(ansi::HIDDEN); + } + if self.strike { + fmt.push_str(ansi::STRIKETHROUGH); + } + if let Some(code) = &self.fg_color_code { + ansi::fg(code, fmt); + } + if let Some(code) = &self.bg_color_code { + ansi::bg(code, fmt); + } + fmt.push_str(&self.symbol); + if fmt.len() != self.symbol.len() { + fmt.push_str(ansi::RESET_ALL); + } + } +} + +/// animation.Frame. Frames live in Scene.all_frames (stable storage); +/// Scene.frames / Scene.played_frames hold indices into it, preserving the +/// upstream object-identity semantics of frame_index_map. +#[derive(Debug, Clone)] +pub struct Frame { + pub character_visual: Rc, + pub duration: i64, + pub ticks_elapsed: i64, +} + +/// animation.Scene. +#[derive(Debug, Clone)] +pub struct Scene { + pub scene_id: String, + pub is_looping: bool, + pub sync: Option, + pub ease: Option, + pub no_color: bool, + pub use_xterm_colors: bool, + /// Stable frame storage; never reordered. + pub all_frames: Vec, + /// Remaining frame queue (indices into all_frames). + pub frames: VecDeque, + /// Played frames (indices into all_frames). + pub played_frames: VecDeque, + /// Tick index -> frame index (upstream frame_index_map). + pub frame_index_map: Vec, + pub easing_total_steps: i64, + pub easing_current_step: i64, + pub preexisting_colors: Option, + pub preexisting_bold: bool, +} + +impl Scene { + pub fn new( + scene_id: &str, + is_looping: bool, + sync: Option, + ease: Option, + no_color: bool, + use_xterm_colors: bool, + ) -> Self { + Scene { + scene_id: scene_id.to_string(), + is_looping, + sync, + ease, + no_color, + use_xterm_colors, + all_frames: Vec::new(), + frames: VecDeque::new(), + played_frames: VecDeque::new(), + frame_index_map: Vec::new(), + easing_total_steps: 0, + easing_current_step: 0, + preexisting_colors: None, + preexisting_bold: false, + } + } + + /// Scene._get_color_code. Upstream memoizes into a process-global ClassVar + /// dict; the memo is value-transparent so we just recompute. + fn get_color_code(&self, color: Option<&Color>) -> Option { + let color = color?; + if self.no_color { + return None; + } + if self.use_xterm_colors { + if let Some(code) = color.xterm_color { + return Some(ColorCode::Xterm(code)); + } + return Some(ColorCode::Xterm(hexterm::hex_to_xterm(&color.rgb_color))); + } + Some(ColorCode::Rgb(color.rgb_color.to_string())) + } + + /// Scene.add_frame with the preexisting-color/bold overrides. + pub fn add_frame(&mut self, symbol: &str, duration: i64, mut params: VisualParams) -> Result<(), String> { + if let Some(pre) = &self.preexisting_colors { + params.colors = Some(pre.clone()); + } + if self.preexisting_bold { + params.bold = true; + } + if let Some(colors) = ¶ms.colors { + params.fg_color_code = self.get_color_code(colors.fg_color.as_ref()); + params.bg_color_code = self.get_color_code(colors.bg_color.as_ref()); + } else { + params.fg_color_code = None; + params.bg_color_code = None; + } + if duration < 1 { + return Err(format!("Frame duration must be at least 1. Received: {duration}")); + } + let visual = CharacterVisual::new(symbol, params); + let frame_index = self.all_frames.len(); + self.all_frames.push(Frame { character_visual: Rc::new(visual), duration, ticks_elapsed: 0 }); + self.frames.push_back(frame_index); + for _ in 0..duration { + self.frame_index_map.push(frame_index); + self.easing_total_steps += 1; + } + Ok(()) + } + + /// Scene.activate: first frame's visual, error when empty. + pub fn activate(&self) -> Result, String> { + match self.frames.front() { + Some(&idx) => Ok(self.all_frames[idx].character_visual.clone()), + None => Err(format!("Scene {} has no frames.", self.scene_id)), + } + } + + /// Scene.get_next_visual: tick the head frame, retiring it (and looping) + /// exactly as upstream. + pub fn get_next_visual(&mut self) -> Rc { + let head = self.frames[0]; + let next_visual = self.all_frames[head].character_visual.clone(); + self.all_frames[head].ticks_elapsed += 1; + if self.all_frames[head].ticks_elapsed == self.all_frames[head].duration { + self.all_frames[head].ticks_elapsed = 0; + self.played_frames.push_back(self.frames.pop_front().unwrap()); + if self.is_looping && self.frames.is_empty() { + self.frames.append(&mut self.played_frames); + } + } + next_visual + } + + /// Scene.apply_gradient_to_symbols with the exact cyclic_distribution + /// generator semantics (repeat factor + overflow-remainder rule). + pub fn apply_gradient_to_symbols( + &mut self, + symbols: &[String], + duration: i64, + fg_gradient: Option<&Gradient>, + bg_gradient: Option<&Gradient>, + ) -> Result<(), String> { + fn cyclic_distribution(larger: &[T], smaller: &[R]) -> Vec<(T, R)> { + let repeat_factor = larger.len() / smaller.len(); + let mut overflow_count = larger.len() % smaller.len(); + let mut overflow_used = false; + let mut smaller_index = 0usize; + let mut current_repeat_factor = 0usize; + let mut out = Vec::with_capacity(larger.len()); + for element in larger { + if current_repeat_factor >= repeat_factor { + if overflow_count > 0 { + if overflow_used { + smaller_index += 1; + current_repeat_factor = 0; + overflow_used = false; + } else { + overflow_used = true; + overflow_count -= 1; + } + } else { + smaller_index += 1; + current_repeat_factor = 0; + } + } + current_repeat_factor += 1; + out.push((element.clone(), smaller[smaller_index].clone())); + } + out + } + + let fg_has = fg_gradient.is_some_and(|g| !g.spectrum.is_empty()); + let bg_has = bg_gradient.is_some_and(|g| !g.spectrum.is_empty()); + if fg_gradient.is_none() && bg_gradient.is_none() { + return Err("Foreground and background gradient are None. At least one gradient must be provided.".into()); + } + if !fg_has && !bg_has { + return Err( + "Foreground and background gradient are empty. At least one gradient must have at least one color." + .into(), + ); + } + for symbol in symbols { + if symbol.chars().count() > 1 { + return Err(format!("Symbol must be a string with a length of 1. Received: `{symbol}`.")); + } + } + let color_pairs: Vec = if fg_has && bg_has { + let fg = &fg_gradient.unwrap().spectrum; + let bg = &bg_gradient.unwrap().spectrum; + if fg.len() >= bg.len() { + cyclic_distribution(fg, bg) + .into_iter() + .map(|(f, b)| ColorPair::new(Some(f), Some(b))) + .collect() + } else { + cyclic_distribution(bg, fg) + .into_iter() + .map(|(b, f)| ColorPair::new(Some(f), Some(b))) + .collect() + } + } else if fg_has { + fg_gradient.unwrap().spectrum.iter().map(|c| ColorPair::new(Some(c.clone()), None)).collect() + } else { + bg_gradient.unwrap().spectrum.iter().map(|c| ColorPair::new(None, Some(c.clone()))).collect() + }; + + if symbols.len() >= color_pairs.len() { + for (symbol, colors) in cyclic_distribution(symbols, &color_pairs) { + self.add_frame(&symbol, duration, VisualParams { colors: Some(colors), ..Default::default() })?; + } + } else { + for (colors, symbol) in cyclic_distribution(&color_pairs, symbols) { + self.add_frame(&symbol, duration, VisualParams { colors: Some(colors), ..Default::default() })?; + } + } + Ok(()) + } + + /// Scene.reset_scene: restore played + remaining frames in original order + /// (played first), zero tick counters and the easing step. + pub fn reset_scene(&mut self) { + // Remaining frames get ticks_elapsed zeroed as they move to played; + // already-played frames were zeroed when they retired. + let remaining: Vec = self.frames.drain(..).collect(); + for &idx in &remaining { + self.all_frames[idx].ticks_elapsed = 0; + self.played_frames.push_back(idx); + } + self.frames.extend(self.played_frames.drain(..)); + self.easing_current_step = 0; + } +} + +/// engine/animation.py Animation: per-character animation state. +#[derive(Debug, Clone)] +pub struct Animation { + pub scenes: OrderedMap, + pub active_scene: Option>, + pub use_xterm_colors: bool, + pub no_color: bool, + pub existing_color_handling: ExistingColorHandling, + pub input_fg_color: Option, + pub input_bg_color: Option, + pub input_bold: bool, + pub active_scene_current_step: i64, + pub current_character_visual: Rc, +} + +impl Animation { + pub fn new(input_symbol: &str) -> Self { + Animation { + scenes: OrderedMap::new(), + active_scene: None, + use_xterm_colors: false, + no_color: false, + existing_color_handling: ExistingColorHandling::Ignore, + input_fg_color: None, + input_bg_color: None, + input_bold: false, + active_scene_current_step: 0, + current_character_visual: Rc::new(CharacterVisual::plain(input_symbol)), + } + } + + /// Animation._get_color_code (identical logic to Scene's; the upstream + /// per-instance memo is value-transparent and omitted). + pub fn get_color_code(&mut self, color: Option<&Color>) -> Option { + let color = color?; + if self.no_color { + return None; + } + if self.use_xterm_colors { + if let Some(code) = color.xterm_color { + return Some(ColorCode::Xterm(code)); + } + return Some(ColorCode::Xterm(hexterm::hex_to_xterm(&color.rgb_color))); + } + Some(ColorCode::Rgb(color.rgb_color.to_string())) + } + + /// Animation.new_scene: auto-ids are stringified integers probing upward; + /// duplicate explicit ids silently overwrite (faithful). + pub fn new_scene( + &mut self, + is_looping: bool, + sync: Option, + ease: Option, + scene_id: &str, + uses_input_preexisting_colors: bool, + ) -> String { + let scene_id = if scene_id.is_empty() { + let mut current_id = self.scenes.len(); + loop { + let candidate = current_id.to_string(); + if !self.scenes.contains_key(&candidate) { + break candidate; + } + current_id += 1; + } + } else { + scene_id.to_string() + }; + let (preexisting_colors, preexisting_bold) = + if self.existing_color_handling == ExistingColorHandling::Always && uses_input_preexisting_colors { + ( + Some(ColorPair::new(self.input_fg_color.clone(), self.input_bg_color.clone())), + self.input_bold, + ) + } else { + (None, false) + }; + let mut scene = Scene::new(&scene_id, is_looping, sync, ease, self.no_color, self.use_xterm_colors); + scene.preexisting_colors = preexisting_colors; + scene.preexisting_bold = preexisting_bold; + self.scenes.insert(scene_id.clone(), scene); + scene_id + } + + /// Animation.active_scene_is_complete: no scene, no remaining frames, or looping. + pub fn active_scene_is_complete(&self) -> bool { + match &self.active_scene { + None => true, + Some(id) => { + let scene = self.scenes.get(id).expect("active scene must exist"); + scene.frames.is_empty() || scene.is_looping + } + } + } + + /// Animation.set_appearance. + pub fn set_appearance( + &mut self, + input_symbol: &str, + uses_input_preexisting_colors: bool, + symbol: Option<&str>, + colors: Option, + ) { + let symbol = symbol.unwrap_or(input_symbol); + let mut colors = colors.unwrap_or_default(); + let mut bold = false; + if self.existing_color_handling == ExistingColorHandling::Always && uses_input_preexisting_colors { + colors = ColorPair::new(self.input_fg_color.clone(), self.input_bg_color.clone()); + bold = self.input_bold; + } + let fg_code = self.get_color_code(colors.fg_color.as_ref()); + let bg_code = self.get_color_code(colors.bg_color.as_ref()); + self.current_character_visual = Rc::new(CharacterVisual::new( + symbol, + VisualParams { + bold, + colors: Some(colors), + fg_color_code: fg_code, + bg_color_code: bg_code, + ..Default::default() + }, + )); + } + + /// Animation.adjust_color_brightness: hand-rolled RGB->HSL->RGB with + /// round() (banker's) at the end — unlike shift_color_towards's truncation. + pub fn adjust_color_brightness(color: &Color, brightness: f64) -> Color { + use crate::utils::pycompat::round_half_even; + + fn hue_to_rgb(lightness_scaled: f64, color_intensity: f64, mut hue_value: f64) -> f64 { + if hue_value < 0.0 { + hue_value += 1.0; + } + if hue_value > 1.0 { + hue_value -= 1.0; + } + if hue_value < 1.0 / 6.0 { + return lightness_scaled + (color_intensity - lightness_scaled) * 6.0 * hue_value; + } + if hue_value < 1.0 / 2.0 { + return color_intensity; + } + if hue_value < 2.0 / 3.0 { + return lightness_scaled + (color_intensity - lightness_scaled) * (2.0 / 3.0 - hue_value) * 6.0; + } + lightness_scaled + } + + let (r, g, b) = color.rgb_ints(); + let normalized_red = r as f64 / 255.0; + let normalized_green = g as f64 / 255.0; + let normalized_blue = b as f64 / 255.0; + + let max_val = normalized_red.max(normalized_green).max(normalized_blue); + let min_val = normalized_red.min(normalized_green).min(normalized_blue); + let mut lightness = (max_val + min_val) / 2.0; + + let lightness_threshold = 0.5; + let (hue_value, saturation) = if max_val == min_val { + (0.0, 0.0) + } else { + let diff = max_val - min_val; + let saturation = if lightness > lightness_threshold { + diff / (2.0 - max_val - min_val) + } else { + diff / (max_val + min_val) + }; + let mut hue_value = if max_val == normalized_red { + (normalized_green - normalized_blue) / diff + if normalized_green < normalized_blue { 6.0 } else { 0.0 } + } else if max_val == normalized_green { + (normalized_blue - normalized_red) / diff + 2.0 + } else { + (normalized_red - normalized_green) / diff + 4.0 + }; + hue_value /= 6.0; + (hue_value, saturation) + }; + + lightness = (lightness * brightness).min(1.0).max(0.0); + + let (red, green, blue) = if saturation == 0.0 { + (lightness, lightness, lightness) + } else { + let color_intensity = if lightness < lightness_threshold { + lightness * (1.0 + saturation) + } else { + lightness + saturation - lightness * saturation + }; + let lightness_scaled = 2.0 * lightness - color_intensity; + ( + hue_to_rgb(lightness_scaled, color_intensity, hue_value + 1.0 / 3.0), + hue_to_rgb(lightness_scaled, color_intensity, hue_value), + hue_to_rgb(lightness_scaled, color_intensity, hue_value - 1.0 / 3.0), + ) + }; + + let adjusted = format!( + "{:02x}{:02x}{:02x}", + round_half_even(red * 255.0), + round_half_even(green * 255.0), + round_half_even(blue * 255.0) + ); + Color::from_hex(&adjusted).unwrap() + } +} diff --git a/vendor/ttfx/src/engine/canvas.rs b/vendor/ttfx/src/engine/canvas.rs new file mode 100644 index 0000000..aaf1fb3 --- /dev/null +++ b/vendor/ttfx/src/engine/canvas.rs @@ -0,0 +1,206 @@ +//! Canvas, ported from engine/terminal.py Canvas. + +use crate::engine::character::{CharId, EffectCharacter}; +use crate::utils::geometry::Coord; +use crate::utils::pycompat::floor_div; +use crate::utils::rng::Rng; + +/// Text anchor / canvas anchor directions (the 9 compass points). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Anchor { + N, + Ne, + E, + Se, + S, + Sw, + W, + Nw, + C, +} + +impl Anchor { + pub fn parse(s: &str) -> Option { + Some(match s { + "n" => Anchor::N, + "ne" => Anchor::Ne, + "e" => Anchor::E, + "se" => Anchor::Se, + "s" => Anchor::S, + "sw" => Anchor::Sw, + "w" => Anchor::W, + "nw" => Anchor::Nw, + "c" => Anchor::C, + _ => return None, + }) + } +} + +#[derive(Debug, Clone)] +pub struct Canvas { + pub top: i64, + pub right: i64, + pub bottom: i64, + pub left: i64, + pub center_row: i64, + pub center_column: i64, + pub center: Coord, + pub width: i64, + pub height: i64, + pub text_left: i64, + pub text_right: i64, + pub text_top: i64, + pub text_bottom: i64, + pub text_width: i64, + pub text_height: i64, + pub text_center_row: i64, + pub text_center_column: i64, + pub text_center: Coord, +} + +impl Canvas { + /// Canvas.__post_init__ with bottom=1, left=1 defaults. + pub fn new(top: i64, right: i64) -> Self { + let bottom = 1; + let left = 1; + let mut center_row = std::cmp::max(floor_div(top, 2), bottom); + if top % 2 != 0 && top > 1 { + center_row += 1; + } + let mut center_column = std::cmp::max(floor_div(right, 2), left); + if right % 2 != 0 && right > 1 { + center_column += 1; + } + Canvas { + top, + right, + bottom, + left, + center_row, + center_column, + center: Coord::new(center_column, center_row), + width: right, + height: top, + text_left: 0, + text_right: 0, + text_top: 0, + text_bottom: 0, + text_width: 0, + text_height: 0, + text_center_row: 0, + text_center_column: 0, + text_center: Coord::new(0, 0), + } + } + + /// Canvas._anchor_text: shift characters per the anchor, drop out-of-canvas + /// ones, then compute text extents. `characters` must be non-empty after the + /// in-canvas filter or this errors (upstream crashes on bare max()/min()). + pub fn anchor_text( + &mut self, + arena: &mut [EffectCharacter], + characters: Vec, + anchor: Anchor, + ) -> Result, String> { + use Anchor::*; + let input_width = characters + .iter() + .map(|&id| arena[id.0 as usize].input_coord.column) + .max() + .ok_or("no input characters to anchor")?; + let input_height = characters + .iter() + .map(|&id| arena[id.0 as usize].input_coord.row) + .max() + .unwrap(); + + let mut column_delta = 0; + let mut row_delta = 0; + if input_width != self.width { + match anchor { + S | N | C => column_delta = self.center_column - floor_div(input_width, 2), + Se | E | Ne => column_delta = self.right - input_width, + Sw | W | Nw => column_delta = self.left - 1, + } + } + if input_height != self.height { + match anchor { + W | E | C => row_delta = self.center_row - floor_div(input_height, 2), + Nw | N | Ne => row_delta = self.top - input_height, + Sw | S | Se => row_delta = self.bottom - 1, + } + } + + for &id in &characters { + let ch = &mut arena[id.0 as usize]; + let anchored = Coord::new(ch.input_coord.column + column_delta, ch.input_coord.row + row_delta); + ch.input_coord = anchored; + ch.motion.set_coordinate(anchored); + } + + let kept: Vec = characters + .into_iter() + .filter(|&id| self.coord_is_in_canvas(arena[id.0 as usize].input_coord)) + .collect(); + + if kept.is_empty() { + // Upstream raises ValueError from max() on an empty sequence here. + return Err("all input characters fall outside the canvas after anchoring".to_string()); + } + + self.text_left = kept.iter().map(|&id| arena[id.0 as usize].input_coord.column).min().unwrap(); + self.text_right = kept.iter().map(|&id| arena[id.0 as usize].input_coord.column).max().unwrap(); + self.text_top = kept.iter().map(|&id| arena[id.0 as usize].input_coord.row).max().unwrap(); + self.text_bottom = kept.iter().map(|&id| arena[id.0 as usize].input_coord.row).min().unwrap(); + self.text_width = std::cmp::max(self.text_right - self.text_left + 1, 1); + self.text_height = std::cmp::max(self.text_top - self.text_bottom + 1, 1); + self.text_center_row = self.text_bottom + floor_div(self.text_top - self.text_bottom, 2); + self.text_center_column = self.text_left + floor_div(self.text_right - self.text_left, 2); + self.text_center = Coord::new(self.text_center_column, self.text_center_row); + Ok(kept) + } + + pub fn coord_is_in_canvas(&self, coord: Coord) -> bool { + self.left <= coord.column && coord.column <= self.right && self.bottom <= coord.row && coord.row <= self.top + } + + pub fn coord_is_in_text(&self, coord: Coord) -> bool { + self.text_left <= coord.column + && coord.column <= self.text_right + && self.text_bottom <= coord.row + && coord.row <= self.text_top + } + + pub fn random_column(&self, rng: &mut Rng, within_text_boundary: bool) -> i64 { + if within_text_boundary { + rng.randint(self.text_left, self.text_right) + } else { + rng.randint(self.left, self.right) + } + } + + pub fn random_row(&self, rng: &mut Rng, within_text_boundary: bool) -> i64 { + if within_text_boundary { + rng.randint(self.text_bottom, self.text_top) + } else { + rng.randint(self.bottom, self.top) + } + } + + /// random_coord: outside_scope picks among four coords exactly one cell past + /// an edge — note the RNG call ORDER (above, below, left, right built first, + /// then choice) is part of the parity contract. + pub fn random_coord(&self, rng: &mut Rng, outside_scope: bool, within_text_boundary: bool) -> Coord { + if outside_scope { + let above = Coord::new(self.random_column(rng, false), self.top + 1); + let below = Coord::new(self.random_column(rng, false), self.bottom - 1); + let left = Coord::new(self.left - 1, self.random_row(rng, false)); + let right = Coord::new(self.right + 1, self.random_row(rng, false)); + return *rng.choice(&[above, below, left, right]); + } + Coord::new( + self.random_column(rng, within_text_boundary), + self.random_row(rng, within_text_boundary), + ) + } +} diff --git a/vendor/ttfx/src/engine/character.rs b/vendor/ttfx/src/engine/character.rs new file mode 100644 index 0000000..fd677a2 --- /dev/null +++ b/vendor/ttfx/src/engine/character.rs @@ -0,0 +1,78 @@ +//! EffectCharacter, ported from engine/base_character.py, stored in an arena. +//! +//! `CharId` is the arena slot index. `character_id` is the Python-compatible +//! monotonically allocated id — these are NOT the same thing: the Python parser +//! allocates ids for characters that are later overwritten by cursor movement, +//! popped as trailing whitespace, or cropped by the canvas, so surviving +//! characters have id gaps. All canonical orderings sort by `character_id`. + +use crate::engine::animation::Animation; +use crate::engine::events::EventHandler; +use crate::engine::motion::Motion; +use crate::utils::geometry::Coord; + +/// Arena slot index (dense). Never used for ordering. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct CharId(pub u32); + +/// Cardinal neighbor slots, upstream's dict keys north/east/south/west. +#[derive(Debug, Clone, Copy, Default)] +pub struct Neighbors { + pub north: Option, + pub east: Option, + pub south: Option, + pub west: Option, +} + +#[derive(Debug, Clone)] +pub struct EffectCharacter { + /// Python-compatible allocation id; canonical ordering key. + pub character_id: u32, + pub input_symbol: String, + pub input_coord: Coord, + /// Raw input SGR sequences captured at parse time (fg, bg). + pub input_ansi_fg_sequence: Option, + pub input_ansi_bg_sequence: Option, + pub is_visible: bool, + pub animation: Animation, + pub motion: Motion, + pub event_handler: EventHandler, + pub layer: i64, + pub is_fill_character: bool, + pub uses_input_preexisting_colors: bool, + /// Spanning-tree links (character ids, insertion-ordered — upstream uses a + /// set, but iteration order is behavior; see plan.md §4.3). + pub links: Vec, + pub neighbors: Neighbors, +} + +impl EffectCharacter { + pub fn new(character_id: u32, symbol: &str, input_column: i64, input_row: i64) -> Self { + let input_coord = Coord::new(input_column, input_row); + EffectCharacter { + character_id, + input_symbol: symbol.to_string(), + input_coord, + input_ansi_fg_sequence: None, + input_ansi_bg_sequence: None, + is_visible: false, + animation: Animation::new(symbol), + motion: Motion::new(input_coord), + event_handler: EventHandler::default(), + layer: 0, + is_fill_character: false, + uses_input_preexisting_colors: false, + links: Vec::new(), + neighbors: Neighbors::default(), + } + } + + /// EffectCharacter.is_active: active while the animation's active scene is + /// incomplete OR motion has an active path. Note looping scenes report + /// complete, so loop-only characters read as inactive (faithful quirk). + pub fn is_active(&self) -> bool { + // Movement is a null check; scene completion is a map lookup. Same + // answer either way, so ask the cheap question first. + !self.motion.movement_is_complete() || !self.animation.active_scene_is_complete() + } +} diff --git a/vendor/ttfx/src/engine/ctx.rs b/vendor/ttfx/src/engine/ctx.rs new file mode 100644 index 0000000..258ba1c --- /dev/null +++ b/vendor/ttfx/src/engine/ctx.rs @@ -0,0 +1,715 @@ +//! EngineCtx: the mutable engine world (terminal + arena + rng + clock + +//! active characters) and every stepping routine that can fire events. +//! +//! Python executes event actions synchronously at the emission point, deep in +//! the middle of Path.step / Motion.move / Animation.step_animation, and those +//! actions reentrantly mutate the same structures being stepped. To preserve +//! that observable ordering (plan.md §4.2), all stepping logic lives here as +//! EngineCtx methods that hold only short-lived borrows: state is re-fetched +//! by id after every emission point, and segment walks are index-based so +//! reentrant list mutation behaves like Python list iteration. + +use std::rc::Rc; +use std::time::Instant; + +use crate::engine::active_characters::ActiveCharacters; +use crate::engine::animation::SyncMetric; +use crate::engine::character::CharId; +use crate::engine::error::EngineError; +use crate::engine::events::{CallerKey, CallerRef, EffectCallback, Event, EventAction}; +use crate::engine::motion::Segment; +use crate::engine::motion::Waypoint; +use crate::engine::terminal::{Terminal, TerminalConfig}; +use crate::utils::geometry::{self, Coord}; +use crate::utils::pycompat::round_half_even; +use crate::utils::rng::Rng; + +thread_local! { + /// The synthetic origin waypoint's id, shared by every activation. + static ORIGIN_WAYPOINT_ID: Rc = Rc::from("origin"); +} + +/// Virtual/real clock (plan.md §4.7). Matrix reads wall time, thunderstorm +/// reads monotonic time; the parity harness swaps in the virtual variant. +#[derive(Debug)] +pub enum Clock { + Real { start: Instant, wall_start: f64 }, + /// Virtual time advancing a fixed dt per emitted frame. + Virtual { now: f64, dt: f64 }, +} + +impl Clock { + pub fn real() -> Self { + let wall_start = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0); + Clock::Real { start: Instant::now(), wall_start } + } + + pub fn virtual_with_frame_rate(frame_rate: i64) -> Self { + let dt = if frame_rate > 0 { 1.0 / frame_rate as f64 } else { 1.0 / 60.0 }; + Clock::Virtual { now: 0.0, dt } + } + + /// time.time() analog. + pub fn now_wall(&self) -> f64 { + match self { + Clock::Real { start, wall_start } => wall_start + start.elapsed().as_secs_f64(), + Clock::Virtual { now, .. } => *now, + } + } + + /// time.monotonic() analog. + pub fn now_monotonic(&self) -> f64 { + match self { + Clock::Real { start, .. } => start.elapsed().as_secs_f64(), + Clock::Virtual { now, .. } => *now, + } + } + + /// Advance virtual time by one frame; no-op for the real clock. + pub fn advance_frame(&mut self) { + if let Clock::Virtual { now, dt } = self { + *now += *dt; + } + } +} + +/// Effect-side hook for CALLBACK actions. The effect struct and the EngineCtx +/// are disjoint ownership trees, so the callback may freely recurse into +/// engine calls with the provided ctx. +pub trait EffectHooks { + fn dispatch_callback(&mut self, ctx: &mut EngineCtx, character: CharId, callback: &EffectCallback); +} + +/// Hooks implementation for engine-internal use (no effect callbacks registered). +pub struct NoopHooks; +impl EffectHooks for NoopHooks { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +pub struct EngineCtx { + pub terminal: Terminal, + pub rng: Rng, + pub clock: Clock, + /// BaseEffectIterator.active_characters — canonical ascending-id order + /// (CharId order == character_id order by construction). + pub active_characters: ActiveCharacters, + active_character_scratch: Vec, + pub preexisting_colors_present: bool, + /// When Some, every event emission appends a trace line (test harness). + pub event_log: Option>, + /// Skip ANSI assembly in [`Self::frame`]. GUI callers still advance the clock. + pub suppress_ansi: bool, +} + +impl EngineCtx { + pub fn new(input_data: &str, config: TerminalConfig, rng: Rng, clock: Clock) -> Result { + let terminal = Terminal::new(input_data, config)?; + let preexisting_colors_present = terminal.input_characters.iter().any(|&id| { + let ch = &terminal.arena[id.0 as usize]; + ch.animation.input_fg_color.is_some() || ch.animation.input_bg_color.is_some() + }); + Ok(EngineCtx { + terminal, + rng, + clock, + active_characters: ActiveCharacters::new(), + active_character_scratch: Vec::new(), + preexisting_colors_present, + event_log: None, + suppress_ansi: false, + }) + } + + // ------------------------------------------------------------------ + // event dispatch (EventHandler._handle_event) + // ------------------------------------------------------------------ + + /// Whether an emission of `event` on `id` can have any observable effect — + /// false lets hot emission sites skip building the CallerKey entirely. + #[inline] + fn observes_event(&self, id: CharId, event: Event) -> bool { + self.event_log.is_some() || self.terminal.arena[id.0 as usize].event_handler.subscribes(event) + } + + /// Execute all actions registered for (event, caller) on `id`, in + /// registration order, inline and reentrantly. The action list is indexed + /// per iteration because a callback may append more actions to it. + pub fn handle_event( + &mut self, + hooks: &mut dyn EffectHooks, + id: CharId, + event: Event, + caller: CallerRef<'_>, + ) { + if self.event_log.is_some() { + let character_id = self.terminal.arena[id.0 as usize].character_id; + let event_name = match event { + Event::SegmentEntered => "SEGMENT_ENTERED", + Event::SegmentExited => "SEGMENT_EXITED", + Event::PathActivated => "PATH_ACTIVATED", + Event::PathComplete => "PATH_COMPLETE", + Event::PathHolding => "PATH_HOLDING", + Event::SceneActivated => "SCENE_ACTIVATED", + Event::SceneComplete => "SCENE_COMPLETE", + }; + let caller_label = match caller { + CallerRef::Path(pid) => format!("path:{pid}"), + CallerRef::Waypoint(wp) => format!("wp:{}", wp.waypoint_id), + CallerRef::Scene(sid) => format!("scene:{sid}"), + }; + self.event_log + .as_mut() + .unwrap() + .push(format!("EVENT char={character_id} {event_name} caller={caller_label}")); + } + let Some(entry_index) = self.terminal.arena[id.0 as usize].event_handler.actions_index(event, caller) else { + return; + }; + let mut action_index = 0; + loop { + let action = { + let handler = &self.terminal.arena[id.0 as usize].event_handler; + let actions = handler.actions(entry_index); + if action_index >= actions.len() { + break; + } + actions[action_index].clone() + }; + match action { + EventAction::ActivatePath(path_id) => self.activate_path(hooks, id, &path_id), + EventAction::ActivateScene(scene_id) => self.activate_scene(hooks, id, &scene_id), + EventAction::DeactivatePath(target) => { + self.terminal.arena[id.0 as usize].motion.deactivate_path(target.as_deref()); + } + EventAction::DeactivateScene(target) => { + self.deactivate_scene(id, target.as_deref()); + } + EventAction::ResetAppearance => { + let ch = &mut self.terminal.arena[id.0 as usize]; + let input_symbol = ch.input_symbol.clone(); + let uses = ch.uses_input_preexisting_colors; + ch.animation.set_appearance(&input_symbol, uses, Some(&input_symbol.clone()), None); + } + EventAction::SetLayer(layer) => { + self.terminal.arena[id.0 as usize].layer = layer; + } + EventAction::SetCoordinate(coord) => { + self.terminal.arena[id.0 as usize].motion.current_coord = coord; + } + EventAction::Callback(cb) => { + hooks.dispatch_callback(self, id, &cb); + } + } + action_index += 1; + } + } + + /// EventHandler.register_event: resolves/validates existence for id-based + /// callers and targets, rejects duplicates. + pub fn register_event( + &mut self, + id: CharId, + event: Event, + caller: CallerKey, + action: EventAction, + ) -> Result<(), String> { + { + let ch = &self.terminal.arena[id.0 as usize]; + match &caller { + CallerKey::Path(pid) => { + if !ch.motion.paths.contains_key(pid) { + return Err(format!("path not found: {pid}")); + } + } + CallerKey::Scene(sid) => { + if !ch.animation.scenes.contains_key(sid) { + return Err(format!("scene not found: {sid}")); + } + } + CallerKey::Waypoint(_) => {} + } + match &action { + EventAction::ActivatePath(pid) | EventAction::DeactivatePath(Some(pid)) => { + if !ch.motion.paths.contains_key(pid) { + return Err(format!("path not found: {pid}")); + } + } + EventAction::ActivateScene(sid) | EventAction::DeactivateScene(Some(sid)) => { + if !ch.animation.scenes.contains_key(sid) { + return Err(format!("scene not found: {sid}")); + } + } + _ => {} + } + } + self.terminal.arena[id.0 as usize].event_handler.push(event, caller, action) + } + + // ------------------------------------------------------------------ + // motion (Motion.activate_path / Path.step / Motion.move) + // ------------------------------------------------------------------ + + /// Motion.activate_path. + pub fn activate_path(&mut self, hooks: &mut dyn EffectHooks, id: CharId, path_id: &str) { + let (current_coord, first_waypoint) = { + let ch = &self.terminal.arena[id.0 as usize]; + let path = ch.motion.paths.get(path_id).expect("activate_path: path not found"); + assert!(!path.waypoints.is_empty(), "activate_path: empty path {path_id}"); + (ch.motion.current_coord, path.waypoints[0].clone()) + }; + let distance_to_first_waypoint = match &first_waypoint.bezier_control { + Some(control) => geometry::find_length_of_bezier_curve(current_coord, control, first_waypoint.coord), + None => geometry::find_length_of_line(current_coord, first_waypoint.coord, true), + }; + let new_origin_segment = Segment::new( + Waypoint { waypoint_id: ORIGIN_WAYPOINT_ID.with(Rc::clone), coord: current_coord, bezier_control: None }, + first_waypoint, + distance_to_first_waypoint, + ); + let layer = { + let ch = &mut self.terminal.arena[id.0 as usize]; + ch.motion.active_path = ch.motion.paths.shared_key(path_id); + let path = ch.motion.paths.get_mut(path_id).unwrap(); + path.total_distance += distance_to_first_waypoint; + if let Some(origin) = &path.origin_segment { + path.total_distance -= origin.distance; + path.segments[0] = new_origin_segment.clone(); + } else { + path.segments.insert(0, new_origin_segment.clone()); + } + path.origin_segment = Some(new_origin_segment); + path.current_step = 0; + path.hold_time_remaining = path.hold_time; + path.max_steps = round_half_even(path.total_distance / path.speed); + for segment in path.segments.iter_mut() { + segment.enter_event_triggered = false; + segment.exit_event_triggered = false; + } + path.layer + }; + if let Some(layer) = layer { + self.terminal.arena[id.0 as usize].layer = layer; + } + if self.observes_event(id, Event::PathActivated) { + self.handle_event(hooks, id, Event::PathActivated, CallerRef::Path(path_id)); + } + } + + /// Path.step on the given path of `id`. Index-based segment walk with + /// re-borrow per access so reentrant mutation behaves like Python. + /// + /// The path's slot is resolved once and re-resolved after every emission, + /// since only a reentrant action can move or drop it. + fn path_step(&mut self, hooks: &mut dyn EffectHooks, id: CharId, path_id: &str) -> Coord { + let mut slot = + self.terminal.arena[id.0 as usize].motion.paths.slot(path_id).expect("path_step: path removed mid-step"); + macro_rules! path { + () => { + self.terminal.arena[id.0 as usize].motion.paths.at(slot) + }; + } + macro_rules! path_mut { + () => { + self.terminal.arena[id.0 as usize].motion.paths.at_mut(slot) + }; + } + macro_rules! resolve_slot { + () => { + slot = self.terminal.arena[id.0 as usize] + .motion + .paths + .slot(path_id) + .expect("path_step: path removed mid-step") + }; + } + + let mut distance_to_travel = { + let p = path_mut!(); + if p.max_steps == 0 || p.current_step >= p.max_steps || p.total_distance == 0.0 { + return p.segments.last().expect("path has no segments").end.coord; + } + p.current_step += 1; + let ratio = p.current_step as f64 / p.max_steps as f64; + let distance_factor = match &p.ease { + Some(ease) => ease.ease(ratio), + None => ratio, + }; + let distance = distance_factor * p.total_distance; + p.last_distance_reached = distance; + distance + }; + + let mut active_segment_index: Option = None; + let mut i = 0usize; + loop { + let (seg_distance, enter_triggered, exit_triggered) = { + let p = path!(); + if i >= p.segments.len() { + break; + } + let seg = &p.segments[i]; + (seg.distance, seg.enter_event_triggered, seg.exit_event_triggered) + }; + if distance_to_travel <= seg_distance { + active_segment_index = Some(i); + if !enter_triggered { + if self.observes_event(id, Event::SegmentEntered) { + let seg_end_key = path!().segments[i].end.key(); + path_mut!().segments[i].enter_event_triggered = true; + self.handle_event(hooks, id, Event::SegmentEntered, CallerRef::Waypoint(&seg_end_key)); + resolve_slot!(); + } else { + path_mut!().segments[i].enter_event_triggered = true; + } + } + break; + } + distance_to_travel -= seg_distance; + if !enter_triggered || !exit_triggered { + let observes = self.observes_event(id, Event::SegmentEntered) + || self.observes_event(id, Event::SegmentExited); + if !observes { + let seg = &mut path_mut!().segments[i]; + seg.enter_event_triggered = true; + seg.exit_event_triggered = true; + } else { + let seg_end_key = path!().segments[i].end.key(); + if !enter_triggered { + path_mut!().segments[i].enter_event_triggered = true; + self.handle_event(hooks, id, Event::SegmentEntered, CallerRef::Waypoint(&seg_end_key)); + resolve_slot!(); + } + if !exit_triggered { + path_mut!().segments[i].exit_event_triggered = true; + self.handle_event(hooks, id, Event::SegmentExited, CallerRef::Waypoint(&seg_end_key)); + resolve_slot!(); + } + } + } + i += 1; + } + // Python for-else: overshoot past the last waypoint re-adds the final + // segment's distance and travels beyond it (eased overshoot). + let active_segment_index = match active_segment_index { + Some(idx) => idx, + None => { + let p = path!(); + let idx = p.segments.len() - 1; + distance_to_travel += p.segments[idx].distance; + idx + } + }; + + let p = path!(); + let seg = &p.segments[active_segment_index]; + let seg_distance = seg.distance; + let t = if seg_distance == 0.0 { + 0.0 + } else if p.ease.is_some() { + distance_to_travel / seg_distance // unclamped: eased overshoot goes past the waypoint + } else { + (distance_to_travel / seg_distance).min(1.0) + }; + match &seg.end.bezier_control { + Some(control) => geometry::find_coord_on_bezier_curve(seg.start.coord, control, seg.end.coord, t), + None => geometry::find_coord_on_line(seg.start.coord, seg.end.coord, t), + } + } + + /// Motion.move. + pub fn motion_move(&mut self, hooks: &mut dyn EffectHooks, id: CharId) { + { + let motion = &mut self.terminal.arena[id.0 as usize].motion; + motion.previous_coord = motion.current_coord; + } + let Some(path_id) = ({ + let motion = &self.terminal.arena[id.0 as usize].motion; + match &motion.active_path { + Some(pid) if !motion.paths.get(pid).is_none_or(|p| p.segments.is_empty()) => Some(pid.clone()), + _ => None, + } + }) else { + return; + }; + let new_coord = self.path_step(hooks, id, &path_id); + self.terminal.arena[id.0 as usize].motion.current_coord = new_coord; + + // Python re-reads self.active_path after step (a callback may have + // swapped it); None here would be an upstream AttributeError. + let active_path_id = self.terminal.arena[id.0 as usize] + .motion + .active_path + .clone() + .expect("active path cleared mid-move (would be an upstream crash)"); + let slot = self.terminal.arena[id.0 as usize].motion.paths.slot(&active_path_id).expect("active path missing"); + let (current_step, max_steps, hold_time, hold_time_remaining, loop_, segment_count) = { + let p = self.terminal.arena[id.0 as usize].motion.paths.at(slot); + (p.current_step, p.max_steps, p.hold_time, p.hold_time_remaining, p.loop_, p.segments.len()) + }; + if current_step == max_steps { + if hold_time != 0 && hold_time_remaining == hold_time { + if self.observes_event(id, Event::PathHolding) { + self.handle_event(hooks, id, Event::PathHolding, CallerRef::Path(&active_path_id)); + } + self.terminal.arena[id.0 as usize] + .motion + .paths + .get_mut(&active_path_id) + .unwrap() + .hold_time_remaining -= 1; + return; + } + if hold_time_remaining != 0 { + self.terminal.arena[id.0 as usize].motion.paths.at_mut(slot).hold_time_remaining -= 1; + return; + } + if loop_ && segment_count > 1 { + self.terminal.arena[id.0 as usize].motion.deactivate_path(Some(&active_path_id)); + self.activate_path(hooks, id, &active_path_id); + } else { + { + let motion = &mut self.terminal.arena[id.0 as usize].motion; + motion.completed_path = Some(active_path_id.clone()); + motion.deactivate_path(Some(&active_path_id)); + } + if self.observes_event(id, Event::PathComplete) { + self.handle_event(hooks, id, Event::PathComplete, CallerRef::Path(&active_path_id)); + } + } + } + } + + /// Motion.chain_paths. + pub fn chain_paths(&mut self, id: CharId, paths: &[String], loop_: bool) -> Result<(), String> { + if paths.len() < 2 { + return Ok(()); + } + for i in 1..paths.len() { + self.register_event( + id, + Event::PathComplete, + CallerKey::Path(paths[i - 1].clone()), + EventAction::ActivatePath(paths[i].clone()), + )?; + } + if loop_ { + self.register_event( + id, + Event::PathComplete, + CallerKey::Path(paths[paths.len() - 1].clone()), + EventAction::ActivatePath(paths[0].clone()), + )?; + } + Ok(()) + } + + // ------------------------------------------------------------------ + // animation (Animation.activate_scene / step_animation) + // ------------------------------------------------------------------ + + /// Animation.activate_scene: does NOT reset playback (resume semantics). + pub fn activate_scene(&mut self, hooks: &mut dyn EffectHooks, id: CharId, scene_id: &str) { + { + let ch = &mut self.terminal.arena[id.0 as usize]; + let visual = ch + .animation + .scenes + .get(scene_id) + .expect("activate_scene: scene not found") + .activate() + .expect("activate_scene: empty scene"); + ch.animation.active_scene = ch.animation.scenes.shared_key(scene_id); + ch.animation.active_scene_current_step = 0; + ch.animation.current_character_visual = visual; + } + if self.observes_event(id, Event::SceneActivated) { + self.handle_event(hooks, id, Event::SceneActivated, CallerRef::Scene(scene_id)); + } + } + + /// Animation.deactivate_scene. + pub fn deactivate_scene(&mut self, id: CharId, scene_id: Option<&str>) { + let animation = &mut self.terminal.arena[id.0 as usize].animation; + match scene_id { + None => animation.active_scene = None, + Some(sid) => { + if animation.active_scene.as_deref() == Some(sid) { + animation.active_scene = None; + } + } + } + } + + /// Animation.step_animation. + /// + /// Nothing between here and complete_scene_if_finished can add or remove a + /// scene, so the active scene's slot is resolved once and reused instead of + /// looking the id up again at every step. + pub fn step_animation(&mut self, hooks: &mut dyn EffectHooks, id: CharId) { + let Some(scene_slot) = ({ + let anim = &self.terminal.arena[id.0 as usize].animation; + match &anim.active_scene { + Some(sid) => { + let slot = anim.scenes.slot(sid).expect("active scene missing"); + (!anim.scenes.at(slot).frames.is_empty()).then_some(slot) + } + None => None, + } + }) else { + return; + }; + + let (sync, ease) = { + let scene = self.terminal.arena[id.0 as usize].animation.scenes.at(scene_slot); + (scene.sync, scene.ease) + }; + + if sync.is_some() { + self.step_synced_scene(id, scene_slot, sync.unwrap()); + } else if ease.is_some() { + self.step_eased_scene(id, scene_slot, ease.unwrap()); + } else { + let ch = &mut self.terminal.arena[id.0 as usize]; + let visual = ch.animation.scenes.at_mut(scene_slot).get_next_visual(); + ch.animation.current_character_visual = visual; + } + + self.complete_scene_if_finished(hooks, id, scene_slot); + } + + /// Animation._step_synced_scene + _synced_scene_frame_index. + fn step_synced_scene(&mut self, id: CharId, scene_slot: usize, sync: SyncMetric) { + let active_path_state = { + let ch = &self.terminal.arena[id.0 as usize]; + ch.motion.active_path.as_ref().map(|pid| { + let p = ch.motion.paths.get(pid).expect("active path missing"); + (p.current_step, p.max_steps, p.total_distance, p.last_distance_reached) + }) + }; + let ch = &mut self.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.at_mut(scene_slot); + match active_path_state { + None => { + // no active path: jump to final frame and force-complete + let last = *scene.frames.back().unwrap(); + ch.animation.current_character_visual = scene.all_frames[last].character_visual.clone(); + let drained: Vec = scene.frames.drain(..).collect(); + scene.played_frames.extend(drained); + } + Some((current_step, max_steps, total_distance, last_distance_reached)) => { + let final_frame_index = scene.frames.len() as i64 - 1; + let progress_ratio = match sync { + SyncMetric::Step => current_step.max(1) as f64 / max_steps.max(1) as f64, + SyncMetric::Distance => { + let total = total_distance.max(1.0); + let remaining = (total_distance - last_distance_reached).max(1.0); + let reached = (total - remaining).max(1.0); + reached / total + } + }; + let frame_index = round_half_even(final_frame_index as f64 * progress_ratio) + .min(final_frame_index) + .max(0); + let frame = scene.frames[frame_index as usize]; + ch.animation.current_character_visual = scene.all_frames[frame].character_visual.clone(); + } + } + } + + /// Animation._step_eased_scene (+ _ease_animation). + fn step_eased_scene(&mut self, id: CharId, scene_slot: usize, ease: crate::utils::easing::Easing) { + let ch = &mut self.terminal.arena[id.0 as usize]; + let scene = ch.animation.scenes.at_mut(scene_slot); + let elapsed_step_ratio = scene.easing_current_step as f64 / scene.easing_total_steps as f64; + let easing_factor = ease.ease(elapsed_step_ratio); + let final_frame_index = (scene.easing_total_steps - 1).max(0); + let frame_index = round_half_even(easing_factor * final_frame_index as f64) + .min(final_frame_index) + .max(0); + let frame = scene.frame_index_map[frame_index as usize]; + ch.animation.current_character_visual = scene.all_frames[frame].character_visual.clone(); + + scene.easing_current_step += 1; + if scene.easing_current_step == scene.easing_total_steps { + if scene.is_looping { + scene.easing_current_step = 0; + } else { + let drained: Vec = scene.frames.drain(..).collect(); + scene.played_frames.extend(drained); + } + } + } + + /// Animation._complete_scene_if_finished: fires SCENE_COMPLETE every tick + /// for looping scenes, faithfully. + fn complete_scene_if_finished(&mut self, hooks: &mut dyn EffectHooks, id: CharId, scene_slot: usize) { + { + // The stepping above cannot clear active_scene, so the slot still + // holds it and active_scene_is_complete reduces to its scene test. + let anim = &self.terminal.arena[id.0 as usize].animation; + let scene = anim.scenes.at(scene_slot); + if !(scene.frames.is_empty() || scene.is_looping) { + return; + } + } + { + let anim = &mut self.terminal.arena[id.0 as usize].animation; + let scene = anim.scenes.at_mut(scene_slot); + if !scene.is_looping { + scene.reset_scene(); + anim.active_scene = None; + } + } + if self.observes_event(id, Event::SceneComplete) { + let scene_id = Rc::clone(self.terminal.arena[id.0 as usize].animation.scenes.key_at(scene_slot)); + self.handle_event(hooks, id, Event::SceneComplete, CallerRef::Scene(&scene_id)); + } + } + + // ------------------------------------------------------------------ + // ticking (EffectCharacter.tick / BaseEffectIterator.update / frame) + // ------------------------------------------------------------------ + + /// EffectCharacter.tick: motion first, then animation. + pub fn tick(&mut self, hooks: &mut dyn EffectHooks, id: CharId) { + self.motion_move(hooks, id); + self.step_animation(hooks, id); + } + + /// BaseEffectIterator.update: tick a snapshot of active characters in + /// canonical ascending-id order, then prune the not-is_active ones. + pub fn update(&mut self, hooks: &mut dyn EffectHooks) { + let mut snapshot = std::mem::take(&mut self.active_character_scratch); + snapshot.clear(); + snapshot.extend(self.active_characters.iter()); + for &id in &snapshot { + self.tick(hooks, id); + } + snapshot.clear(); + self.active_character_scratch = snapshot; + + let arena = &self.terminal.arena; + self.active_characters.retain(|id| arena[id.0 as usize].is_active()); + } + + /// BaseEffectIterator.frame: enforce framerate (real clock only), then the + /// formatted output string; advances the virtual clock by one frame. + pub fn frame(&mut self) -> String { + if !self.suppress_ansi + && matches!(self.clock, Clock::Real { .. }) + && self.terminal.config.frame_rate != 0 + { + self.terminal.enforce_framerate(); + } + self.clock.advance_frame(); + if self.suppress_ansi { + self.terminal.snapshot_present_grid(); + String::new() + } else { + self.terminal.get_formatted_output_string() + } + } +} diff --git a/vendor/ttfx/src/engine/effect.rs b/vendor/ttfx/src/engine/effect.rs new file mode 100644 index 0000000..9dbc86f --- /dev/null +++ b/vendor/ttfx/src/engine/effect.rs @@ -0,0 +1,158 @@ +//! Effect trait and run loop (base_effect.py equivalents). + +use std::io::Write; + +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::error::EngineError; + +/// One effect: build() once (upstream iterator __init__/build), then +/// next_frame() until None (upstream __next__/StopIteration). Every effect +/// also implements EffectHooks for its registered callbacks. +pub trait Effect: EffectHooks { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError>; + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option; + + /// Tick one simulation step without assembling an ANSI frame. + /// `next_frame` remains `advance` plus `get_formatted_output_string`. + fn advance(&mut self, ctx: &mut EngineCtx) -> bool { + let previous = ctx.suppress_ansi; + ctx.suppress_ansi = true; + let going = self.next_frame(ctx).is_some(); + ctx.suppress_ansi = previous; + going + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunOutcome { + Complete, + Interrupted, + Terminated, + TerminalResized, + /// The terminal went away mid-run — see [`output_closed`]. + OutputClosed, +} + +/// __main__ run loop with terminal_output(): prep canvas, stream frames, +/// always restore the cursor (even on error — RAII would not run on a raw +/// process exit, so this is explicit). +/// +/// On a tty a settled terminal resize also ends the pass, wiped and parked at +/// the top of the area so the caller can rebuild in place, and a terminal that +/// goes away ends the run. A redirected stream gets neither: SIGWINCH there is +/// not about our output, and a write that fails to a file is a real failure. +pub fn run_effect( + effect: &mut dyn Effect, + ctx: &mut EngineCtx, + tty_output: bool, +) -> Result { + effect.build(ctx)?; + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + let mut outcome = RunOutcome::Complete; + let result = (|| -> std::io::Result<()> { + ctx.terminal.prep_canvas(&mut out)?; + loop { + if let Some(stop) = requested_stop(ctx, tty_output) { + outcome = stop; + break; + } + let Some(frame) = effect.next_frame(ctx) else { + break; + }; + if let Some(stop) = requested_stop(ctx, tty_output) { + outcome = stop; + ctx.terminal.recycle_output_string(frame); + break; + } + ctx.terminal.print_frame(&mut out, &frame)?; + ctx.terminal.recycle_output_string(frame); + } + Ok(()) + })(); + let teardown = if outcome == RunOutcome::TerminalResized { + // Leave the cursor hidden and parked at the top of the wiped area: the + // rebuild redraws in place, and showing the cursor here would strobe it + // dozens of times a second through a window drag. + ctx.terminal.reset_canvas_area(&mut out) + } else { + ctx.terminal.restore_cursor(&mut out, "\n") + }; + out.flush().ok(); + match result.and(teardown) { + Ok(()) => Ok(outcome), + Err(e) if tty_output && output_closed(&e) => Ok(RunOutcome::OutputClosed), + Err(e) => Err(io_err(e)), + } +} + +/// The terminal an animation is drawing on can go away mid-frame: the +/// screensaver's window is killed at lock, an emulator exits, a reader closes +/// the pipe. Every write after that fails, teardown included, and there is no +/// cursor left to restore — so this ends the run instead of raising an error +/// nobody can be told about (basecamp/omarchy#6762). +/// +/// EIO is the pty slave outliving its master; EPIPE only surfaces here when +/// SIGPIPE was ignored by whoever started us, since we restore its default. +/// Both mean the same thing, and neither is a failure of this run. +fn output_closed(e: &std::io::Error) -> bool { + const EIO: i32 = 5; + e.kind() == std::io::ErrorKind::BrokenPipe || e.raw_os_error() == Some(EIO) +} + +fn requested_stop(ctx: &mut EngineCtx, stop_on_resize: bool) -> Option { + if crate::interrupted() { + Some(RunOutcome::Interrupted) + } else if crate::terminated() { + Some(RunOutcome::Terminated) + } else if stop_on_resize && ctx.terminal.resize_settled() { + Some(RunOutcome::TerminalResized) + } else { + None + } +} + +/// Parity mode: write length-prefixed frames to stdout, no tty escapes. +pub fn dump_effect( + effect: &mut dyn Effect, + ctx: &mut EngineCtx, + max_frames: Option, +) -> Result { + effect.build(ctx)?; + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + let mut count: u64 = 0; + while let Some(frame) = effect.next_frame(ctx) { + let data = frame.as_bytes(); + writeln!(out, "{}", data.len()).map_err(io_err)?; + out.write_all(data).map_err(io_err)?; + out.write_all(b"\n").map_err(io_err)?; + ctx.terminal.recycle_output_string(frame); + count += 1; + if max_frames.is_some_and(|m| count >= m) { + break; + } + } + out.flush().ok(); + crate::errln!("frames={count}"); + Ok(count) +} + +fn io_err(e: std::io::Error) -> EngineError { + EngineError::Other(format!("io error: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Error, ErrorKind}; + + #[test] + fn a_lost_output_stream_is_not_an_error() { + assert!(output_closed(&Error::from_raw_os_error(5))); + assert!(output_closed(&Error::from(ErrorKind::BrokenPipe))); + // A full disk or a revoked device still deserves a report. + assert!(!output_closed(&Error::from_raw_os_error(28))); + assert!(!output_closed(&Error::from(ErrorKind::PermissionDenied))); + } +} diff --git a/vendor/ttfx/src/engine/error.rs b/vendor/ttfx/src/engine/error.rs new file mode 100644 index 0000000..71b1fdc --- /dev/null +++ b/vendor/ttfx/src/engine/error.rs @@ -0,0 +1,22 @@ +//! Engine error taxonomy. Error *conditions* match upstream; message text may differ. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EngineError { + /// UnsupportedAnsiSequenceError: printed to stderr, exit 1 (see __main__.py). + UnsupportedAnsiSequence(String), + /// Any other upstream hard error (ValueError etc.). + Other(String), +} + +impl std::fmt::Display for EngineError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EngineError::UnsupportedAnsiSequence(seq) => { + write!(f, "Unsupported ANSI sequence in input data: {seq:?}") + } + EngineError::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for EngineError {} diff --git a/vendor/ttfx/src/engine/events.rs b/vendor/ttfx/src/engine/events.rs new file mode 100644 index 0000000..c339606 --- /dev/null +++ b/vendor/ttfx/src/engine/events.rs @@ -0,0 +1,224 @@ +//! Event system, ported from engine/base_character.py EventHandler. +//! +//! Storage is plain data; dispatch lives on EngineCtx (engine/ctx.rs) so that +//! actions execute inline at the exact upstream emission points, reentrantly +//! (plan.md §4.2). Effect callbacks are (CallbackId, payload) routed through +//! EffectHooks::dispatch_callback — never closures in the arena. + +use crate::engine::character::CharId; +use crate::utils::geometry::Coord; +use crate::utils::graphics::Color; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Event { + SegmentEntered, + SegmentExited, + PathActivated, + PathComplete, + PathHolding, + SceneActivated, + SceneComplete, +} + +impl Event { + #[inline] + fn bit(self) -> u8 { + 1 << (self as u8) + } +} + +/// Waypoint identity for event keying: upstream Waypoint is a frozen dataclass +/// hashed/compared by ALL fields (id, coord, bezier controls) — two waypoints +/// with identical fields in different paths collide, faithfully. +/// +/// Field order is load-bearing for speed, not for meaning: the derived +/// comparison short-circuits in declaration order, and `coord` rejects +/// non-matches with two integer compares instead of a string memcmp. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WaypointKey { + pub coord: Coord, + pub waypoint_id: std::rc::Rc, + pub bezier_control: Option>, +} + +/// Event caller identity. Scene/Path compare by id (their upstream __eq__). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CallerKey { + Scene(String), + Path(String), + Waypoint(WaypointKey), +} + +/// A caller identity borrowed for the duration of a lookup. Emission sites +/// already hold the id they are firing for, so matching against the table costs +/// nothing — building an owned CallerKey per emission did. +#[derive(Debug, Clone, Copy)] +pub enum CallerRef<'a> { + Scene(&'a str), + Path(&'a str), + Waypoint(&'a WaypointKey), +} + +/// FNV-1a over a byte run, seeded so the three caller kinds cannot collide. +#[inline] +fn fnv(mut hash: u32, bytes: &[u8]) -> u32 { + for &byte in bytes { + hash ^= byte as u32; + hash = hash.wrapping_mul(16_777_619); + } + hash +} + +impl CallerRef<'_> { + /// A cheap digest of the caller's identity. Registered entries store theirs, + /// so a lookup hashes once and then rejects candidates with an integer + /// compare instead of a string compare each. + #[inline] + fn fingerprint(self) -> u32 { + match self { + CallerRef::Scene(id) => fnv(0x0100_0193, id.as_bytes()), + CallerRef::Path(id) => fnv(0x0200_0193, id.as_bytes()), + CallerRef::Waypoint(key) => { + let hash = fnv(0x0300_0193, &key.coord.column.to_le_bytes()); + let hash = fnv(hash, &key.coord.row.to_le_bytes()); + fnv(hash, key.waypoint_id.as_bytes()) + } + } + } +} + +impl CallerKey { + #[inline] + fn as_ref(&self) -> CallerRef<'_> { + match self { + CallerKey::Scene(id) => CallerRef::Scene(id), + CallerKey::Path(id) => CallerRef::Path(id), + CallerKey::Waypoint(key) => CallerRef::Waypoint(key), + } + } + + #[inline] + fn matches(&self, caller: CallerRef<'_>) -> bool { + match (self, caller) { + (CallerKey::Scene(a), CallerRef::Scene(b)) => **a == *b, + (CallerKey::Path(a), CallerRef::Path(b)) => **a == *b, + (CallerKey::Waypoint(a), CallerRef::Waypoint(b)) => a == b, + _ => false, + } + } +} + +/// Typed payload values for effect callbacks (upstream Callback *args). +#[derive(Debug, Clone, PartialEq)] +pub enum CallbackValue { + Int(i64), + Float(f64), + Str(String), + Coord(Coord), + Char(CharId), + Color(Color), +} + +/// An effect-defined callback: the id selects behavior inside the effect's +/// dispatch_callback; args are owned data captured at registration. +#[derive(Debug, Clone, PartialEq)] +pub struct EffectCallback { + pub id: u32, + pub args: Vec, +} + +/// A registered action with its resolved target (upstream resolves string ids +/// to objects at registration; Scene/Path equality is by id, so ids suffice). +#[derive(Debug, Clone, PartialEq)] +pub enum EventAction { + ActivatePath(String), + ActivateScene(String), + DeactivatePath(Option), + DeactivateScene(Option), + ResetAppearance, + SetLayer(i64), + SetCoordinate(Coord), + Callback(EffectCallback), +} + +/// Per-character event table: insertion-ordered (event, caller) -> actions. +/// +/// `subscribed` mirrors the table as a bitmask of registered event kinds. Most +/// characters register a handful of events while the engine emits thousands, +/// so callers test it first and skip building the (allocating) CallerKey when +/// nothing could match. +#[derive(Debug, Clone, Default)] +pub struct EventHandler { + registered_events: Vec, + subscribed: u8, +} + +#[derive(Debug, Clone)] +struct RegisteredEvent { + event: Event, + fingerprint: u32, + caller: CallerKey, + actions: Vec, +} + +impl EventHandler { + /// register_event with the duplicate check (upstream raises + /// DuplicateEventRegistrationError). Caller/target id resolution and type + /// validation happen in EngineCtx::register_event, which has arena access. + pub fn push(&mut self, event: Event, caller: CallerKey, action: EventAction) -> Result<(), String> { + let fingerprint = caller.as_ref().fingerprint(); + let existing = self.registered_events.iter_mut().find(|entry| { + entry.event == event && entry.fingerprint == fingerprint && entry.caller == caller + }); + if let Some(entry) = existing { + if entry.actions.contains(&action) { + return Err(format!( + "duplicate event registration: {:?} {:?}", + (entry.event, &entry.caller), + action + )); + } + entry.actions.push(action); + } else { + self.registered_events.push(RegisteredEvent { event, fingerprint, caller, actions: vec![action] }); + } + self.subscribed |= event.bit(); + Ok(()) + } + + /// True when at least one action is registered for this event kind, for any + /// caller. A false answer means `actions_index` cannot match. + #[inline] + pub fn subscribes(&self, event: Event) -> bool { + self.subscribed & event.bit() != 0 + } + + #[inline] + pub fn actions_index(&self, event: Event, caller: CallerRef<'_>) -> Option { + if !self.subscribes(event) { + return None; + } + // Hashing the query only pays once there is a run of candidates to + // reject; a table with a couple of entries compares them directly. + if self.registered_events.len() <= 2 { + return self + .registered_events + .iter() + .position(|entry| entry.event == event && entry.caller.matches(caller)); + } + let fingerprint = caller.fingerprint(); + self.registered_events.iter().position(|entry| { + entry.event == event && entry.fingerprint == fingerprint && entry.caller.matches(caller) + }) + } + + #[inline] + pub fn actions(&self, index: usize) -> &[EventAction] { + &self.registered_events[index].actions + } + + pub fn clear(&mut self) { + self.registered_events.clear(); + self.subscribed = 0; + } +} diff --git a/vendor/ttfx/src/engine/input.rs b/vendor/ttfx/src/engine/input.rs new file mode 100644 index 0000000..ae8aa31 --- /dev/null +++ b/vendor/ttfx/src/engine/input.rs @@ -0,0 +1,440 @@ +//! Input preprocessing: the mini terminal emulator from Terminal._preprocess_input_data. +//! +//! Walks the input codepoint-by-codepoint (one char = one cell, faithfully — no +//! wcwidth upstream), tracking SGR color state and cursor movement, producing +//! rows of arena character ids. Everything here, including which malformed +//! sequences error vs. get silently ignored, transcribes terminal.py:604-862. + +use std::collections::HashMap; + +use crate::engine::animation::ExistingColorHandling; +use crate::engine::character::{CharId, EffectCharacter}; +use crate::engine::error::EngineError; +use crate::engine::terminal::TerminalConfig; +use crate::utils::graphics::Color; + +/// Insertion-ordered Color -> count map (upstream: dict[Color, int]). Iteration +/// order is behavior for get_input_colors; the population is small, linear scan. +#[derive(Debug, Default, Clone)] +pub struct ColorFrequency(pub Vec<(Color, i64)>); + +impl ColorFrequency { + fn increment(&mut self, color: &Color) { + if let Some(entry) = self.0.iter_mut().find(|(c, _)| c == color) { + entry.1 += 1; + } else { + self.0.push((color.clone(), 1)); + } + } +} + +#[derive(Clone, Default)] +struct ActiveState { + fg_sequence: String, // "" = none, like upstream's active_sequences + bg_sequence: String, + fg_color: Option, + bg_color: Option, + bold: bool, + standard_fg_parameter: Option, +} + +pub struct Preprocessor<'a> { + pub arena: &'a mut Vec, + pub next_character_id: &'a mut u32, + pub input_colors_frequency: &'a mut ColorFrequency, + pub config: &'a TerminalConfig, +} + +impl<'a> Preprocessor<'a> { + /// Returns rows of character ids (top row first, as parsed). + pub fn preprocess(&mut self, input_data: &str) -> Result>, EngineError> { + let chars: Vec = input_data.chars().collect(); + let mut screen: HashMap<(i64, i64), CharId> = HashMap::new(); + let mut state = ActiveState::default(); + let (mut row, mut column) = (0i64, 0i64); + let (mut max_row, mut max_column) = (0i64, 0i64); + let mut i = 0usize; + + while i < chars.len() { + if chars[i] == '\x1b' { + let end = match_escape_sequence(&chars, i) + .ok_or_else(|| EngineError::UnsupportedAnsiSequence(chars[i].to_string()))?; + let sequence: String = chars[i..end].iter().collect(); + if sequence.starts_with("\x1b[") { + let (params, intermediates, final_byte) = split_csi(&sequence) + .ok_or_else(|| EngineError::UnsupportedAnsiSequence(sequence.clone()))?; + if final_byte == 'm' { + self.apply_sgr_sequence(&sequence, ¶ms, &mut state)?; + } else if is_supported_private_mode_sequence(&sequence) { + // ignored: cursor show/hide, autowrap on/off + } else { + let (new_row, new_column) = + apply_cursor_sequence(&sequence, ¶ms, &intermediates, final_byte, row, column)?; + row = new_row; + column = new_column; + max_row = max_row.max(row); + max_column = max_column.max(column); + } + } else { + return Err(EngineError::UnsupportedAnsiSequence(sequence)); + } + i = end; + } else if chars[i] == '\n' { + row += 1; + column = 0; + max_row = max_row.max(row); + i += 1; + } else if chars[i] == '\r' { + column = 0; + i += 1; + } else { + let (symbol, count) = if chars[i] == '\t' { + (' ', self.config.tab_width - (column % self.config.tab_width)) + } else { + (chars[i], 1) + }; + for _ in 0..count { + let id = self.build_character(&symbol.to_string(), &state)?; + screen.insert((row, column), id); + max_row = max_row.max(row); + max_column = max_column.max(column); + column += 1; + } + i += 1; + } + } + + let empty_state = ActiveState::default(); + let mut characters: Vec> = Vec::new(); + for screen_row in 0..=max_row { + let mut line: Vec = Vec::new(); + for screen_column in 0..=max_column { + let id = match screen.get(&(screen_row, screen_column)) { + Some(&id) => id, + None => self.build_character(" ", &empty_state)?, + }; + line.push(id); + } + while let Some(&last) = line.last() { + let ch = &self.arena[last.0 as usize]; + if ch.input_symbol == " " + && ch.animation.input_fg_color.is_none() + && ch.animation.input_bg_color.is_none() + { + line.pop(); + } else { + break; + } + } + characters.push(line); + } + while characters.last().is_some_and(|line| line.is_empty()) { + characters.pop(); + } + + if characters.is_empty() { + // Faithful: the fallback character carries the END-of-input active state. + let id = self.build_character(" ", &state)?; + characters.push(vec![id]); + } + Ok(characters) + } + + /// build_character: allocates an id (even for characters later discarded), + /// captures active colors, bumps the color frequency at CREATION time (even + /// if a later cursor write overwrites the cell — see plan.md §5.13). + fn build_character(&mut self, symbol: &str, state: &ActiveState) -> Result { + let mut ch = EffectCharacter::new(*self.next_character_id, symbol, 0, 0); + *self.next_character_id += 1; + // fg first, then bg — upstream dict iteration order over active_sequences + if !state.fg_sequence.is_empty() { + if let Some(color) = &state.fg_color { + ch.input_ansi_fg_sequence = Some(state.fg_sequence.clone()); + self.input_colors_frequency.increment(color); + ch.animation.input_fg_color = Some(color.clone()); + } + } + if !state.bg_sequence.is_empty() { + if let Some(color) = &state.bg_color { + ch.input_ansi_bg_sequence = Some(state.bg_sequence.clone()); + self.input_colors_frequency.increment(color); + ch.animation.input_bg_color = Some(color.clone()); + } + } + ch.animation.input_bold = state.bold; + ch.animation.no_color = self.config.no_color; + ch.animation.use_xterm_colors = self.config.xterm_colors; + ch.animation.existing_color_handling = self.config.existing_color_handling; + ch.uses_input_preexisting_colors = true; + if ch.animation.existing_color_handling == ExistingColorHandling::Always { + let input_symbol = ch.input_symbol.clone(); + ch.animation.set_appearance(&input_symbol, true, None, None); + } + let id = CharId(self.arena.len() as u32); + self.arena.push(ch); + Ok(id) + } + + fn apply_sgr_sequence( + &mut self, + sequence: &str, + params_text: &str, + state: &mut ActiveState, + ) -> Result<(), EngineError> { + let mut parameters = parse_csi_parameters(params_text)?; + if parameters.is_empty() { + parameters = vec![0]; + } + let mut idx = 0usize; + while idx < parameters.len() { + let parameter = parameters[idx]; + match parameter { + 0 => { + state.fg_sequence.clear(); + state.bg_sequence.clear(); + state.fg_color = None; + state.bg_color = None; + state.bold = false; + state.standard_fg_parameter = None; + } + 1 => { + state.bold = true; + if let Some(p) = state.standard_fg_parameter { + state.fg_color = Some(xterm_color(p - 30 + 8)?); + } + } + 22 => { + state.bold = false; + if let Some(p) = state.standard_fg_parameter { + state.fg_color = Some(xterm_color(p - 30)?); + } + } + 39 => { + state.fg_sequence.clear(); + state.fg_color = None; + state.standard_fg_parameter = None; + } + 49 => { + state.bg_sequence.clear(); + state.bg_color = None; + } + 30..=37 => { + let color = xterm_color(parameter - 30 + if state.bold { 8 } else { 0 })?; + state.fg_sequence = format!("\x1b[{parameter}m"); + state.fg_color = Some(color); + state.standard_fg_parameter = Some(parameter); + } + 90..=97 => { + let color = xterm_color(parameter - 90 + 8)?; + state.fg_sequence = format!("\x1b[{parameter}m"); + state.fg_color = Some(color); + state.standard_fg_parameter = None; + } + 40..=47 => { + let color = xterm_color(parameter - 40)?; + state.bg_sequence = format!("\x1b[{parameter}m"); + state.bg_color = Some(color); + } + 100..=107 => { + let color = xterm_color(parameter - 100 + 8)?; + state.bg_sequence = format!("\x1b[{parameter}m"); + state.bg_color = Some(color); + } + 38 | 48 => { + if idx + 1 >= parameters.len() { + return Err(EngineError::UnsupportedAnsiSequence(sequence.to_string())); + } + let is_fg = parameter == 38; + let selector = parameter; + let color_mode = parameters[idx + 1]; + let (normalized_sequence, color) = match color_mode { + 5 => { + if idx + 2 >= parameters.len() { + return Err(EngineError::UnsupportedAnsiSequence(sequence.to_string())); + } + let code = parameters[idx + 2]; + let color = xterm_color(code)?; + idx += 2; + (format!("\x1b[{selector};5;{code}m"), color) + } + 2 => { + if idx + 4 >= parameters.len() { + return Err(EngineError::UnsupportedAnsiSequence(sequence.to_string())); + } + let hex: String = (2..5).map(|o| format!("{:02X}", parameters[idx + o])).collect(); + let color = Color::from_hex(&hex).map_err(EngineError::Other)?; + let (r, g, b) = color.rgb_ints(); + idx += 4; + (format!("\x1b[{selector};2;{r};{g};{b}m"), color) + } + _ => return Err(EngineError::UnsupportedAnsiSequence(sequence.to_string())), + }; + if is_fg { + state.fg_sequence = normalized_sequence; + state.fg_color = Some(color); + state.standard_fg_parameter = None; + } else { + state.bg_sequence = normalized_sequence; + state.bg_color = Some(color); + } + } + // Faithful: any other SGR parameter value is silently ignored + // (the upstream loop has no fallback error branch). + _ => {} + } + idx += 1; + } + Ok(()) + } +} + +fn xterm_color(code: i64) -> Result { + // Upstream Color(int) raises ValueError outside 0..=255; that error is not + // an UnsupportedAnsiSequenceError but still aborts the run. + if (0..=255).contains(&code) { + Ok(Color::from_xterm(code as u8)) + } else { + Err(EngineError::Other(format!("invalid xterm color code in input: {code}"))) + } +} + +/// parse_csi_parameters: only digits and ';' allowed; empty fields are 0. +fn parse_csi_parameters(parameters: &str) -> Result, EngineError> { + if parameters.chars().any(|c| !c.is_ascii_digit() && c != ';') { + return Err(EngineError::UnsupportedAnsiSequence(format!("\x1b[{parameters}"))); + } + if parameters.is_empty() { + return Ok(vec![]); + } + Ok(parameters + .split(';') + .map(|p| if p.is_empty() { 0 } else { p.parse().unwrap() }) + .collect()) +} + +fn default_parameter(parameters: &[i64]) -> i64 { + match parameters.first() { + None => 1, + Some(&p) => p.max(1), + } +} + +fn is_supported_private_mode_sequence(sequence: &str) -> bool { + matches!(sequence, "\x1b[?25h" | "\x1b[?25l" | "\x1b[?7h" | "\x1b[?7l") +} + +fn apply_cursor_sequence( + sequence: &str, + params_text: &str, + intermediates: &str, + final_byte: char, + mut row: i64, + mut column: i64, +) -> Result<(i64, i64), EngineError> { + if !intermediates.is_empty() { + return Err(EngineError::UnsupportedAnsiSequence(sequence.to_string())); + } + if params_text.starts_with('?') { + return Err(EngineError::UnsupportedAnsiSequence(sequence.to_string())); + } + let parameters = parse_csi_parameters(params_text)?; + match final_byte { + 'A' => row -= default_parameter(¶meters), + 'B' => row += default_parameter(¶meters), + 'C' => column += default_parameter(¶meters), + 'D' => column -= default_parameter(¶meters), + 'E' => { + row += default_parameter(¶meters); + column = 0; + } + 'F' => { + row -= default_parameter(¶meters); + column = 0; + } + 'G' => column = default_parameter(¶meters) - 1, + 'H' | 'f' => { + row = default_parameter(¶meters) - 1; + column = match parameters.get(1) { + Some(&p) if p != 0 => p - 1, + _ => 0, // (1) - 1 + }; + } + _ => return Err(EngineError::UnsupportedAnsiSequence(sequence.to_string())), + } + Ok((row.max(0), column.max(0))) +} + +/// Emulates upstream's ansi_escape_sequence_pattern.match at a position, with +/// the same alternation order: OSC, CSI, then `\x1b.` (any char except newline). +/// Returns the exclusive end index of the match. +fn match_escape_sequence(chars: &[char], start: usize) -> Option { + debug_assert_eq!(chars[start], '\x1b'); + // OSC: \x1b\] [^\x07]* (\x07 | \x1b\\) — greedy class run, longest match first + if chars.get(start + 1) == Some(&']') { + let run_start = start + 2; + let mut t = run_start; + while t < chars.len() && chars[t] != '\x07' { + t += 1; + } + if t < chars.len() { + // class run ends right before \x07: longest match consumes it + return Some(t + 1); + } + // no BEL: backtrack for the rightmost \x1b\\ terminator inside the run + let mut p = chars.len(); + while p >= run_start + 2 { + if chars[p - 2] == '\x1b' && chars[p - 1] == '\\' { + return Some(p); + } + p -= 1; + } + // fall through to the remaining alternatives, like regex alternation + } + // CSI: \x1b\[ [0-?]* [ -/]* [@-~] + if chars.get(start + 1) == Some(&'[') { + let mut t = start + 2; + while t < chars.len() && ('\u{30}'..='\u{3f}').contains(&chars[t]) { + t += 1; + } + while t < chars.len() && ('\u{20}'..='\u{2f}').contains(&chars[t]) { + t += 1; + } + if t < chars.len() && ('\u{40}'..='\u{7e}').contains(&chars[t]) { + return Some(t + 1); + } + // no valid final byte: fall through to \x1b. + } + // \x1b. — '.' does not match newline + match chars.get(start + 1) { + Some(&c) if c != '\n' => Some(start + 2), + _ => None, + } +} + +/// splits a full CSI sequence into (params, intermediates, final) like +/// csi_sequence_pattern.fullmatch; None if it isn't a well-formed CSI sequence. +fn split_csi(sequence: &str) -> Option<(String, String, char)> { + let chars: Vec = sequence.chars().collect(); + if chars.len() < 3 || chars[0] != '\x1b' || chars[1] != '[' { + return None; + } + let mut t = 2; + let params_start = t; + while t < chars.len() && ('\u{30}'..='\u{3f}').contains(&chars[t]) { + t += 1; + } + let params: String = chars[params_start..t].iter().collect(); + let inter_start = t; + while t < chars.len() && ('\u{20}'..='\u{2f}').contains(&chars[t]) { + t += 1; + } + let intermediates: String = chars[inter_start..t].iter().collect(); + if t != chars.len() - 1 { + return None; + } + let final_byte = chars[t]; + if !('\u{40}'..='\u{7e}').contains(&final_byte) { + return None; + } + Some((params, intermediates, final_byte)) +} diff --git a/vendor/ttfx/src/engine/mod.rs b/vendor/ttfx/src/engine/mod.rs new file mode 100644 index 0000000..f716b26 --- /dev/null +++ b/vendor/ttfx/src/engine/mod.rs @@ -0,0 +1,14 @@ +pub mod active_characters; +pub mod animation; +pub mod canvas; +pub mod character; +pub mod ctx; +pub mod effect; +pub mod error; +pub mod events; +pub mod input; +pub mod motion; +pub mod particles; +pub mod terminal; + +pub use terminal::{PackedCell, CELL_HAS_BACKGROUND, CELL_HAS_GLYPH}; diff --git a/vendor/ttfx/src/engine/motion.rs b/vendor/ttfx/src/engine/motion.rs new file mode 100644 index 0000000..fd80476 --- /dev/null +++ b/vendor/ttfx/src/engine/motion.rs @@ -0,0 +1,225 @@ +//! Waypoint, Segment, Path, Motion — state from engine/motion.py. The stepping +//! logic that fires events (Path.step, Motion.move, activate_path) lives on +//! EngineCtx (ctx.rs) so actions run inline at upstream emission points. + +use std::rc::Rc; + +use crate::engine::events::WaypointKey; +use crate::utils::easing::Easing; +use crate::utils::geometry::{self, Coord}; +use crate::utils::ordered_map::OrderedMap; +use crate::utils::pycompat::round_half_even; + +/// Waypoints are cloned constantly — into segments, into origin segments on +/// every path activation, and into event keys — so both owned fields are +/// reference counted and a clone is two refcount bumps. +#[derive(Debug, Clone, PartialEq)] +pub struct Waypoint { + pub waypoint_id: Rc, + pub coord: Coord, + pub bezier_control: Option>, +} + +impl Waypoint { + pub fn key(&self) -> WaypointKey { + WaypointKey { + coord: self.coord, + waypoint_id: self.waypoint_id.clone(), + bezier_control: self.bezier_control.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub struct Segment { + pub start: Waypoint, + pub end: Waypoint, + pub distance: f64, + pub enter_event_triggered: bool, + pub exit_event_triggered: bool, +} + +impl Segment { + pub fn new(start: Waypoint, end: Waypoint, distance: f64) -> Self { + Segment { start, end, distance, enter_event_triggered: false, exit_event_triggered: false } + } +} + +#[derive(Debug, Clone)] +pub struct Path { + pub path_id: String, + pub speed: f64, + pub ease: Option, + pub layer: Option, + pub hold_time: i64, + pub loop_: bool, + pub segments: Vec, + pub waypoints: Vec, + pub total_distance: f64, + pub current_step: i64, + pub max_steps: i64, + pub hold_time_remaining: i64, + pub last_distance_reached: f64, + /// Distance of the synthetic origin segment set at activation (upstream + /// keeps the Segment object; only its distance is read back). + pub origin_segment: Option, +} + +impl Path { + pub fn new( + path_id: &str, + speed: f64, + ease: Option, + layer: Option, + hold_time: i64, + loop_: bool, + ) -> Result { + if speed <= 0.0 { + return Err(format!("Path speed must be greater than 0. Received: {speed}")); + } + Ok(Path { + path_id: path_id.to_string(), + speed, + ease, + layer, + hold_time, + loop_, + segments: Vec::new(), + waypoints: Vec::new(), + total_distance: 0.0, + current_step: 0, + max_steps: 0, + hold_time_remaining: hold_time, + last_distance_reached: 0.0, + origin_segment: None, + }) + } + + /// Path.new_waypoint: auto-id like scenes; duplicate explicit id errors. + pub fn new_waypoint( + &mut self, + coord: Coord, + bezier_control: Option>, + waypoint_id: &str, + ) -> Result { + let waypoint_id: Rc = if waypoint_id.is_empty() { + let mut current_id = self.waypoints.len(); + loop { + let candidate = current_id.to_string(); + if !self.waypoints.iter().any(|w| *w.waypoint_id == *candidate) { + break Rc::from(candidate); + } + current_id += 1; + } + } else { + if self.waypoints.iter().any(|w| *w.waypoint_id == *waypoint_id) { + return Err(format!("duplicate waypoint id: {waypoint_id}")); + } + Rc::from(waypoint_id) + }; + // Python: empty tuple bezier_control is falsy -> None + let bezier_control = bezier_control.filter(|v| !v.is_empty()).map(Rc::from); + let waypoint = Waypoint { waypoint_id, coord, bezier_control }; + self.add_waypoint_to_path(waypoint.clone()); + Ok(waypoint) + } + + /// Path._add_waypoint_to_path. + fn add_waypoint_to_path(&mut self, waypoint: Waypoint) { + self.waypoints.push(waypoint); + if self.waypoints.len() < 2 { + return; + } + let prev = &self.waypoints[self.waypoints.len() - 2]; + let waypoint = &self.waypoints[self.waypoints.len() - 1]; + let distance_from_previous = match &waypoint.bezier_control { + Some(control) => geometry::find_length_of_bezier_curve(prev.coord, control, waypoint.coord), + None => geometry::find_length_of_line(prev.coord, waypoint.coord, true), + }; + self.total_distance += distance_from_previous; + self.segments.push(Segment::new(prev.clone(), waypoint.clone(), distance_from_previous)); + self.max_steps = round_half_even(self.total_distance / self.speed); + } + + pub fn query_waypoint(&self, waypoint_id: &str) -> Result<&Waypoint, String> { + self.waypoints + .iter() + .find(|w| *w.waypoint_id == *waypoint_id) + .ok_or_else(|| format!("waypoint not found: {waypoint_id}")) + } +} + +/// engine/motion.py Motion: per-character movement state. `active_path` and +/// `completed_path` are path ids (upstream holds object references; Path +/// equality is by id). +#[derive(Debug, Clone)] +pub struct Motion { + pub paths: OrderedMap, + pub current_coord: Coord, + pub previous_coord: Coord, + pub active_path: Option>, + pub completed_path: Option>, +} + +impl Motion { + pub fn new(input_coord: Coord) -> Self { + Motion { + paths: OrderedMap::new(), + current_coord: input_coord, + previous_coord: Coord::new(-1, -1), + active_path: None, + completed_path: None, + } + } + + pub fn set_coordinate(&mut self, coord: Coord) { + self.current_coord = coord; + } + + /// Motion.new_path: auto-id probing; duplicate explicit id errors. + pub fn new_path( + &mut self, + speed: f64, + ease: Option, + layer: Option, + hold_time: i64, + loop_: bool, + path_id: &str, + ) -> Result { + let path_id = if path_id.is_empty() { + let mut current_id = self.paths.len(); + loop { + let candidate = current_id.to_string(); + if !self.paths.contains_key(&candidate) { + break candidate; + } + current_id += 1; + } + } else { + if self.paths.contains_key(path_id) { + return Err(format!("duplicate path id: {path_id}")); + } + path_id.to_string() + }; + let path = Path::new(&path_id, speed, ease, layer, hold_time, loop_)?; + self.paths.insert(path_id.clone(), path); + Ok(path_id) + } + + pub fn movement_is_complete(&self) -> bool { + self.active_path.is_none() + } + + /// Motion.deactivate_path: None clears unconditionally; otherwise only + /// clears when the given path is the active one. + pub fn deactivate_path(&mut self, path_id: Option<&str>) { + match path_id { + None => self.active_path = None, + Some(id) => { + if self.active_path.as_deref() == Some(id) { + self.active_path = None; + } + } + } + } +} diff --git a/vendor/ttfx/src/engine/particles.rs b/vendor/ttfx/src/engine/particles.rs new file mode 100644 index 0000000..1c300c3 --- /dev/null +++ b/vendor/ttfx/src/engine/particles.rs @@ -0,0 +1,205 @@ +//! ParticlePool / ParticleReset, ported from engine/effect_support/particles.py. +//! +//! The pool lives in effect state and operates through &mut EngineCtx. The +//! upstream `initializer` / `on_emit` closures become explicit closure +//! parameters receiving (ctx, particle_id) — for new-particle setup the effect +//! passes its initializer to the creating call. Reclaim-on-event is wired by +//! the effect as a Callback action dispatching back to `reclaim` (plan.md §4.2). + +use std::collections::VecDeque; + +use crate::engine::character::CharId; +use crate::engine::ctx::EngineCtx; +use crate::utils::geometry::Coord; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParticleReset { + pub clear_paths: bool, + pub clear_scenes: bool, + pub clear_events: bool, + pub deactivate_path: bool, + pub deactivate_scene: bool, + pub reset_appearance: bool, +} + +impl Default for ParticleReset { + fn default() -> Self { + ParticleReset { + clear_paths: true, + clear_scenes: false, + clear_events: false, + deactivate_path: true, + deactivate_scene: true, + reset_appearance: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct ParticlePool { + pub symbols: Vec, + pub max_size: Option, + pub coord: Coord, + /// Available queue: pop from the RIGHT (Python deque.pop), push right. + pub available: VecDeque, + /// All owned particles, active and available. + pub particles: Vec, +} + +impl ParticlePool { + /// ParticlePool.__init__ (without preallocation — call `preallocate` after + /// construction so the initializer closure can run against ctx). + pub fn new(symbols: Vec, max_size: Option, coord: Option) -> Result { + if symbols.is_empty() { + return Err("ParticlePool requires at least one symbol.".to_string()); + } + Ok(ParticlePool { + symbols, + max_size, + coord: coord.unwrap_or(Coord::new(0, 0)), + available: VecDeque::new(), + particles: Vec::new(), + }) + } + + /// The `initial_count` loop from __init__. + pub fn preallocate( + &mut self, + ctx: &mut EngineCtx, + initial_count: usize, + mut initializer: impl FnMut(&mut EngineCtx, CharId), + ) -> Result<(), String> { + if let Some(max) = self.max_size { + if max < initial_count { + return Err("max_size must be greater than or equal to initial_count.".to_string()); + } + } + for _ in 0..initial_count { + let particle = self.create_particle(ctx, None, &mut initializer); + self.available.push_back(particle); + } + Ok(()) + } + + pub fn len(&self) -> usize { + self.particles.len() + } + + pub fn is_empty(&self) -> bool { + self.particles.is_empty() + } + + /// ParticlePool._create_particle. + fn create_particle( + &mut self, + ctx: &mut EngineCtx, + symbol: Option<&str>, + initializer: &mut impl FnMut(&mut EngineCtx, CharId), + ) -> CharId { + let symbol = match symbol { + Some(s) => s.to_string(), + None => ctx.rng.choice(&self.symbols).clone(), + }; + let particle = ctx.terminal.add_character(&symbol, self.coord); + initializer(ctx, particle); + self.particles.push(particle); + particle + } + + /// ParticlePool._reset_particle. + fn reset_particle(ctx: &mut EngineCtx, id: CharId, reset: ParticleReset) { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + if reset.deactivate_path { + ch.motion.deactivate_path(None); + } + if reset.deactivate_scene { + ch.animation.active_scene = None; + } + if reset.clear_paths { + ch.motion.paths.clear(); + } + if reset.clear_scenes { + ch.animation.scenes.clear(); + } + if reset.clear_events { + ch.event_handler.clear(); + } + if reset.reset_appearance { + let input_symbol = ch.input_symbol.clone(); + let uses = ch.uses_input_preexisting_colors; + ch.animation.set_appearance(&input_symbol, uses, Some(&input_symbol.clone()), None); + } + } + + /// ParticlePool.acquire. + pub fn acquire( + &mut self, + ctx: &mut EngineCtx, + symbol: Option<&str>, + reset: ParticleReset, + mut initializer: impl FnMut(&mut EngineCtx, CharId), + ) -> Option { + if let Some(particle) = self.available.pop_back() { + Self::reset_particle(ctx, particle, reset); + if let Some(symbol) = symbol { + let ch = &mut ctx.terminal.arena[particle.0 as usize]; + ch.input_symbol = symbol.to_string(); + let uses = ch.uses_input_preexisting_colors; + ch.animation.set_appearance(symbol, uses, Some(symbol), None); + } + return Some(particle); + } + if let Some(max) = self.max_size { + if self.particles.len() >= max { + return None; + } + } + let particle = self.create_particle(ctx, symbol, &mut initializer); + Self::reset_particle(ctx, particle, reset); + Some(particle) + } + + /// ParticlePool.emit: acquire -> position -> on_emit -> visibility -> activate. + #[allow(clippy::too_many_arguments)] + pub fn emit( + &mut self, + ctx: &mut EngineCtx, + origin: Coord, + symbol: Option<&str>, + visible: bool, + reset: ParticleReset, + initializer: impl FnMut(&mut EngineCtx, CharId), + on_emit: impl FnOnce(&mut EngineCtx, CharId), + ) -> Option { + let particle = self.acquire(ctx, symbol, reset, initializer)?; + ctx.terminal.arena[particle.0 as usize].motion.set_coordinate(origin); + on_emit(ctx, particle); + ctx.terminal.set_character_visibility(particle, visible); + ctx.active_characters.insert(particle); + Some(particle) + } + + /// ParticlePool.reclaim (idempotent: no duplicate queue entries). + pub fn reclaim(&mut self, ctx: &mut EngineCtx, id: CharId, hide: bool, deactivate: bool) { + if hide { + ctx.terminal.set_character_visibility(id, false); + } + if deactivate { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.deactivate_path(None); + ch.animation.active_scene = None; + } + ctx.active_characters.remove(&id); + if !self.available.contains(&id) { + self.available.push_back(id); + } + } + + /// ParticlePool.extend: adopt externally created characters, no reset. + pub fn extend(&mut self, particles: impl IntoIterator) { + for particle in particles { + self.particles.push(particle); + self.available.push_back(particle); + } + } +} diff --git a/vendor/ttfx/src/engine/terminal.rs b/vendor/ttfx/src/engine/terminal.rs new file mode 100644 index 0000000..fc3b57e --- /dev/null +++ b/vendor/ttfx/src/engine/terminal.rs @@ -0,0 +1,1060 @@ +//! Terminal: config, canvas assembly, character queries, renderer, tty output. +//! Ported from engine/terminal.py. Unlike upstream (which builds two Terminals +//! per run), this single Terminal owns both the simulation and the tty side. + +use std::collections::HashMap; +use std::io::Write; +use std::time::Instant; + +use crate::engine::animation::ExistingColorHandling; +use crate::engine::canvas::{Anchor, Canvas}; +use crate::engine::character::{CharId, EffectCharacter}; +use crate::engine::error::EngineError; +use crate::engine::input::{ColorFrequency, Preprocessor}; +use crate::utils::ansi; +use crate::utils::geometry::Coord; +use crate::utils::graphics::Color; +use crate::utils::rng::Rng; + +const EMPTY_RENDER_CELL: u32 = u32::MAX; +const NOT_VISIBLE: usize = usize::MAX; + +pub const CELL_HAS_BACKGROUND: u8 = 1; +pub const CELL_HAS_GLYPH: u8 = 2; + +/// Packed ABI cell. Layout matches OmacyCell (16 bytes, align 4). +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +pub struct PackedCell { + pub glyph: u32, + pub fg_r: u8, + pub fg_g: u8, + pub fg_b: u8, + pub fg_a: u8, + pub bg_r: u8, + pub bg_g: u8, + pub bg_b: u8, + pub bg_a: u8, + pub flags: u8, + pub occupancy: u8, + pub _pad: [u8; 2], +} + +#[derive(Debug, Clone)] +pub struct TerminalConfig { + pub tab_width: i64, + pub xterm_colors: bool, + pub no_color: bool, + pub terminal_background_color: Color, + pub existing_color_handling: ExistingColorHandling, + pub wrap_text: bool, + pub frame_rate: i64, + pub canvas_width: i64, + pub canvas_height: i64, + pub anchor_canvas: Anchor, + pub anchor_text: Anchor, + pub ignore_terminal_dimensions: bool, + pub reuse_canvas: bool, + pub no_eol: bool, + pub no_restore_cursor: bool, +} + +impl Default for TerminalConfig { + fn default() -> Self { + TerminalConfig { + tab_width: 4, + xterm_colors: false, + no_color: false, + terminal_background_color: Color::from_hex("000000").unwrap(), + existing_color_handling: ExistingColorHandling::Ignore, + wrap_text: false, + frame_rate: 60, + canvas_width: -1, + canvas_height: -1, + anchor_canvas: Anchor::Sw, + anchor_text: Anchor::Sw, + ignore_terminal_dimensions: false, + reuse_canvas: false, + no_eol: false, + no_restore_cursor: false, + } + } +} + +impl TerminalConfig { + /// GUI construction: explicit canvas, centered text, no tty pacing. + pub fn gui(width: i64, height: i64, background: Color) -> Self { + TerminalConfig { + canvas_width: width, + canvas_height: height, + ignore_terminal_dimensions: true, + anchor_canvas: Anchor::C, + anchor_text: Anchor::C, + frame_rate: 0, + terminal_background_color: background, + ..Self::default() + } + } +} + +/// CharacterSort (argutils.CharacterSort). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CharacterSort { + Random, + TopToBottomLeftToRight, + BottomToTopRightToLeft, + BottomToTopLeftToRight, + TopToBottomRightToLeft, + OutsideRowToMiddle, + MiddleRowToOutside, +} + +/// CharacterGroup (argutils.CharacterGroup). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CharacterGroup { + ColumnLeftToRight, + ColumnRightToLeft, + RowTopToBottom, + RowBottomToTop, + DiagonalBottomLeftToTopRight, + DiagonalTopRightToBottomLeft, + DiagonalTopLeftToBottomRight, + DiagonalBottomRightToTopLeft, + CenterToOutside, + OutsideToCenter, +} + +/// ColorSort (argutils.ColorSort). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorSort { + LeastToMost, + MostToLeast, + Random, +} + +/// Which character populations to include in a query (the four bool kwargs). +#[derive(Debug, Clone, Copy)] +pub struct CharacterFilter { + pub input_chars: bool, + pub inner_fill_chars: bool, + pub outer_fill_chars: bool, + pub added_chars: bool, +} + +impl Default for CharacterFilter { + fn default() -> Self { + CharacterFilter { input_chars: true, inner_fill_chars: false, outer_fill_chars: false, added_chars: false } + } +} + +pub struct Terminal { + pub config: TerminalConfig, + pub canvas: Canvas, + pub arena: Vec, + next_character_id: u32, + pub input_colors_frequency: ColorFrequency, + terminal_dimensions: (i64, i64), + resize_seen_at: Option, + layout: Layout, + /// Pre-wrap input line lengths — all `compute_layout` needs from the input, + /// so a resize can re-derive the geometry without re-preprocessing. + input_line_lengths: Vec, + pub canvas_column_offset: i64, + pub canvas_row_offset: i64, + pub visible_top: i64, + pub visible_bottom: i64, + pub visible_right: i64, + pub visible_left: i64, + pub input_characters: Vec, + pub added_characters: Vec, + pub character_by_input_coord: HashMap, + pub inner_fill_characters: Vec, + pub outer_fill_characters: Vec, + visible_characters: Vec, + visible_positions: Vec, + render_cells: Vec, + pub terminal_state: Vec, + output_buffer: String, + move_cursor_to_top: String, + frame_rate: i64, + last_time_printed: Instant, + /// Packed cells captured at the last `frame()` / ANSI emit. + /// Effects such as `unstable` restore coordinates after `ctx.frame()`, so + /// a later live pack would miss the displayed rumble offset. + grid_snapshot: Vec, + snapshot_width: usize, + snapshot_height: usize, + has_snapshot: bool, +} + +fn ordered_buckets( + characters: Vec, + first_key: i64, + last_key: i64, + mut key: impl FnMut(CharId) -> i64, +) -> Vec> { + if first_key > last_key { + return Vec::new(); + } + let bucket_count = last_key + .checked_sub(first_key) + .and_then(|span| span.checked_add(1)) + .and_then(|span| usize::try_from(span).ok()) + .expect("terminal canvas is too large"); + let expected_bucket_len = characters.len() / bucket_count; + let mut buckets: Vec> = (0..bucket_count) + .map(|_| Vec::with_capacity(expected_bucket_len)) + .collect(); + for id in characters { + let character_key = key(id); + if first_key <= character_key && character_key <= last_key { + buckets[(character_key - first_key) as usize].push(id); + } + } + buckets.into_iter().filter(|bucket| !bucket.is_empty()).collect() +} + +fn pack_render_cells_into( + arena: &[EffectCharacter], + render_cells: &[u32], + out: &mut [PackedCell], + term_bg: [u8; 3], + width: usize, + height: usize, +) { + const DEFAULT_INK: [u8; 3] = [255, 255, 255]; + for internal_row in 0..height { + let abi_row = height - 1 - internal_row; + for col in 0..width { + let dst = &mut out[abi_row * width + col]; + *dst = PackedCell::default(); + let cell = render_cells[internal_row * width + col]; + if cell == EMPTY_RENDER_CELL { + continue; + } + let vis = &arena[cell as usize].animation.current_character_visual; + let ink = vis + .fg_color_code + .as_ref() + .map(crate::utils::ansi::ColorCode::rgb) + .map(|(r, g, b)| [r, g, b]) + .unwrap_or(DEFAULT_INK); + let orig_bg = vis + .bg_color_code + .as_ref() + .map(crate::utils::ansi::ColorCode::rgb) + .map(|(r, g, b)| [r, g, b]); + + let (fg, bg, has_background) = if vis.reverse { + (orig_bg.unwrap_or(term_bg), Some(ink), true) + } else { + (ink, orig_bg, orig_bg.is_some()) + }; + + if has_background { + let [r, g, b] = bg.expect("reverse always supplies a background"); + dst.occupancy |= CELL_HAS_BACKGROUND; + dst.bg_r = r; + dst.bg_g = g; + dst.bg_b = b; + dst.bg_a = 255; + } + + let glyph = vis.symbol.chars().next().filter(|&c| c != ' ' && c != '\0'); + if let Some(ch) = glyph { + dst.occupancy |= CELL_HAS_GLYPH; + dst.glyph = ch as u32; + dst.fg_r = fg[0]; + dst.fg_g = fg[1]; + dst.fg_b = fg[2]; + dst.fg_a = 255; + } + } + } +} + +impl Terminal { + pub fn new(input_data: &str, config: TerminalConfig) -> Result { + let input_data = if input_data.is_empty() { "No Input." } else { input_data }; + let mut arena: Vec = Vec::new(); + let mut next_character_id: u32 = 0; + let mut input_colors_frequency = ColorFrequency::default(); + + let preprocessed_lines = Preprocessor { + arena: &mut arena, + next_character_id: &mut next_character_id, + input_colors_frequency: &mut input_colors_frequency, + config: &config, + } + .preprocess(input_data)?; + + let input_line_lengths: Vec = preprocessed_lines.iter().map(|l| l.len() as i64).collect(); + let terminal_dimensions = get_terminal_dimensions(); + let layout = compute_layout(&config, &input_line_lengths, terminal_dimensions.0, terminal_dimensions.1); + let mut canvas = Canvas::new(layout.canvas_height, layout.canvas_width); + let Layout { + column_offset: canvas_column_offset, + row_offset: canvas_row_offset, + visible_top, + visible_bottom, + visible_right, + visible_left, + .. + } = layout; + + let input_characters = setup_input_characters(&config, &mut canvas, &mut arena, preprocessed_lines)? + .into_iter() + .filter(|&id| { + let coord = arena[id.0 as usize].input_coord; + coord.row <= canvas.top && coord.column <= canvas.right + }) + .collect::>(); + + let mut character_by_input_coord: HashMap = HashMap::new(); + for &id in &input_characters { + character_by_input_coord.insert(arena[id.0 as usize].input_coord, id); + } + + let frame_rate = config.frame_rate; + let arena_len = arena.len(); + let move_cursor_to_top = format!( + "{}{}{}", + ansi::DEC_RESTORE_CURSOR, + ansi::DEC_SAVE_CURSOR, + ansi::move_cursor_up(visible_top.max(0) as usize) + ); + let mut terminal = Terminal { + config, + canvas, + arena, + next_character_id, + input_colors_frequency, + terminal_dimensions, + resize_seen_at: None, + layout, + input_line_lengths, + canvas_column_offset, + canvas_row_offset, + visible_top, + visible_bottom, + visible_right, + visible_left, + input_characters, + added_characters: Vec::new(), + character_by_input_coord, + inner_fill_characters: Vec::new(), + outer_fill_characters: Vec::new(), + visible_characters: Vec::new(), + visible_positions: vec![NOT_VISIBLE; arena_len], + render_cells: Vec::new(), + terminal_state: Vec::new(), + output_buffer: String::new(), + move_cursor_to_top, + frame_rate, + last_time_printed: Instant::now(), + grid_snapshot: Vec::new(), + snapshot_width: 0, + snapshot_height: 0, + has_snapshot: false, + }; + terminal.make_fill_characters(); + terminal.setup_character_neighbors(); + terminal.update_terminal_state(); + Ok(terminal) + } + + /// Terminal._make_fill_characters: row-major from (1,1), fresh space chars + /// for unoccupied canvas coords, split inner/outer by the text bounds. + fn make_fill_characters(&mut self) { + for row in 1..=self.canvas.top { + for column in 1..=self.canvas.right { + let coord = Coord::new(column, row); + if !self.character_by_input_coord.contains_key(&coord) { + let mut fill = EffectCharacter::new(self.next_character_id, " ", column, row); + fill.is_fill_character = true; + fill.animation.no_color = self.config.no_color; + fill.animation.use_xterm_colors = self.config.xterm_colors; + fill.animation.existing_color_handling = self.config.existing_color_handling; + fill.uses_input_preexisting_colors = false; + self.next_character_id += 1; + let id = CharId(self.arena.len() as u32); + self.arena.push(fill); + self.character_by_input_coord.insert(coord, id); + if self.canvas.text_left <= column + && column <= self.canvas.text_right + && self.canvas.text_bottom <= row + && row <= self.canvas.text_top + { + self.inner_fill_characters.push(id); + } else { + self.outer_fill_characters.push(id); + } + } + } + } + } + + fn setup_character_neighbors(&mut self) { + let coords: Vec<(Coord, CharId)> = self.character_by_input_coord.iter().map(|(&c, &id)| (c, id)).collect(); + for (coord, id) in coords { + let n = self.character_by_input_coord.get(&Coord::new(coord.column, coord.row + 1)).copied(); + let e = self.character_by_input_coord.get(&Coord::new(coord.column + 1, coord.row)).copied(); + let s = self.character_by_input_coord.get(&Coord::new(coord.column, coord.row - 1)).copied(); + let w = self.character_by_input_coord.get(&Coord::new(coord.column - 1, coord.row)).copied(); + let ch = &mut self.arena[id.0 as usize]; + ch.neighbors.north = n; + ch.neighbors.east = e; + ch.neighbors.south = s; + ch.neighbors.west = w; + } + } + + /// Terminal.add_character: registered only in added_characters, not in + /// character_by_input_coord or the neighbor map. + pub fn add_character(&mut self, symbol: &str, coord: Coord) -> CharId { + let mut ch = EffectCharacter::new(self.next_character_id, symbol, coord.column, coord.row); + ch.animation.no_color = self.config.no_color; + ch.animation.use_xterm_colors = self.config.xterm_colors; + ch.animation.existing_color_handling = self.config.existing_color_handling; + ch.uses_input_preexisting_colors = false; + self.next_character_id += 1; + let id = CharId(self.arena.len() as u32); + self.arena.push(ch); + self.added_characters.push(id); + id + } + + pub fn get_character_by_input_coord(&self, coord: Coord) -> Option { + self.character_by_input_coord.get(&coord).copied() + } + + pub fn set_character_visibility(&mut self, id: CharId, is_visible: bool) { + let arena_index = id.0 as usize; + if self.arena[arena_index].is_visible == is_visible { + return; + } + self.arena[arena_index].is_visible = is_visible; + self.visible_positions.resize(self.arena.len(), NOT_VISIBLE); + if is_visible { + self.visible_positions[arena_index] = self.visible_characters.len(); + self.visible_characters.push(id); + } else { + let position = std::mem::replace(&mut self.visible_positions[arena_index], NOT_VISIBLE); + self.visible_characters.swap_remove(position); + if position < self.visible_characters.len() { + let moved = self.visible_characters[position]; + self.visible_positions[moved.0 as usize] = position; + } + } + } + + /// Terminal.get_input_colors. Equal-count ties keep insertion order + /// (Python's stable sort over dict keys). + pub fn get_input_colors(&self, rng: &mut Rng, sort: ColorSort) -> Vec { + let mut colors: Vec<(Color, i64)> = self.input_colors_frequency.0.clone(); + match sort { + ColorSort::MostToLeast => { + // Python: sorted(keys, key=count, reverse=True) — reverse of a + // stable ascending sort reverses tie order too; replicate by + // sorting descending with stable tie order = insertion order. + colors.sort_by(|a, b| b.1.cmp(&a.1)); + } + ColorSort::LeastToMost => { + colors.sort_by(|a, b| a.1.cmp(&b.1)); + } + ColorSort::Random => { + rng.shuffle(&mut colors); + } + } + colors.into_iter().map(|(c, _)| c).collect() + } + + pub fn collect_characters(&self, filter: CharacterFilter) -> Vec { + let capacity = if filter.input_chars { self.input_characters.len() } else { 0 } + + if filter.inner_fill_chars { self.inner_fill_characters.len() } else { 0 } + + if filter.outer_fill_chars { self.outer_fill_characters.len() } else { 0 } + + if filter.added_chars { self.added_characters.len() } else { 0 }; + let mut all: Vec = Vec::with_capacity(capacity); + if filter.input_chars { + all.extend(&self.input_characters); + } + if filter.inner_fill_chars { + all.extend(&self.inner_fill_characters); + } + if filter.outer_fill_chars { + all.extend(&self.outer_fill_characters); + } + if filter.added_chars { + all.extend(&self.added_characters); + } + all + } + + /// Terminal.get_characters with all sort variants. + pub fn get_characters(&self, rng: &mut Rng, filter: CharacterFilter, sort: CharacterSort) -> Vec { + let mut all = self.collect_characters(filter); + // default sort: (-row, column), stable + all.sort_by_key(|&id| { + let c = self.arena[id.0 as usize].input_coord; + (-c.row, c.column) + }); + match sort { + CharacterSort::Random => rng.shuffle(&mut all), + CharacterSort::TopToBottomLeftToRight => {} + CharacterSort::BottomToTopRightToLeft => all.reverse(), + CharacterSort::BottomToTopLeftToRight | CharacterSort::TopToBottomRightToLeft => { + all.sort_by_key(|&id| { + let c = self.arena[id.0 as usize].input_coord; + (c.row, c.column) + }); + if sort == CharacterSort::TopToBottomRightToLeft { + all.reverse(); + } + } + CharacterSort::OutsideRowToMiddle | CharacterSort::MiddleRowToOutside => { + // upstream: alternate pop(0)/pop(-1) + let mut deque: std::collections::VecDeque = all.into(); + let mut interleaved = Vec::with_capacity(deque.len()); + let mut from_front = true; + while let Some(id) = if from_front { deque.pop_front() } else { deque.pop_back() } { + interleaved.push(id); + from_front = !from_front; + } + all = interleaved; + if sort == CharacterSort::MiddleRowToOutside { + all.reverse(); + } + } + } + all + } + + /// Terminal.get_characters_grouped with all grouping variants. + pub fn get_characters_grouped(&self, filter: CharacterFilter, grouping: CharacterGroup) -> Vec> { + let mut all = self.collect_characters(filter); + all.sort_by_key(|&id| { + let c = self.arena[id.0 as usize].input_coord; + (c.row, c.column) + }); + let coord = |id: &CharId| self.arena[id.0 as usize].input_coord; + match grouping { + CharacterGroup::ColumnLeftToRight | CharacterGroup::ColumnRightToLeft => { + let mut columns = ordered_buckets(all, 0, self.canvas.right, |id| coord(&id).column); + if grouping == CharacterGroup::ColumnRightToLeft { + columns.reverse(); + } + columns + } + CharacterGroup::RowBottomToTop | CharacterGroup::RowTopToBottom => { + let mut rows = ordered_buckets(all, 0, self.canvas.top, |id| coord(&id).row); + if grouping == CharacterGroup::RowTopToBottom { + rows.reverse(); + } + rows + } + CharacterGroup::DiagonalBottomLeftToTopRight | CharacterGroup::DiagonalTopRightToBottomLeft => { + let mut diagonals = ordered_buckets(all, 0, self.canvas.top + self.canvas.right, |id| { + let c = coord(&id); + c.row + c.column + }); + if grouping == CharacterGroup::DiagonalTopRightToBottomLeft { + diagonals.reverse(); + } + diagonals + } + CharacterGroup::DiagonalTopLeftToBottomRight | CharacterGroup::DiagonalBottomRightToTopLeft => { + let mut diagonals = ordered_buckets( + all, + self.canvas.left - self.canvas.top, + self.canvas.right - self.canvas.bottom, + |id| { + let c = coord(&id); + c.column - c.row + }, + ); + if grouping == CharacterGroup::DiagonalBottomRightToTopLeft { + diagonals.reverse(); + } + diagonals + } + CharacterGroup::CenterToOutside | CharacterGroup::OutsideToCenter => { + let max_distance = all + .iter() + .map(|&id| { + let c = coord(&id); + (c.column - self.canvas.text_center.column).abs() + + (c.row - self.canvas.text_center.row).abs() + }) + .max(); + let dense_limit = all.len().saturating_mul(4).max(256); + let mut groups = if max_distance + .and_then(|distance| usize::try_from(distance).ok()) + .is_some_and(|distance| distance <= dense_limit) + { + ordered_buckets(all, 0, max_distance.unwrap(), |id| { + let c = coord(&id); + (c.column - self.canvas.text_center.column).abs() + + (c.row - self.canvas.text_center.row).abs() + }) + } else { + // Out-of-canvas added characters can have sparse, arbitrarily + // large distances; avoid allocating through the largest key. + let mut distances: HashMap> = HashMap::new(); + for id in all { + let c = coord(&id); + let distance = (c.column - self.canvas.text_center.column).abs() + + (c.row - self.canvas.text_center.row).abs(); + distances.entry(distance).or_default().push(id); + } + let mut distances: Vec<(i64, Vec)> = distances.into_iter().collect(); + distances.sort_by_key(|&(distance, _)| distance); + distances.into_iter().map(|(_, group)| group).collect() + }; + if grouping == CharacterGroup::OutsideToCenter { + groups.reverse(); + } + groups + } + } + } + + /// Paint the visible characters into the reusable cell buffer using the + /// canonical (layer, character_id) painter order (plan.md §4.3). + fn update_render_cells(&mut self) -> (usize, usize) { + let width = self.visible_right.max(0) as usize; + let height = self.visible_top.max(0) as usize; + let cell_count = width.checked_mul(height).expect("terminal canvas is too large"); + self.render_cells.resize(cell_count, EMPTY_RENDER_CELL); + self.render_cells.fill(EMPTY_RENDER_CELL); + + // The old implementation sorted every visible character by painter + // order and overwrote cells in that order. A cell only needs the + // maximum key, so select that winner directly and avoid the per-frame + // allocation and O(n log n) sort. + for &id in &self.visible_characters { + let ch = &self.arena[id.0 as usize]; + let row = ch.motion.current_coord.row + self.canvas_row_offset; + let column = ch.motion.current_coord.column + self.canvas_column_offset; + if self.visible_bottom <= row + && row <= self.visible_top + && self.visible_left <= column + && column <= self.visible_right + { + let cell = &mut self.render_cells[(row - 1) as usize * width + (column - 1) as usize]; + if *cell == EMPTY_RENDER_CELL { + *cell = id.0; + } else { + let painted = &self.arena[*cell as usize]; + if (ch.layer, ch.character_id) > (painted.layer, painted.character_id) { + *cell = id.0; + } + } + } + } + + (width, height) + } + + fn term_bg_rgb(&self) -> [u8; 3] { + let (r, g, b) = self.config.terminal_background_color.rgb_ints(); + [r, g, b] + } + + /// Pack the current `render_cells` (must already be up to date). + fn pack_render_cells(&self, out: &mut [PackedCell], term_bg: [u8; 3], width: usize, height: usize) { + pack_render_cells_into(&self.arena, &self.render_cells, out, term_bg, width, height); + } + + fn store_snapshot(&mut self, width: usize, height: usize) { + let needed = width + .checked_mul(height) + .expect("terminal canvas is too large"); + let term_bg = self.term_bg_rgb(); + self.grid_snapshot.resize(needed, PackedCell::default()); + pack_render_cells_into( + &self.arena, + &self.render_cells, + &mut self.grid_snapshot, + term_bg, + width, + height, + ); + self.snapshot_width = width; + self.snapshot_height = height; + self.has_snapshot = true; + } + + /// Capture occupancy at the same instant ANSI would emit. + /// GUI `advance` sets `suppress_ansi` but still needs this snapshot. + pub fn snapshot_present_grid(&mut self) { + let (width, height) = self.update_render_cells(); + self.store_snapshot(width, height); + } + + /// Paint occupancy + glyph + RGBA with reverse already resolved. + /// Origin is top-left; internal south-west rows are remapped. + /// + /// After `frame()` / `get_formatted_output_string`, copies the snapshot + /// taken at emit time so post-frame coordinate restores (e.g. unstable + /// rumble) cannot desync Metal from the ANSI oracle. + pub fn fill_grid(&mut self, out: &mut [PackedCell], term_bg: [u8; 3]) -> (usize, usize) { + if self.has_snapshot { + let needed = self + .snapshot_width + .checked_mul(self.snapshot_height) + .expect("terminal canvas is too large"); + assert!( + out.len() >= needed, + "fill_grid buffer is smaller than the canvas" + ); + out[..needed].copy_from_slice(&self.grid_snapshot); + return (self.snapshot_width, self.snapshot_height); + } + let (width, height) = self.update_render_cells(); + let needed = width + .checked_mul(height) + .expect("terminal canvas is too large"); + assert!( + out.len() >= needed, + "fill_grid buffer is smaller than the canvas" + ); + self.pack_render_cells(out, term_bg, width, height); + (width, height) + } + + /// Terminal._update_terminal_state: materialize the row-oriented state + /// exposed by the upstream API. Frame output uses the cell buffer directly + /// so the hot path does not copy every rendered byte through these rows. + pub fn update_terminal_state(&mut self) { + let (width, height) = self.update_render_cells(); + + self.terminal_state.resize_with(height, String::new); + self.terminal_state.truncate(height); + let arena = &self.arena; + for (row_index, row) in self.terminal_state.iter_mut().enumerate() { + row.clear(); + if row.capacity() < width { + row.reserve(width); + } + for &cell in &self.render_cells[row_index * width..(row_index + 1) * width] { + if cell == EMPTY_RENDER_CELL { + row.push(' '); + } else { + row.push_str(arena[cell as usize].animation.current_character_visual.formatted_symbol.as_str()); + } + } + } + } + + /// get_formatted_output_string: refresh + emit top row first. + pub fn get_formatted_output_string(&mut self) -> String { + let (width, height) = self.update_render_cells(); + self.store_snapshot(width, height); + let minimum_capacity = width + .checked_mul(height) + .and_then(|cells| cells.checked_add(height.saturating_sub(1))) + .expect("terminal canvas is too large"); + let mut out = std::mem::take(&mut self.output_buffer).into_bytes(); + out.clear(); + if out.capacity() < minimum_capacity { + out.reserve(minimum_capacity); + } + let arena = &self.arena; + for row_index in (0..height).rev() { + if row_index + 1 < height { + out.push(b'\n'); + } + for &cell in &self.render_cells[row_index * width..(row_index + 1) * width] { + if cell == EMPTY_RENDER_CELL { + out.push(b' '); + } else { + arena[cell as usize].animation.current_character_visual.formatted_symbol.append_to(&mut out); + } + } + } + // SAFETY: every appended run is a whole formatted symbol, which is UTF-8. + unsafe { String::from_utf8_unchecked(out) } + } + + pub(crate) fn recycle_output_string(&mut self, mut output: String) { + output.clear(); + if output.capacity() > self.output_buffer.capacity() { + self.output_buffer = output; + } + } + + /// Whether a resize has landed, settled, and actually moved something. + /// + /// Settled: dragging a window edge emits a SIGWINCH per step, and rebuilding + /// for each one pins the animation at its opening frames for the whole drag. + /// Each signal restarts a quiet window; the old canvas keeps animating until + /// it expires, so the wait costs nothing on screen. + /// + /// Moved something: a new terminal size is not enough. With an input-sized + /// canvas and no anchor offsets most resizes leave every rendered cell + /// exactly where it was, and restarting for those is pure loss. Explicitly + /// ignored dimensions are fixed by definition. + pub fn resize_settled(&mut self) -> bool { + const QUIET: std::time::Duration = std::time::Duration::from_millis(50); + + if crate::take_terminal_resize() { + self.resize_seen_at = Some(Instant::now()); + } + match self.resize_seen_at { + Some(seen) if seen.elapsed() >= QUIET => self.resize_seen_at = None, + _ => return false, + } + if self.config.ignore_terminal_dimensions { + return false; + } + let (width, height) = get_terminal_dimensions(); + if (width, height) == self.terminal_dimensions { + return false; + } + compute_layout(&self.config, &self.input_line_lengths, width, height) != self.layout + } + + /// After a resize: go back to the top of the area this run allocated, wipe + /// it, and leave the cursor there so the rebuilt canvas takes the same rows + /// instead of scrolling a second one into the terminal. + pub fn reset_canvas_area(&self, out: &mut impl Write) -> std::io::Result<()> { + out.write_all(ansi::DEC_RESTORE_CURSOR.as_bytes())?; + if self.visible_top > 0 { + out.write_all(ansi::move_cursor_up(self.visible_top as usize).as_bytes())?; + } + out.write_all(ansi::CLEAR_TO_END_OF_SCREEN.as_bytes())?; + Ok(()) + } + + // --- tty side (upstream's second Terminal instance) --- + + pub fn prep_canvas(&mut self, out: &mut impl Write) -> std::io::Result<()> { + out.write_all(ansi::HIDE_CURSOR.as_bytes())?; + if self.config.reuse_canvas { + self.write_move_cursor_to_top(out)?; + } + for _ in 0..self.visible_top { + let blank = " ".repeat(self.visible_right.max(0) as usize); + out.write_all(blank.as_bytes())?; + out.write_all(b"\n")?; + } + out.write_all(ansi::DEC_SAVE_CURSOR.as_bytes())?; + Ok(()) + } + + pub fn restore_cursor(&self, out: &mut impl Write, end_symbol: &str) -> std::io::Result<()> { + let end_symbol = if self.config.no_eol { "" } else { end_symbol }; + if !self.config.no_restore_cursor { + out.write_all(ansi::SHOW_CURSOR.as_bytes())?; + } + out.write_all(end_symbol.as_bytes())?; + Ok(()) + } + + pub fn print_frame(&mut self, out: &mut impl Write, output_string: &str) -> std::io::Result<()> { + self.write_move_cursor_to_top(out)?; + out.write_all(output_string.as_bytes())?; + out.flush() + } + + fn write_move_cursor_to_top(&self, out: &mut impl Write) -> std::io::Result<()> { + out.write_all(self.move_cursor_to_top.as_bytes()) + } + + /// Terminal.enforce_framerate: sleep off the remainder; timestamp taken + /// AFTER the sleep (drift accumulates, faithfully). + pub fn enforce_framerate(&mut self) { + if self.frame_rate == 0 { + return; + } + let frame_delay = 1.0 / self.frame_rate as f64; + let elapsed = self.last_time_printed.elapsed().as_secs_f64(); + if elapsed < frame_delay { + std::thread::sleep(std::time::Duration::from_secs_f64(frame_delay - elapsed)); + } + self.last_time_printed = Instant::now(); + } +} + +/// shutil.get_terminal_size semantics: COLUMNS/LINES env vars win; else query +/// the tty; on failure (80, 24). +fn get_terminal_dimensions() -> (i64, i64) { + let env_dim = |name: &str| -> Option { + std::env::var(name).ok()?.parse::().ok() + }; + let columns = env_dim("COLUMNS"); + let lines = env_dim("LINES"); + if let (Some(c), Some(l)) = (columns, lines) { + return (c, l); + } + match terminal_size::terminal_size() { + Some((terminal_size::Width(w), terminal_size::Height(h))) => { + (columns.unwrap_or(w as i64), lines.unwrap_or(h as i64)) + } + None => (columns.unwrap_or(80), lines.unwrap_or(24)), + } +} + +/// Everything about the drawing area that is derived from the terminal size. +/// A resize only matters if recomputing this yields something different, so it +/// is factored out of Terminal::new rather than inlined there. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Layout { + canvas_height: i64, + canvas_width: i64, + column_offset: i64, + row_offset: i64, + visible_top: i64, + visible_bottom: i64, + visible_right: i64, + visible_left: i64, +} + +fn compute_layout( + config: &TerminalConfig, + line_lengths: &[i64], + terminal_width: i64, + terminal_height: i64, +) -> Layout { + let (canvas_height, canvas_width) = + get_canvas_dimensions(config, line_lengths, terminal_width, terminal_height); + let canvas = Canvas::new(canvas_height, canvas_width); + let (mut width, mut height) = (terminal_width, terminal_height); + let (column_offset, row_offset) = if !config.ignore_terminal_dimensions { + calc_canvas_offsets(config, &canvas, width, height) + } else { + width = canvas.right; + height = canvas.top; + (0, 0) + }; + Layout { + canvas_height, + canvas_width, + column_offset, + row_offset, + visible_top: std::cmp::min(canvas.top + row_offset, height), + visible_bottom: std::cmp::max(canvas.bottom + row_offset, 1), + visible_right: std::cmp::min(canvas.right + column_offset, width), + visible_left: std::cmp::max(canvas.left + column_offset, 1), + } +} + +/// Terminal._get_canvas_dimensions -> (height, width). +fn get_canvas_dimensions( + config: &TerminalConfig, + line_lengths: &[i64], + terminal_width: i64, + terminal_height: i64, +) -> (i64, i64) { + let canvas_width = if config.canvas_width > 0 { + config.canvas_width + } else if config.canvas_width == 0 { + terminal_width + } else { + let input_width = line_lengths.iter().copied().max().unwrap_or(0); + if config.ignore_terminal_dimensions { + input_width + } else { + std::cmp::min(terminal_width, input_width) + } + }; + let canvas_height = if config.canvas_height > 0 { + config.canvas_height + } else if config.canvas_height == 0 { + terminal_height + } else { + let input_height = line_lengths.len() as i64; + if config.ignore_terminal_dimensions { + input_height + } else if config.wrap_text { + std::cmp::min(wrapped_line_count(line_lengths, canvas_width), terminal_height) + } else { + std::cmp::min(terminal_height, input_height) + } + }; + (canvas_height, canvas_width) +} + +fn wrapped_line_count(line_lengths: &[i64], width: i64) -> i64 { + let mut count: i64 = 0; + for &length in line_lengths { + let mut remaining = length; + while remaining > width { + count += 1; + remaining -= width; + } + count += 1; + } + count +} + +/// Terminal._wrap_lines. +fn wrap_lines(lines: Vec>, width: i64) -> Vec> { + let mut wrapped: Vec> = Vec::new(); + for line in lines { + let mut current = line; + while current.len() as i64 > width { + let rest = current.split_off(width as usize); + wrapped.push(current); + current = rest; + } + wrapped.push(current); + } + wrapped +} + +fn calc_canvas_offsets( + config: &TerminalConfig, + canvas: &Canvas, + terminal_width: i64, + terminal_height: i64, +) -> (i64, i64) { + use crate::engine::canvas::Anchor::*; + use crate::utils::pycompat::floor_div; + let mut column_offset = 0; + let mut row_offset = 0; + match config.anchor_canvas { + S | N | C => column_offset = floor_div(terminal_width, 2) - floor_div(canvas.width, 2), + Se | E | Ne => column_offset = terminal_width - canvas.width, + _ => {} + } + match config.anchor_canvas { + W | E | C => row_offset = floor_div(terminal_height, 2) - floor_div(canvas.height, 2), + Nw | N | Ne => row_offset = terminal_height - canvas.height, + _ => {} + } + (column_offset, row_offset) +} + +/// Terminal._setup_input_characters: wrap, assign 1-based bottom-up coords, +/// drop plain spaces (they become fill), anchor, and keep in-canvas chars. +fn setup_input_characters( + config: &TerminalConfig, + canvas: &mut Canvas, + arena: &mut Vec, + preprocessed_lines: Vec>, +) -> Result, EngineError> { + let formatted_lines = if config.wrap_text { + wrap_lines(preprocessed_lines, canvas.right) + } else { + preprocessed_lines + }; + let input_height = formatted_lines.len() as i64; + let mut input_characters: Vec = Vec::new(); + for (row, line) in formatted_lines.iter().enumerate() { + for (column0, &id) in line.iter().enumerate() { + let column = column0 as i64 + 1; + let ch = &mut arena[id.0 as usize]; + ch.input_coord = Coord::new(column, input_height - row as i64); + if ch.input_symbol != " " + || ch.animation.input_fg_color.is_some() + || ch.animation.input_bg_color.is_some() + { + input_characters.push(id); + } + } + } + canvas + .anchor_text(arena, input_characters, config.anchor_text) + .map_err(EngineError::Other) +} diff --git a/vendor/ttfx/src/lib.rs b/vendor/ttfx/src/lib.rs new file mode 100644 index 0000000..dd34693 --- /dev/null +++ b/vendor/ttfx/src/lib.rs @@ -0,0 +1,157 @@ +pub mod cli; +pub mod effects; +pub mod engine; +pub mod utils; + +use std::sync::atomic::{AtomicBool, Ordering}; + +/// `println!` and `eprintln!` panic when their write fails, and a release +/// build aborts on panic — so on a terminal that has just gone away, reporting +/// the loss is what dumps the core, not the loss itself: +/// +/// ```text +/// thread 'main' panicked at library/std/src/io/stdio.rs:1166:9: +/// failed printing to stderr: Input/output error (os error 5) +/// ``` +/// +/// Nothing ttfx says is worth dying over, so messages go out through these two +/// and a failed write is dropped (basecamp/omarchy#6762). +#[macro_export] +macro_rules! outln { + ($($arg:tt)*) => {{ + let _ = ::std::io::Write::write_fmt( + &mut ::std::io::stdout(), + format_args!("{}\n", format_args!($($arg)*)), + ); + }}; +} + +/// [`outln!`] for stderr. +#[macro_export] +macro_rules! errln { + ($($arg:tt)*) => {{ + let _ = ::std::io::Write::write_fmt( + &mut ::std::io::stderr(), + format_args!("{}\n", format_args!($($arg)*)), + ); + }}; +} + +static INTERRUPTED: AtomicBool = AtomicBool::new(false); +static TERMINATED: AtomicBool = AtomicBool::new(false); +static TERMINAL_RESIZED: AtomicBool = AtomicBool::new(false); + +/// SIGINT is recorded and checked from the run loop so teardown (cursor +/// restore) happens through normal control flow — Drop alone would not run on +/// a raw signal exit (plan.md §8). +pub fn install_sigint_handler() { + // SAFETY: signal(2) with a signal-safe handler that only stores a flag. + unsafe { + libc_signal(SIGINT, handle_sigint as *const () as usize); + } +} + +extern "C" fn handle_sigint(_: i32) { + INTERRUPTED.store(true, Ordering::SeqCst); +} + +pub fn interrupted() -> bool { + INTERRUPTED.load(Ordering::SeqCst) +} + +/// SIGTERM is recorded like SIGINT so a supervisor killing an animation gets +/// the normal teardown instead of a hidden cursor. `die_from_sigterm` then +/// finishes the job the handler deferred. +pub fn install_sigterm_handler() { + // SAFETY: signal(2) with a signal-safe handler that only stores a flag. + unsafe { + libc_signal(SIGTERM, handle_sigterm as *const () as usize); + } +} + +extern "C" fn handle_sigterm(_: i32) { + TERMINATED.store(true, Ordering::SeqCst); +} + +pub fn terminated() -> bool { + TERMINATED.load(Ordering::SeqCst) +} + +/// Finish the SIGTERM we deferred: the cursor is back, so hand the signal to +/// the default action and die from it. A supervisor then sees a terminated +/// child, exactly as it would from the redirected run that never installs a +/// handler at all. SIGINT does not go through here — upstream exits 1 on +/// KeyboardInterrupt and parity outranks the convention (plan.md §8). +pub fn die_from_sigterm() -> ! { + // SAFETY: restoring the default action and re-raising is the documented + // way to exit with a signal's status; raise(2) here does not return. + unsafe { + libc_signal(SIGTERM, SIG_DFL); + libc_raise(SIGTERM); + } + unreachable!("SIGTERM with the default action terminates the process"); +} + +/// Record terminal resizes so the CLI can rebuild effects whose canvas and +/// character positions were derived from the previous dimensions. +pub fn install_sigwinch_handler() { + // SAFETY: signal(2) with a signal-safe handler that only stores a flag. + unsafe { + libc_signal(SIGWINCH, handle_sigwinch as *const () as usize); + } +} + +extern "C" fn handle_sigwinch(_: i32) { + TERMINAL_RESIZED.store(true, Ordering::SeqCst); +} + +/// Consume a pending terminal resize notification. +pub fn take_terminal_resize() -> bool { + TERMINAL_RESIZED.swap(false, Ordering::SeqCst) +} + +/// Restore default SIGPIPE so `ttfx ... | head` dies quietly like any Unix +/// tool instead of panicking on a broken pipe (Rust ignores SIGPIPE by default). +pub fn restore_sigpipe() { + unsafe { + libc_signal(SIGPIPE, SIG_DFL); + } +} + +const SIGINT: i32 = 2; +const SIGTERM: i32 = 15; +const SIGPIPE: i32 = 13; +/// 28 on Linux and on the BSDs, macOS included. +const SIGWINCH: i32 = 28; +const SIG_DFL: usize = 0; + +unsafe fn libc_signal(signum: i32, handler: usize) { + unsafe extern "C" { + fn signal(signum: i32, handler: usize) -> usize; + } + unsafe { + signal(signum, handler); + } +} + +unsafe fn libc_raise(signum: i32) { + unsafe extern "C" { + fn raise(signum: i32) -> i32; + } + unsafe { + raise(signum); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_resize_notifications_are_consumed() { + take_terminal_resize(); + handle_sigwinch(SIGWINCH); + assert!(take_terminal_resize()); + assert!(!take_terminal_resize()); + } +} diff --git a/vendor/ttfx/src/main.rs b/vendor/ttfx/src/main.rs new file mode 100644 index 0000000..39fef25 --- /dev/null +++ b/vendor/ttfx/src/main.rs @@ -0,0 +1,228 @@ +use std::io::{IsTerminal, Read}; +use std::process::ExitCode; + +use clap::Parser; + +use ttfx::engine::terminal::Terminal; +use ttfx::{cli, engine}; + +fn get_piped_input() -> String { + let stdin = std::io::stdin(); + if stdin.is_terminal() { + return String::new(); + } + let mut buf: Vec = Vec::new(); + if stdin.lock().read_to_end(&mut buf).is_err() { + return String::new(); + } + // strict UTF-8, like Python's text-mode stdin (plan.md §8) + match String::from_utf8(buf) { + Ok(s) => s, + Err(e) => { + ttfx::outln!("Error decoding input: {e}"); + std::process::exit(1); + } + } +} + +/// Skip the arena teardown on the way out. Output is already flushed and +/// nothing in the engine has a Drop impl that does work, so freeing tens of +/// thousands of characters — each with its own scenes, paths and frames — one +/// by one is pure exit latency; on binarypath it is ~4% of the run. +/// +/// Only the exit paths come through here. A resize rebuild drops its engine +/// normally, so a long session of resizes does not accumulate them. +fn forget_engine(effect: E, ctx: C) { + std::mem::forget(effect); + std::mem::forget(ctx); +} + +fn main() -> ExitCode { + ttfx::restore_sigpipe(); + let cli = cli::Cli::parse(); + + // upstream prints the completion script and returns before any input handling + if let Some(shell) = &cli.print_completion { + use clap::CommandFactory; + let generator = match shell.as_str() { + "bash" => clap_complete::Shell::Bash, + _ => clap_complete::Shell::Zsh, + }; + let mut command = cli::Cli::command(); + clap_complete::generate(generator, &mut command, "ttfx", &mut std::io::stdout()); + return ExitCode::SUCCESS; + } + + let input_data = match &cli.input_file { + Some(path) => match std::fs::read(path) { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(s) => s, + Err(e) => { + // upstream prints runtime file errors to STDOUT and exits 1 + ttfx::outln!("Error reading input file: {e}"); + return ExitCode::from(1); + } + }, + Err(e) => { + ttfx::outln!("Error reading input file: {e}"); + return ExitCode::from(1); + } + }, + None => get_piped_input(), + }; + + if input_data.trim().is_empty() { + ttfx::outln!("NO INPUT."); + return ExitCode::from(1); + } + + if cli.m0_dump { + return m0_dump(&input_data, &cli); + } + + let mut rng = match cli.seed { + Some(seed) => ttfx::utils::rng::Rng::seeded(seed), + None => ttfx::utils::rng::Rng::from_entropy(), + }; + + // --random-effect: pick from the registry (filtered), run with pure + // default effect config — upstream ignores effect CLI args here too. + let chosen_effect; + let effect_command = if cli.random_effect { + use clap::CommandFactory; + let mut names: Vec = cli::Cli::command() + .get_subcommands() + .map(|c| c.get_name().to_string()) + .collect(); + if !cli.include_effects.is_empty() { + names.retain(|n| cli.include_effects.contains(n)); + } + names.retain(|n| !cli.exclude_effects.contains(n)); + if names.is_empty() { + ttfx::errln!("Error: No effects available after filtering."); + return ExitCode::from(1); + } + let name = names[rng.choice_index(names.len())].clone(); + chosen_effect = match clap::Parser::try_parse_from::<_, &str>(["ttfx", &name]) { + Ok(cli::Cli { effect: Some(effect), .. }) => effect, + _ => { + ttfx::errln!("Error: failed to build effect '{name}'."); + return ExitCode::from(1); + } + }; + &chosen_effect + } else { + match &cli.effect { + Some(effect) => effect, + None => { + ttfx::errln!("Error: No effect specified."); + return ExitCode::from(1); + } + } + }; + + let mut config = cli.terminal_config(); + // SIGWINCH is delivered to every process in the terminal's foreground group, + // whatever its stdout points at. Reacting to it when the animation is being + // redirected would leave a truncated first run followed by a complete second + // one in the file. SIGTERM teardown is tty-only for the same reason: a + // redirected stream must not gain teardown bytes. Only the teardown differs + // — the tty run re-raises afterwards, so both die from the signal. + let tty_output = !cli.parity_dump && std::io::stdout().is_terminal(); + if !cli.parity_dump { + ttfx::install_sigint_handler(); + } + if tty_output { + ttfx::install_sigterm_handler(); + ttfx::install_sigwinch_handler(); + } + + let result = loop { + let clock = if cli.parity_dump || cli.virtual_clock { + ttfx::engine::ctx::Clock::virtual_with_frame_rate(config.frame_rate) + } else { + ttfx::engine::ctx::Clock::real() + }; + let mut ctx = match ttfx::engine::ctx::EngineCtx::new( + &input_data, + config.clone(), + rng, + clock, + ) { + Ok(ctx) => ctx, + Err(engine::error::EngineError::UnsupportedAnsiSequence(seq)) => { + ttfx::errln!("Error: Unsupported ANSI sequence in input data: {seq:?}"); + return ExitCode::from(1); + } + Err(e) => { + ttfx::errln!("Error: {e}"); + return ExitCode::from(1); + } + }; + let mut effect = effect_command.build_effect(); + + let outcome = if cli.parity_dump { + ttfx::engine::effect::dump_effect(effect.as_mut(), &mut ctx, cli.max_frames) + .map(|_| ttfx::engine::effect::RunOutcome::Complete) + } else { + ttfx::engine::effect::run_effect(effect.as_mut(), &mut ctx, tty_output) + }; + match outcome { + Ok(ttfx::engine::effect::RunOutcome::TerminalResized) => { + // run_effect wiped the old area and left the cursor at its top, + // so the rebuild lays out from here. --reuse-canvas would send + // prep_canvas to a DEC anchor that no longer applies, so it only + // governs the first run. Dropping this engine normally is what + // keeps a long session of resizes from accumulating them. + config.reuse_canvas = false; + rng = ctx.rng; + } + done => { + forget_engine(effect, ctx); + break done.map(|_| ()); + } + } + }; + std::mem::forget(input_data); + + match result { + Ok(()) => { + if ttfx::terminated() { + ttfx::die_from_sigterm(); + } + if ttfx::interrupted() { + ExitCode::from(1) + } else { + ExitCode::SUCCESS + } + } + Err(e) => { + ttfx::errln!("Error: {e}"); + ExitCode::from(1) + } + } +} + +/// M0 parity path: build the Terminal, make every character in +/// character_by_input_coord visible, print the first frame to stdout. +fn m0_dump(input_data: &str, cli: &cli::Cli) -> ExitCode { + let config = cli.terminal_config(); + let mut terminal = match Terminal::new(input_data, config) { + Ok(t) => t, + Err(engine::error::EngineError::UnsupportedAnsiSequence(seq)) => { + ttfx::errln!("Error: Unsupported ANSI sequence in input data: {seq:?}"); + return ExitCode::from(1); + } + Err(e) => { + ttfx::errln!("Error: {e}"); + return ExitCode::from(1); + } + }; + let ids: Vec<_> = terminal.character_by_input_coord.values().copied().collect(); + for id in ids { + terminal.set_character_visibility(id, true); + } + print!("{}", terminal.get_formatted_output_string()); + println!(); + ExitCode::SUCCESS +} diff --git a/vendor/ttfx/src/utils/ansi.rs b/vendor/ttfx/src/utils/ansi.rs new file mode 100644 index 0000000..39a06d6 --- /dev/null +++ b/vendor/ttfx/src/utils/ansi.rs @@ -0,0 +1,168 @@ +//! ANSI escape sequences, ported from utils/ansitools.py + utils/colorterm.py. + +use std::fmt::Write; + +pub const DEC_SAVE_CURSOR: &str = "\x1b7"; +pub const DEC_RESTORE_CURSOR: &str = "\x1b8"; +pub const HIDE_CURSOR: &str = "\x1b[?25l"; +pub const SHOW_CURSOR: &str = "\x1b[?25h"; +pub const RESET_ALL: &str = "\x1b[0m"; +pub const CLEAR_TO_END_OF_SCREEN: &str = "\x1b[0J"; +pub const BOLD: &str = "\x1b[1m"; +pub const DIM: &str = "\x1b[2m"; +pub const ITALIC: &str = "\x1b[3m"; +pub const UNDERLINE: &str = "\x1b[4m"; +pub const BLINK: &str = "\x1b[5m"; +pub const REVERSE: &str = "\x1b[7m"; +pub const HIDDEN: &str = "\x1b[8m"; +pub const STRIKETHROUGH: &str = "\x1b[9m"; + +pub fn move_cursor_up(y: usize) -> String { + format!("\x1b[{y}A") +} + +pub fn move_cursor_to_column(x: usize) -> String { + format!("\x1b[{x}G") +} + +/// A resolved color code ready for SGR emission: hex string => 24-bit, int => 8-bit. +/// Mirrors the str|int union threaded through colorterm/animation upstream. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ColorCode { + Rgb(String), // hex without '#', case preserved as upstream passes it + Xterm(u8), +} + +impl ColorCode { + /// RGB bytes this code would emit. Xterm indexes use hexterm's palette. + pub fn rgb(&self) -> (u8, u8, u8) { + match self { + ColorCode::Rgb(hex) => parse_rgb_hex(hex), + ColorCode::Xterm(n) => parse_rgb_hex(crate::utils::hexterm::xterm_to_hex(*n)), + } + } +} + +fn parse_rgb_hex(hex: &str) -> (u8, u8, u8) { + let s = hex.trim_matches('#'); + if s.len() < 6 { + return (0, 0, 0); + } + ( + u8::from_str_radix(&s[0..2], 16).unwrap_or(0), + u8::from_str_radix(&s[2..4], 16).unwrap_or(0), + u8::from_str_radix(&s[4..6], 16).unwrap_or(0), + ) +} + +/// Decimal digits of a byte, without going through core::fmt. Every restyled +/// character reassembles its SGR sequence, so the formatting machinery shows up +/// in profiles. +#[inline] +fn push_decimal(out: &mut String, value: u8) { + if value >= 100 { + out.push((b'0' + value / 100) as char); + } + if value >= 10 { + out.push((b'0' + (value / 10) % 10) as char); + } + out.push((b'0' + value % 10) as char); +} + +/// colorterm._color: fg selector 38, bg selector 48. +fn sgr_color(code: &ColorCode, location: u8, out: &mut String) { + out.push_str("\x1b["); + push_decimal(out, location); + match code { + ColorCode::Rgb(hex) => { + let s = hex.trim_matches('#'); + let r = u8::from_str_radix(&s[0..2], 16).unwrap(); + let g = u8::from_str_radix(&s[2..4], 16).unwrap(); + let b = u8::from_str_radix(&s[4..6], 16).unwrap(); + out.push_str(";2;"); + push_decimal(out, r); + out.push(';'); + push_decimal(out, g); + out.push(';'); + push_decimal(out, b); + } + ColorCode::Xterm(n) => { + out.push_str(";5;"); + push_decimal(out, *n); + } + } + out.push('m'); +} + +pub fn fg(code: &ColorCode, out: &mut String) { + sgr_color(code, 38, out); +} + +pub fn bg(code: &ColorCode, out: &mut String) { + sgr_color(code, 48, out); +} + +/// ansitools.parse_ansi_color_sequence: strips `\x1b[` prefix and trailing `m`s, +/// recognizes 38;2/48;2 (24-bit, empty channels -> 0, UPPERCASE hex) and +/// 38;5/48;5 (8-bit). Anything else is an error. +pub fn parse_ansi_color_sequence(sequence: &str) -> Result { + let s = sequence + .strip_prefix("\x1b[") + .unwrap_or(sequence) + .trim_matches('m'); + if let Some(rest) = s.strip_prefix("38;2").or_else(|| s.strip_prefix("48;2")) { + // upstream strips "38;2;" (with semicolon); bare "38;2" leaves "" -> single empty field -> "00" + let rest = rest.strip_prefix(';').unwrap_or(rest); + let mut hex = String::new(); + for field in rest.split(';') { + let v: i64 = if field.is_empty() { + 0 + } else { + field.parse().map_err(|_| "Invalid ANSI color sequence".to_string())? + }; + write!(hex, "{v:02X}").unwrap(); + } + return Ok(ColorCode::Rgb(hex)); + } + if let Some(rest) = s.strip_prefix("38;5").or_else(|| s.strip_prefix("48;5")) { + let rest = rest.strip_prefix(';').unwrap_or(rest); + let v: i64 = rest.parse().map_err(|_| "Invalid ANSI color sequence".to_string())?; + return Ok(ColorCode::Xterm(v as u8)); + } + Err("Invalid ANSI color sequence".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_24_bit() { + assert_eq!( + parse_ansi_color_sequence("\x1b[38;2;255;0;128m"), + Ok(ColorCode::Rgb("FF0080".into())) + ); + // empty channel normalized to 0 (upstream doc example: 38;2;;0m). + // Note upstream's prefix regex eats "38;2;" so ";;0" leaves TWO fields + // ("", "0") -> "0000", not three. Faithful, if odd. + assert_eq!( + parse_ansi_color_sequence("\x1b[38;2;;0m"), + Ok(ColorCode::Rgb("0000".into())) + ); + } + + #[test] + fn parse_8_bit() { + assert_eq!(parse_ansi_color_sequence("\x1b[48;5;42m"), Ok(ColorCode::Xterm(42))); + } + + #[test] + fn sgr_emission() { + let mut s = String::new(); + fg(&ColorCode::Rgb("ff0080".into()), &mut s); + assert_eq!(s, "\x1b[38;2;255;0;128m"); + s.clear(); + bg(&ColorCode::Xterm(42), &mut s); + assert_eq!(s, "\x1b[48;5;42m"); + } +} diff --git a/vendor/ttfx/src/utils/easing.rs b/vendor/ttfx/src/utils/easing.rs new file mode 100644 index 0000000..6a81504 --- /dev/null +++ b/vendor/ttfx/src/utils/easing.rs @@ -0,0 +1,420 @@ +//! Easing functions, ported from utils/easing.py. +//! +//! Transcription rule: Python `x ** n` routes through C `pow()` even for int +//! exponents, so every `**` here is `powf`, never `powi` — they can differ by +//! ULPs and coordinate quantization sits downstream (plan.md §5.20). + +use std::f64::consts::PI; + +/// A named easing or a custom cubic bezier (make_easing). Copyable so Paths and +/// Scenes can carry it by value. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Easing { + Linear, + InSine, + OutSine, + InOutSine, + InQuad, + OutQuad, + InOutQuad, + InCubic, + OutCubic, + InOutCubic, + InQuart, + OutQuart, + InOutQuart, + InQuint, + OutQuint, + InOutQuint, + InExpo, + OutExpo, + InOutExpo, + InCirc, + OutCirc, + InOutCirc, + InBack, + OutBack, + InOutBack, + InElastic, + OutElastic, + InOutElastic, + InBounce, + OutBounce, + InOutBounce, + /// easing.make_easing(x1, y1, x2, y2): CSS-style cubic bezier. + CubicBezier(f64, f64, f64, f64), +} + +impl Easing { + /// CLI parser for the 31 named functions (argutils.Ease.type_parser). + pub fn parse(s: &str) -> Option { + Some(match s.to_lowercase().as_str() { + "linear" => Easing::Linear, + "in_sine" => Easing::InSine, + "out_sine" => Easing::OutSine, + "in_out_sine" => Easing::InOutSine, + "in_quad" => Easing::InQuad, + "out_quad" => Easing::OutQuad, + "in_out_quad" => Easing::InOutQuad, + "in_cubic" => Easing::InCubic, + "out_cubic" => Easing::OutCubic, + "in_out_cubic" => Easing::InOutCubic, + "in_quart" => Easing::InQuart, + "out_quart" => Easing::OutQuart, + "in_out_quart" => Easing::InOutQuart, + "in_quint" => Easing::InQuint, + "out_quint" => Easing::OutQuint, + "in_out_quint" => Easing::InOutQuint, + "in_expo" => Easing::InExpo, + "out_expo" => Easing::OutExpo, + "in_out_expo" => Easing::InOutExpo, + "in_circ" => Easing::InCirc, + "out_circ" => Easing::OutCirc, + "in_out_circ" => Easing::InOutCirc, + "in_back" => Easing::InBack, + "out_back" => Easing::OutBack, + "in_out_back" => Easing::InOutBack, + "in_elastic" => Easing::InElastic, + "out_elastic" => Easing::OutElastic, + "in_out_elastic" => Easing::InOutElastic, + "in_bounce" => Easing::InBounce, + "out_bounce" => Easing::OutBounce, + "in_out_bounce" => Easing::InOutBounce, + _ => return None, + }) + } + + pub fn ease(&self, p: f64) -> f64 { + match *self { + Easing::Linear => p, + Easing::InSine => 1.0 - ((p * PI) / 2.0).cos(), + Easing::OutSine => ((p * PI) / 2.0).sin(), + Easing::InOutSine => -((PI * p).cos() - 1.0) / 2.0, + Easing::InQuad => p.powf(2.0), + Easing::OutQuad => 1.0 - (1.0 - p) * (1.0 - p), + Easing::InOutQuad => { + if p < 0.5 { + 2.0 * p.powf(2.0) + } else { + 1.0 - (-2.0 * p + 2.0).powf(2.0) / 2.0 + } + } + Easing::InCubic => p.powf(3.0), + Easing::OutCubic => 1.0 - (1.0 - p).powf(3.0), + Easing::InOutCubic => { + if p < 0.5 { + 4.0 * p.powf(3.0) + } else { + 1.0 - (-2.0 * p + 2.0).powf(3.0) / 2.0 + } + } + Easing::InQuart => p.powf(4.0), + Easing::OutQuart => 1.0 - (1.0 - p).powf(4.0), + Easing::InOutQuart => { + if p < 0.5 { + 8.0 * p.powf(4.0) + } else { + 1.0 - (-2.0 * p + 2.0).powf(4.0) / 2.0 + } + } + Easing::InQuint => p.powf(5.0), + Easing::OutQuint => 1.0 - (1.0 - p).powf(5.0), + Easing::InOutQuint => { + if p < 0.5 { + 16.0 * p.powf(5.0) + } else { + 1.0 - (-2.0 * p + 2.0).powf(5.0) / 2.0 + } + } + Easing::InExpo => { + if p == 0.0 { + 0.0 + } else { + 2.0_f64.powf(10.0 * p - 10.0) + } + } + Easing::OutExpo => { + if p == 1.0 { + 1.0 + } else { + 1.0 - 2.0_f64.powf(-10.0 * p) + } + } + Easing::InOutExpo => { + if p == 0.0 { + 0.0 + } else if p == 1.0 { + 1.0 + } else if p < 0.5 { + 2.0_f64.powf(20.0 * p - 10.0) / 2.0 + } else { + (2.0 - 2.0_f64.powf(-20.0 * p + 10.0)) / 2.0 + } + } + Easing::InCirc => 1.0 - (1.0 - p.powf(2.0)).sqrt(), + Easing::OutCirc => (1.0 - (p - 1.0).powf(2.0)).sqrt(), + Easing::InOutCirc => { + if p < 0.5 { + (1.0 - (1.0 - (2.0 * p).powf(2.0)).sqrt()) / 2.0 + } else { + ((1.0 - (-2.0 * p + 2.0).powf(2.0)).sqrt() + 1.0) / 2.0 + } + } + Easing::InBack => { + let c1 = 1.70158; + let c3 = c1 + 1.0; + c3 * p.powf(3.0) - c1 * p.powf(2.0) + } + Easing::OutBack => { + let c1 = 1.70158; + let c3 = c1 + 1.0; + 1.0 + c3 * (p - 1.0).powf(3.0) + c1 * (p - 1.0).powf(2.0) + } + Easing::InOutBack => { + let c1 = 1.70158; + let c2 = c1 * 1.525; + if p < 0.5 { + ((2.0 * p).powf(2.0) * ((c2 + 1.0) * 2.0 * p - c2)) / 2.0 + } else { + ((2.0 * p - 2.0).powf(2.0) * ((c2 + 1.0) * (p * 2.0 - 2.0) + c2) + 2.0) / 2.0 + } + } + Easing::InElastic => { + let c4 = (2.0 * PI) / 3.0; + if p == 0.0 { + 0.0 + } else if p == 1.0 { + 1.0 + } else { + -(2.0_f64.powf(10.0 * p - 10.0)) * ((p * 10.0 - 10.75) * c4).sin() + } + } + Easing::OutElastic => { + let c4 = (2.0 * PI) / 3.0; + if p == 0.0 { + 0.0 + } else if p == 1.0 { + 1.0 + } else { + 2.0_f64.powf(-10.0 * p) * ((p * 10.0 - 0.75) * c4).sin() + 1.0 + } + } + Easing::InOutElastic => { + let c5 = (2.0 * PI) / 4.5; + if p == 0.0 { + 0.0 + } else if p == 1.0 { + 1.0 + } else if p < 0.5 { + -(2.0_f64.powf(20.0 * p - 10.0) * ((20.0 * p - 11.125) * c5).sin()) / 2.0 + } else { + (2.0_f64.powf(-20.0 * p + 10.0) * ((20.0 * p - 11.125) * c5).sin()) / 2.0 + 1.0 + } + } + Easing::InBounce => 1.0 - out_bounce(1.0 - p), + Easing::OutBounce => out_bounce(p), + Easing::InOutBounce => { + if p < 0.5 { + (1.0 - out_bounce(1.0 - 2.0 * p)) / 2.0 + } else { + (1.0 + out_bounce(2.0 * p - 1.0)) / 2.0 + } + } + Easing::CubicBezier(x1, y1, x2, y2) => bezier_easing(x1, y1, x2, y2, p), + } + } +} + +fn out_bounce(p: f64) -> f64 { + let n1 = 7.5625; + let d1 = 2.75; + if p < 1.0 / d1 { + n1 * p.powf(2.0) + } else if p < 2.0 / d1 { + n1 * (p - 1.5 / d1).powf(2.0) + 0.75 + } else if p < 2.5 / d1 { + n1 * (p - 2.25 / d1).powf(2.0) + 0.9375 + } else { + n1 * (p - 2.625 / d1).powf(2.0) + 0.984375 + } +} + +/// easing.make_easing's bezier_easing: Newton-Raphson on x with the exact +/// upstream constants (20 iterations, 1e-5 convergence, 1e-6 derivative bail). +/// Upstream lru_caches this; we just recompute (behavior-identical). +fn bezier_easing(x1: f64, y1: f64, x2: f64, y2: f64, progress: f64) -> f64 { + let sample_curve_x = |t: f64| 3.0 * x1 * (1.0 - t).powf(2.0) * t + 3.0 * x2 * (1.0 - t) * t.powf(2.0) + t.powf(3.0); + let sample_curve_y = |t: f64| 3.0 * y1 * (1.0 - t).powf(2.0) * t + 3.0 * y2 * (1.0 - t) * t.powf(2.0) + t.powf(3.0); + let sample_curve_derivative_x = + |t: f64| 3.0 * (1.0 - t).powf(2.0) * x1 + 6.0 * (1.0 - t) * t * (x2 - x1) + 3.0 * t.powf(2.0) * (1.0 - x2); + + if progress <= 0.0 { + return 0.0; + } + if progress >= 1.0 { + return 1.0; + } + let mut t = progress; + for _ in 0..20 { + let x_est = sample_curve_x(t); + let dx = x_est - progress; + if dx.abs() < 1e-5 { + break; + } + let d = sample_curve_derivative_x(t); + if d.abs() < 1e-6 { + break; + } + t -= dx / d; + } + sample_curve_y(t) +} + +/// easing.EasingTracker. +#[derive(Debug, Clone)] +pub struct EasingTracker { + pub easing_function: Easing, + pub total_steps: i64, + clamp: bool, + pub current_step: i64, + pub progress_ratio: f64, + pub step_delta: f64, + pub eased_value: f64, + last_eased_value: f64, +} + +impl EasingTracker { + pub fn new(easing_function: Easing, total_steps: i64, clamp: bool) -> Self { + EasingTracker { + easing_function, + total_steps, + clamp, + current_step: 0, + progress_ratio: 0.0, + step_delta: 0.0, + eased_value: 0.0, + last_eased_value: 0.0, + } + } + + pub fn step(&mut self) -> f64 { + if self.current_step < self.total_steps { + self.current_step += 1; + self.progress_ratio = self.current_step as f64 / self.total_steps as f64; + self.eased_value = self.easing_function.ease(self.progress_ratio); + if self.clamp { + self.eased_value = self.eased_value.min(1.0).max(0.0); + } + self.step_delta = self.eased_value - self.last_eased_value; + self.last_eased_value = self.eased_value; + } + self.eased_value + } + + pub fn reset(&mut self) { + self.current_step = 0; + self.progress_ratio = 0.0; + self.step_delta = 0.0; + self.eased_value = 0.0; + self.last_eased_value = 0.0; + } + + pub fn is_complete(&self) -> bool { + self.current_step >= self.total_steps + } +} + +/// easing.SequenceEaser over owned elements (effects use it over id groups). +#[derive(Debug, Clone)] +pub struct SequenceEaser { + pub sequence: Vec, + pub easing_tracker: EasingTracker, +} + +#[derive(Debug, Clone, Copy)] +pub struct SequenceStep<'a, T> { + pub added: &'a [T], + pub removed: &'a [T], +} + +impl SequenceEaser { + pub fn new(sequence: Vec, easing_function: Easing, total_steps: i64) -> Self { + SequenceEaser { + sequence, + easing_tracker: EasingTracker::new(easing_function, total_steps, true), + } + } + + pub fn step(&mut self) -> SequenceStep<'_, T> { + let previous_eased = self.easing_tracker.eased_value; + let eased_value = self.easing_tracker.step(); + let seq_len = self.sequence.len(); + if seq_len == 0 { + return SequenceStep { added: &[], removed: &[] }; + } + // int() truncation, faithfully + let length = (eased_value * seq_len as f64) as i64 as usize; + let previous_length = (previous_eased * seq_len as f64) as i64 as usize; + + match length.cmp(&previous_length) { + std::cmp::Ordering::Greater => SequenceStep { + added: &self.sequence[previous_length..length], + removed: &[], + }, + std::cmp::Ordering::Less => SequenceStep { + added: &[], + removed: &self.sequence[length..previous_length], + }, + std::cmp::Ordering::Equal => SequenceStep { added: &[], removed: &[] }, + } + } + + pub fn is_complete(&self) -> bool { + self.easing_tracker.is_complete() + } + + pub fn reset(&mut self) { + self.easing_tracker.reset(); + } +} + +#[cfg(test)] +mod tests { + use super::{Easing, SequenceEaser}; + + #[derive(Debug, PartialEq)] + struct NonClone(u8); + + #[test] + fn sequence_easer_returns_borrowed_deltas_without_clone() { + let mut easer = SequenceEaser::new( + vec![NonClone(0), NonClone(1), NonClone(2), NonClone(3)], + Easing::Linear, + 4, + ); + + assert_eq!(easer.step().added, &[NonClone(0)]); + assert_eq!(easer.step().added, &[NonClone(1)]); + assert_eq!(easer.step().added, &[NonClone(2)]); + assert_eq!(easer.step().added, &[NonClone(3)]); + assert!(easer.step().added.is_empty()); + + easer.reset(); + assert_eq!(easer.step().added, &[NonClone(0)]); + } + + #[test] + fn sequence_easer_reports_reversed_easing_as_removed() { + let sequence: Vec = (0..100).collect(); + let mut easer = SequenceEaser::new(sequence, Easing::OutBounce, 100); + let mut saw_removed = false; + + for _ in 0..100 { + let step = easer.step(); + assert!(step.added.is_empty() || step.removed.is_empty()); + saw_removed |= !step.removed.is_empty(); + } + + assert!(saw_removed); + } +} diff --git a/vendor/ttfx/src/utils/geometry.rs b/vendor/ttfx/src/utils/geometry.rs new file mode 100644 index 0000000..6b6f6a4 --- /dev/null +++ b/vendor/ttfx/src/utils/geometry.rs @@ -0,0 +1,236 @@ +//! Coord and geometry math, ported from utils/geometry.py. +//! +//! Upstream wraps every function in lru_cache; behavior is identical without +//! the caches, so they are omitted. All `round()` calls are banker's rounding +//! (pycompat), all int() casts truncate. + +use std::collections::HashSet; + +use crate::utils::pycompat::round_half_even; + +/// 1-based canvas coordinate: column grows right, row grows UP (origin bottom-left). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Coord { + pub column: i64, + pub row: i64, +} + +impl Coord { + pub fn new(column: i64, row: i64) -> Self { + Coord { column, row } + } +} + +/// Float-valued point for bezier intermediates: upstream builds Coord objects +/// with float fields inside de_casteljau (violating its own annotation) and +/// only rounds the final result. +#[derive(Debug, Clone, Copy)] +struct FloatPoint { + column: f64, + row: f64, +} + +impl FloatPoint { + #[inline] + fn interpolate(self, other: Self, t: f64) -> Self { + FloatPoint { + column: (1.0 - t) * self.column + t * other.column, + row: (1.0 - t) * self.row + t * other.row, + } + } +} + +/// find_coords_on_circle: coords_limit 0 -> round(2*pi*r); x offset from the +/// origin is doubled for cell aspect; every point rounded (banker's). +pub fn find_coords_on_circle(origin: Coord, radius: i64, coords_limit: i64, unique: bool) -> Vec { + let mut points: Vec = Vec::new(); + if radius == 0 { + return points; + } + let mut seen: HashSet = HashSet::new(); + let coords_limit = if coords_limit == 0 { + round_half_even(2.0 * std::f64::consts::PI * radius as f64) + } else { + coords_limit + }; + let angle_step = 2.0 * std::f64::consts::PI / coords_limit as f64; + for i in 0..coords_limit { + let angle = angle_step * i as f64; + let mut x = origin.column as f64 + radius as f64 * angle.cos(); + let x_diff = x - origin.column as f64; + x += x_diff; + let y = origin.row as f64 + radius as f64 * angle.sin(); + let point = Coord::new(round_half_even(x), round_half_even(y)); + if unique { + if !seen.contains(&point) { + points.push(point); + } + } else { + points.push(point); + } + seen.insert(point); + } + points +} + +/// find_coords_in_circle: actually an ellipse (a = diameter, b = diameter/2); +/// int() truncation on the y offset, faithfully. +pub fn find_coords_in_circle(center: Coord, diameter: i64) -> Vec { + let (h, k) = (center.column, center.row); + let mut coords: Vec = Vec::new(); + if diameter == 0 { + return coords; + } + let a_squared = (diameter as f64).powf(2.0); + let b_squared = (diameter as f64 / 2.0).powf(2.0); + for x in (h - diameter)..=(h + diameter) { + let x_component = ((x - h) as f64).powf(2.0) / a_squared; + let max_y_offset = (b_squared * (1.0 - x_component)).powf(0.5) as i64; + for y in (k - max_y_offset)..=(k + max_y_offset) { + coords.push(Coord::new(x, y)); + } + } + coords +} + +/// find_coords_in_rect: full (2d+1)^2 block, empty for distance 0. +/// Iteration order is column-major like upstream. +pub fn find_coords_in_rect(origin: Coord, distance: i64) -> Vec { + let mut coords: Vec = Vec::new(); + if distance == 0 { + return coords; + } + for column in (origin.column - distance)..=(origin.column + distance) { + for row in (origin.row - distance)..=(origin.row + distance) { + coords.push(Coord::new(column, row)); + } + } + coords +} + +/// find_coords_on_rect: perimeter only; empty if either half-dimension is 0. +pub fn find_coords_on_rect(origin: Coord, half_width: i64, half_height: i64) -> Vec { + let mut coords: Vec = Vec::new(); + if half_width == 0 || half_height == 0 { + return coords; + } + for column in (origin.column - half_width)..=(origin.column + half_width) { + if column == origin.column - half_width || column == origin.column + half_width { + for row in (origin.row - half_height)..=(origin.row + half_height) { + coords.push(Coord::new(column, row)); + } + } else { + coords.push(Coord::new(column, origin.row - half_height)); + coords.push(Coord::new(column, origin.row + half_height)); + } + } + coords +} + +/// extrapolate_along_ray: NON-doubled line length, lerp past the target, round. +pub fn extrapolate_along_ray(origin: Coord, target: Coord, offset_from_target: f64) -> Coord { + let base = find_length_of_line(origin, target, false); + let total_distance = base + offset_from_target; + if total_distance == 0.0 || origin == target { + return target; + } + let t = total_distance / base; + let next_column = (1.0 - t) * origin.column as f64 + t * target.column as f64; + let next_row = (1.0 - t) * origin.row as f64 + t * target.row as f64; + Coord::new(round_half_even(next_column), round_half_even(next_row)) +} + +/// find_coord_on_bezier_curve: recursive De Casteljau of arbitrary degree with +/// float intermediates, rounded only at the end. +pub fn find_coord_on_bezier_curve(start: Coord, control: &[Coord], end: Coord, t: f64) -> Coord { + if control.is_empty() { + return find_coord_on_line(start, end, t); + } + + let start = FloatPoint { column: start.column as f64, row: start.row as f64 }; + let end = FloatPoint { column: end.column as f64, row: end.row as f64 }; + + // Every production path is quadratic. Keep that per-frame hot path on the + // stack instead of allocating a Vec at each De Casteljau level. + if let [control] = control { + let control = FloatPoint { column: control.column as f64, row: control.row as f64 }; + let point = start.interpolate(control, t).interpolate(control.interpolate(end, t), t); + return Coord::new(round_half_even(point.column), round_half_even(point.row)); + } + + let mut points: Vec = Vec::with_capacity(control.len() + 2); + points.push(start); + for c in control { + points.push(FloatPoint { column: c.column as f64, row: c.row as f64 }); + } + points.push(end); + let mut remaining = points.len(); + while remaining > 1 { + for i in 0..remaining - 1 { + points[i] = points[i].interpolate(points[i + 1], t); + } + remaining -= 1; + } + Coord::new(round_half_even(points[0].column), round_half_even(points[0].row)) +} + +/// find_coord_on_line: lerp + round. +pub fn find_coord_on_line(start: Coord, end: Coord, t: f64) -> Coord { + let x = (1.0 - t) * start.column as f64 + t * end.column as f64; + let y = (1.0 - t) * start.row as f64 + t * end.row as f64; + Coord::new(round_half_even(x), round_half_even(y)) +} + +/// find_length_of_bezier_curve: 10-sample polyline that stops at t=0.9 — the +/// final t=0.9..1.0 span is deliberately (faithfully) omitted, systematically +/// underestimating lengths. Do not fix (plan.md §5.4). +pub fn find_length_of_bezier_curve(start: Coord, control: &[Coord], end: Coord) -> f64 { + let mut length = 0.0; + let mut prev_coord = start; + for t in 1..10 { + let coord = find_coord_on_bezier_curve(start, control, end, t as f64 / 10.0); + length += find_length_of_line(prev_coord, coord, true); + prev_coord = coord; + } + length +} + +/// find_length_of_line: hypot, with the row delta doubled when requested +/// (terminal cell aspect convention). +pub fn find_length_of_line(coord1: Coord, coord2: Coord, double_row_diff: bool) -> f64 { + let column_diff = (coord2.column - coord1.column) as f64; + let row_diff = (coord2.row - coord1.row) as f64; + if double_row_diff { + f64::hypot(column_diff, 2.0 * row_diff) + } else { + f64::hypot(column_diff, row_diff) + } +} + +/// find_normalized_distance_from_center: rejects out-of-rectangle coords +/// (upstream ValueError); stays within [0, 1] for accepted ones. +pub fn find_normalized_distance_from_center( + bottom: i64, + top: i64, + left: i64, + right: i64, + other_coord: Coord, +) -> Result { + let y_offset = bottom - 1; + let x_offset = left - 1; + let right = right - x_offset; + let top = top - y_offset; + let center_x = right as f64 / 2.0; + let center_y = top as f64 / 2.0; + + // Python: `n not in range(a, b+1)` — integer membership + let col = other_coord.column - x_offset; + let row = other_coord.row - y_offset; + if !(left - x_offset..=right).contains(&col) || !(bottom - y_offset..=top).contains(&row) { + return Err("Coordinate is not within the rectangle.".to_string()); + } + + let max_distance = ((right as f64).powf(2.0) + ((top * 2) as f64).powf(2.0)).powf(0.5); + let distance = ((col as f64 - center_x).powf(2.0) + ((row as f64 - center_y) * 2.0).powf(2.0)).powf(0.5); + Ok(distance / (max_distance / 2.0)) +} diff --git a/vendor/ttfx/src/utils/graphics.rs b/vendor/ttfx/src/utils/graphics.rs new file mode 100644 index 0000000..60ad861 --- /dev/null +++ b/vendor/ttfx/src/utils/graphics.rs @@ -0,0 +1,399 @@ +//! Color, ColorPair, and Gradient, ported from utils/graphics.py. + +use std::collections::HashMap; +use std::fmt; +use std::ops::Deref; + +use crate::utils::geometry::{self, Coord}; +use crate::utils::hexterm; +use crate::utils::pycompat::floor_div; +use crate::utils::rng::Rng; + +/// The original constructor argument, preserved because upstream `Color.__eq__` +/// and `__hash__` compare `color_arg` — `Color(255) != Color("ffffff")` even +/// when they resolve to the same RGB. Dict/set keying depends on this. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ColorArg { + Xterm(u8), + Hex(RgbString), // stored stripped of '#', case preserved (upstream strips '#' only) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RgbString { + bytes: [u8; 7], + len: u8, +} + +impl RgbString { + fn new(value: &str) -> Self { + debug_assert!(value.len() <= 7); + let mut bytes = [0; 7]; + bytes[..value.len()].copy_from_slice(value.as_bytes()); + RgbString { + bytes, + len: value.len() as u8, + } + } +} + +impl Deref for RgbString { + type Target = str; + + fn deref(&self) -> &Self::Target { + std::str::from_utf8(&self.bytes[..self.len as usize]).unwrap() + } +} + +impl AsRef for RgbString { + fn as_ref(&self) -> &str { + self + } +} + +impl std::borrow::Borrow for RgbString { + fn borrow(&self) -> &str { + self + } +} + +impl std::hash::Hash for RgbString { + fn hash(&self, state: &mut H) { + std::hash::Hash::hash(&**self, state); + } +} + +impl fmt::Display for RgbString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self) + } +} + +impl fmt::Debug for RgbString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&**self, f) + } +} + +#[derive(Clone, Copy)] +pub struct Color { + pub color_arg: ColorArg, + /// Some(code) when constructed from an xterm int, None for hex strings. + pub xterm_color: Option, + /// hex string without '#' + pub rgb_color: RgbString, + rgb: [u8; 3], +} + +impl fmt::Debug for Color { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Color") + .field("color_arg", &self.color_arg) + .field("xterm_color", &self.xterm_color) + .field("rgb_color", &self.rgb_color) + .finish() + } +} + +impl PartialEq for Color { + fn eq(&self, other: &Self) -> bool { + self.color_arg == other.color_arg + } +} +impl Eq for Color {} +impl std::hash::Hash for Color { + fn hash(&self, state: &mut H) { + self.color_arg.hash(state); + } +} + +impl Color { + pub fn from_xterm(code: u8) -> Self { + let rgb_color = RgbString::new(hexterm::xterm_to_hex(code)); + Color { + color_arg: ColorArg::Xterm(code), + xterm_color: Some(code), + rgb: Self::parse_rgb(&rgb_color), + rgb_color, + } + } + + /// Hex-string constructor. Errors mirror upstream ValueError. + pub fn from_hex(hex: &str) -> Result { + let stripped = hex.trim_matches('#'); + if !hexterm::is_valid_hex_color(stripped) { + return Err( + "Invalid color value. Color must be an XTerm-256 color code or an RGB hex color string. \ + Example: 255 or 'ffffff' or '#ffffff'" + .to_string(), + ); + } + let rgb_color = RgbString::new(stripped); + Ok(Color { + color_arg: ColorArg::Hex(rgb_color), + xterm_color: None, + rgb: Self::parse_rgb(&rgb_color), + rgb_color, + }) + } + + pub fn rgb_ints(&self) -> (u8, u8, u8) { + (self.rgb[0], self.rgb[1], self.rgb[2]) + } + + fn parse_rgb(s: &str) -> [u8; 3] { + [ + u8::from_str_radix(&s[0..2], 16).unwrap(), + u8::from_str_radix(&s[2..4], 16).unwrap(), + u8::from_str_radix(&s[4..6], 16).unwrap(), + ] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct ColorPair { + pub fg_color: Option, + pub bg_color: Option, +} + +impl ColorPair { + pub fn new(fg: Option, bg: Option) -> Self { + ColorPair { fg_color: fg, bg_color: bg } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GradientDirection { + Vertical, + Horizontal, + Radial, + Diagonal, +} + +/// Insertion-ordered Coord -> Color mapping (upstream returns a dict; iteration +/// order is Python dict insertion order, which some effects walk). +#[derive(Debug, Clone, Default)] +pub struct CoordColorMap { + pub order: Vec, + map: HashMap, +} + +impl CoordColorMap { + fn insert(&mut self, coord: Coord, color: Color) { + if self.map.insert(coord, color).is_none() { + self.order.push(coord); + } + } + + pub fn get(&self, coord: &Coord) -> Option<&Color> { + self.map.get(coord) + } + + pub fn iter(&self) -> impl Iterator { + self.order.iter().map(move |c| (*c, &self.map[c])) + } +} + +/// graphics.Gradient. The spectrum is NOT float lerp: channel deltas use +/// Python integer floor division and the exact end stop is appended per pair +/// (plan.md §5.2). +#[derive(Debug, Clone)] +pub struct Gradient { + pub spectrum: Vec, +} + +impl Gradient { + /// Gradient(*stops, steps=...). `steps_was_int` mirrors the upstream quirk + /// that only scalar (int) steps are validated before generation. + pub fn new(stops: &[Color], steps: &[i64], steps_was_int: bool, do_loop: bool) -> Result { + if stops.is_empty() { + return Err("At least one stop must be provided.".to_string()); + } + if steps_was_int { + for &step in steps { + if step < 1 { + return Err("Steps must be greater than 0.".to_string()); + } + } + } + let mut spectrum: Vec = Vec::new(); + if stops.len() == 1 { + for _ in 0..steps[0] { + spectrum.push(stops[0].clone()); + } + return Ok(Gradient { spectrum }); + } + let mut stops: Vec = stops.to_vec(); + if do_loop { + stops.push(stops[0].clone()); + } + let pair_count = stops.len() - 1; + let mut steps: Vec = steps[..steps.len().min(pair_count)].to_vec(); + while steps.len() < pair_count { + steps.push(*steps.last().unwrap()); + } + for (pair_index, step_count) in steps.iter().copied().enumerate() { + if step_count < 1 { + return Err(format!("Invalid steps: {step_count} | Steps must be greater than 0.")); + } + let start = &stops[pair_index]; + let end = &stops[pair_index + 1]; + let (sr, sg, sb) = start.rgb_ints(); + let (er, eg, eb) = end.rgb_ints(); + let (sr, sg, sb) = (sr as i64, sg as i64, sb as i64); + let red_delta = floor_div(er as i64 - sr, step_count); + let green_delta = floor_div(eg as i64 - sg, step_count); + let blue_delta = floor_div(eb as i64 - sb, step_count); + let range_start = i64::from(!spectrum.is_empty()); + for i in range_start..step_count.max(0) { + let red = (sr + red_delta * i).clamp(0, 255); + let green = (sg + green_delta * i).clamp(0, 255); + let blue = (sb + blue_delta * i).clamp(0, 255); + spectrum.push(Color::from_hex(&format!("{red:02x}{green:02x}{blue:02x}")).unwrap()); + } + spectrum.push(end.clone()); + } + Ok(Gradient { spectrum }) + } + + /// Convenience: single scalar step count (the common upstream call shape). + pub fn with_steps(stops: &[Color], steps: i64, do_loop: bool) -> Result { + Gradient::new(stops, &[steps], true, do_loop) + } + + /// get_color_at_fraction: first i in 1..=len with fraction <= i/len. + pub fn get_color_at_fraction(&self, fraction: f64) -> Result<&Color, String> { + if !(0.0..=1.0).contains(&fraction) { + return Err("Fraction must be 0 <= fraction <= 1.".to_string()); + } + let len = self.spectrum.len(); + for i in 1..=len { + if fraction <= i as f64 / len as f64 { + return Ok(&self.spectrum[i - 1]); + } + } + Ok(self.spectrum.last().unwrap()) + } + + /// build_coordinate_color_mapping with upstream's insertion order per direction. + pub fn build_coordinate_color_mapping( + &self, + min_row: i64, + max_row: i64, + min_column: i64, + max_column: i64, + direction: GradientDirection, + ) -> Result { + if max_row < 1 || max_column < 1 || min_row < 1 || min_column < 1 { + return Err("max_row and max_column must be greater than 0.".to_string()); + } + if min_row > max_row || min_column > max_column { + return Err("min_row and min_column must be less than or equal to max_row and max_column.".to_string()); + } + let row_offset = min_row - 1; + let column_offset = min_column - 1; + let mut mapping = CoordColorMap::default(); + match direction { + GradientDirection::Vertical => { + for row in min_row..=max_row { + let fraction = (row - row_offset) as f64 / (max_row - row_offset) as f64; + let color = self.get_color_at_fraction(fraction)?.clone(); + for column in min_column..=max_column { + mapping.insert(Coord::new(column, row), color.clone()); + } + } + } + GradientDirection::Horizontal => { + for column in min_column..=max_column { + let fraction = (column - column_offset) as f64 / (max_column - column_offset) as f64; + let color = self.get_color_at_fraction(fraction)?.clone(); + for row in min_row..=max_row { + mapping.insert(Coord::new(column, row), color.clone()); + } + } + } + GradientDirection::Radial => { + for row in min_row..=max_row { + for column in min_column..=max_column { + let distance = geometry::find_normalized_distance_from_center( + min_row, + max_row, + min_column, + max_column, + Coord::new(column, row), + )?; + let color = self.get_color_at_fraction(distance)?.clone(); + mapping.insert(Coord::new(column, row), color); + } + } + } + GradientDirection::Diagonal => { + for row in min_row..=max_row { + for column in min_column..=max_column { + let fraction = (((row - row_offset) * 2) + (column - column_offset)) as f64 + / (((max_row - row_offset) * 2) + (max_column - column_offset)) as f64; + let color = self.get_color_at_fraction(fraction)?.clone(); + mapping.insert(Coord::new(column, row), color); + } + } + } + } + Ok(mapping) + } +} + +/// graphics.random_color. +pub fn random_color(rng: &mut Rng) -> Color { + Color::from_hex(&format!("{:06x}", rng.randint(0, 0xFFFFFF))).unwrap() +} + +/// graphics.shift_color_towards: float lerp with int() TRUNCATION back to hex +/// (unlike adjust_color_brightness's round()). Negative components format +/// Python-style ("-3" not two's complement) so error conditions match. +pub fn shift_color_towards(color: &Color, target_color: &Color, factor: f64) -> Result { + let interpolate = |start: f64, end: f64, factor: f64| start + (end - start) * factor; + let norm = |c: &Color| { + let (r, g, b) = c.rgb_ints(); + (r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0) + }; + let (cr, cg, cb) = norm(color); + let (tr, tg, tb) = norm(target_color); + let py_hex = |v: f64| { + let i = (v * 255.0) as i64; // int() truncation + if i < 0 { + format!("-{:01x}", -i) + } else { + format!("{i:02x}") + } + }; + let hex = format!( + "{}{}{}", + py_hex(interpolate(cr, tr, factor)), + py_hex(interpolate(cg, tg, factor)), + py_hex(interpolate(cb, tb, factor)) + ); + Color::from_hex(&hex) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::Color; + + #[test] + fn rgb_string_borrowed_lookup_matches_str_hash() { + let rgb = Color::from_hex("12AbEf7").unwrap().rgb_color; + let mut colors = HashMap::new(); + colors.insert(rgb, 1); + assert_eq!(colors.get("12AbEf7"), Some(&1)); + } + + #[test] + fn color_debug_hides_the_cached_representation() { + let color = Color::from_hex("12AbEf7").unwrap(); + assert_eq!( + format!("{color:?}"), + "Color { color_arg: Hex(\"12AbEf7\"), xterm_color: None, rgb_color: \"12AbEf7\" }" + ); + } +} diff --git a/vendor/ttfx/src/utils/hexterm.rs b/vendor/ttfx/src/utils/hexterm.rs new file mode 100644 index 0000000..4f66844 --- /dev/null +++ b/vendor/ttfx/src/utils/hexterm.rs @@ -0,0 +1,145 @@ +//! xterm-256 <-> RGB conversion, ported from utils/hexterm.py. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::OnceLock; + +include!("hexterm_table.rs"); + +type Rgb = [u8; 3]; + +static XTERM_RGB: OnceLock<[Rgb; 256]> = OnceLock::new(); + +thread_local! { + /// Scene and Animation both memoize this conversion upstream. Keeping the + /// memo here also covers callers outside the animation engine and lets all + /// scenes on the render thread share the same results. + static HEX_TO_XTERM_CACHE: RefCell> = RefCell::new(HashMap::new()); +} + +fn parse_rgb(hex_color: &str) -> Rgb { + let s = hex_color.trim_matches('#'); + [ + u8::from_str_radix(&s[0..2], 16).unwrap(), + u8::from_str_radix(&s[2..4], 16).unwrap(), + u8::from_str_radix(&s[4..6], 16).unwrap(), + ] +} + +/// Parse the generated palette once rather than reparsing all 768 channels on +/// every conversion. +fn xterm_rgb() -> &'static [Rgb; 256] { + XTERM_RGB.get_or_init(|| std::array::from_fn(|code| parse_rgb(XTERM_TO_HEX[code]))) +} + +fn closest_xterm([r, g, b]: Rgb) -> u8 { + let mut min_diff = u16::MAX; + let mut closest = 0u8; + for (code, &[xr, xg, xb]) in xterm_rgb().iter().enumerate() { + // Upstream divides this sum by three before comparing it. Division by + // the same positive constant is order-preserving, so comparing the + // integer sums retains its strict-first-minimum tie behavior exactly. + let diff = r.abs_diff(xr) as u16 + g.abs_diff(xg) as u16 + b.abs_diff(xb) as u16; + if diff < min_diff { + min_diff = diff; + closest = code as u8; + } + } + closest +} + +/// Closest xterm-256 code by mean absolute channel difference; linear scan over +/// codes 0..=255 in order, strict `<` so the first minimum wins (upstream +/// hexterm.py hex_to_xterm). +pub fn hex_to_xterm(hex_color: &str) -> u8 { + let rgb = parse_rgb(hex_color); + let key = u32::from_be_bytes([0, rgb[0], rgb[1], rgb[2]]); + HEX_TO_XTERM_CACHE.with(|cache| { + if let Some(cached) = cache.borrow().get(&key).copied() { + return cached; + } + let closest = closest_xterm(rgb); + cache.borrow_mut().insert(key, closest); + closest + }) +} + +/// xterm code -> hex string without leading '#'. +pub fn xterm_to_hex(xterm_color: u8) -> &'static str { + XTERM_TO_HEX[xterm_color as usize] +} + +/// Upstream is_valid_color for strings: 6 (or, faithfully, 7) hex digits with +/// optional leading '#'s. Integer codes are validated by range at the type level (u8). +pub fn is_valid_hex_color(color: &str) -> bool { + let stripped_len = color.trim_start_matches('#').len(); + if stripped_len != 6 && stripped_len != 7 { + return false; + } + i64::from_str_radix(color.trim_matches('#'), 16).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reference_hex_to_xterm(hex_color: &str) -> u8 { + let [r, g, b] = parse_rgb(hex_color).map(i64::from); + let mut min_diff = f64::INFINITY; + let mut closest = 0u8; + for (code, hex) in XTERM_TO_HEX.iter().enumerate() { + let [xr, xg, xb] = parse_rgb(hex).map(i64::from); + let diff = ((r - xr).abs() + (g - xg).abs() + (b - xb).abs()) as f64 / 3.0; + if diff < min_diff { + min_diff = diff; + closest = code as u8; + } + } + closest + } + + #[test] + fn table_spot_checks() { + // Golden values from the pinned reference + assert_eq!(xterm_to_hex(0), "000000"); + assert_eq!(xterm_to_hex(15), "ffffff"); + assert_eq!(xterm_to_hex(196), "ff0000"); + } + + #[test] + fn cached_conversion_matches_reference_across_rgb_space() { + // Include exact palette boundaries, neighboring values, and mixed-case + // spelling. This exercises ties as well as ordinary nearest colors. + let channels = [ + 0u8, 1, 14, 15, 16, 31, 47, 63, 79, 95, 127, 128, 159, 191, 223, 254, 255, + ]; + for &r in &channels { + for &g in &channels { + for &b in &channels { + let hex = format!("#{r:02X}{g:02x}{b:02X}"); + assert_eq!(hex_to_xterm(&hex), reference_hex_to_xterm(&hex), "{hex}"); + } + } + } + } + + #[test] + fn repeated_and_equivalent_spellings_share_the_exact_result() { + let expected = reference_hex_to_xterm("ff00aa"); + assert_eq!(hex_to_xterm("ff00aa"), expected); + assert_eq!(hex_to_xterm("#FF00AA"), expected); + // Seven digits are accepted elsewhere and the existing converter uses + // its first six, a compatibility quirk worth keeping explicit. + assert_eq!(hex_to_xterm("ff00aa7"), expected); + } + + #[test] + fn valid_hex() { + assert!(is_valid_hex_color("#ff00aa")); + assert!(is_valid_hex_color("ff00aa")); + assert!(!is_valid_hex_color("ff00a")); + assert!(!is_valid_hex_color("gg00aa")); + // Upstream quirk: 7 hex digits pass validation + assert!(is_valid_hex_color("1234567")); + } +} diff --git a/vendor/ttfx/src/utils/hexterm_table.rs b/vendor/ttfx/src/utils/hexterm_table.rs new file mode 100644 index 0000000..b7d9b95 --- /dev/null +++ b/vendor/ttfx/src/utils/hexterm_table.rs @@ -0,0 +1,263 @@ +// xterm-256 <-> RGB mapping, ported from utils/hexterm.py. +// The table below is GENERATED from the pinned reference (see tools/gen_hexterm.py note) +// — do not hand-edit. + +/// xterm code -> "rrggbb" (no '#'), exactly upstream's xterm_to_hex_map. +pub const XTERM_TO_HEX: [&str; 256] = [ + "000000", // 0 + "800000", // 1 + "008000", // 2 + "808000", // 3 + "000080", // 4 + "800080", // 5 + "008080", // 6 + "c0c0c0", // 7 + "808080", // 8 + "ff0000", // 9 + "00ff00", // 10 + "ffff00", // 11 + "0000ff", // 12 + "ff00ff", // 13 + "00ffff", // 14 + "ffffff", // 15 + "000000", // 16 + "00005f", // 17 + "000087", // 18 + "0000af", // 19 + "0000d7", // 20 + "0000ff", // 21 + "005f00", // 22 + "005f5f", // 23 + "005f87", // 24 + "005faf", // 25 + "005fd7", // 26 + "005fff", // 27 + "008700", // 28 + "00875f", // 29 + "008787", // 30 + "0087af", // 31 + "0087d7", // 32 + "0087ff", // 33 + "00af00", // 34 + "00af5f", // 35 + "00af87", // 36 + "00afaf", // 37 + "00afd7", // 38 + "00afff", // 39 + "00d700", // 40 + "00d75f", // 41 + "00d787", // 42 + "00d7af", // 43 + "00d7d7", // 44 + "00d7ff", // 45 + "00ff00", // 46 + "00ff5f", // 47 + "00ff87", // 48 + "00ffaf", // 49 + "00ffd7", // 50 + "00ffff", // 51 + "5f0000", // 52 + "5f005f", // 53 + "5f0087", // 54 + "5f00af", // 55 + "5f00d7", // 56 + "5f00ff", // 57 + "5f5f00", // 58 + "5f5f5f", // 59 + "5f5f87", // 60 + "5f5faf", // 61 + "5f5fd7", // 62 + "5f5fff", // 63 + "5f8700", // 64 + "5f875f", // 65 + "5f8787", // 66 + "5f87af", // 67 + "5f87d7", // 68 + "5f87ff", // 69 + "5faf00", // 70 + "5faf5f", // 71 + "5faf87", // 72 + "5fafaf", // 73 + "5fafd7", // 74 + "5fafff", // 75 + "5fd700", // 76 + "5fd75f", // 77 + "5fd787", // 78 + "5fd7af", // 79 + "5fd7d7", // 80 + "5fd7ff", // 81 + "5fff00", // 82 + "5fff5f", // 83 + "5fff87", // 84 + "5fffaf", // 85 + "5fffd7", // 86 + "5fffff", // 87 + "870000", // 88 + "87005f", // 89 + "870087", // 90 + "8700af", // 91 + "8700d7", // 92 + "8700ff", // 93 + "875f00", // 94 + "875f5f", // 95 + "875f87", // 96 + "875faf", // 97 + "875fd7", // 98 + "875fff", // 99 + "878700", // 100 + "87875f", // 101 + "878787", // 102 + "8787af", // 103 + "8787d7", // 104 + "8787ff", // 105 + "87af00", // 106 + "87af5f", // 107 + "87af87", // 108 + "87afaf", // 109 + "87afd7", // 110 + "87afff", // 111 + "87d700", // 112 + "87d75f", // 113 + "87d787", // 114 + "87d7af", // 115 + "87d7d7", // 116 + "87d7ff", // 117 + "87ff00", // 118 + "87ff5f", // 119 + "87ff87", // 120 + "87ffaf", // 121 + "87ffd7", // 122 + "87ffff", // 123 + "af0000", // 124 + "af005f", // 125 + "af0087", // 126 + "af00af", // 127 + "af00d7", // 128 + "af00ff", // 129 + "af5f00", // 130 + "af5f5f", // 131 + "af5f87", // 132 + "af5faf", // 133 + "af5fd7", // 134 + "af5fff", // 135 + "af8700", // 136 + "af875f", // 137 + "af8787", // 138 + "af87af", // 139 + "af87d7", // 140 + "af87ff", // 141 + "afaf00", // 142 + "afaf5f", // 143 + "afaf87", // 144 + "afafaf", // 145 + "afafd7", // 146 + "afafff", // 147 + "afd700", // 148 + "afd75f", // 149 + "afd787", // 150 + "afd7af", // 151 + "afd7d7", // 152 + "afd7ff", // 153 + "afff00", // 154 + "afff5f", // 155 + "afff87", // 156 + "afffaf", // 157 + "afffd7", // 158 + "afffff", // 159 + "d70000", // 160 + "d7005f", // 161 + "d70087", // 162 + "d700af", // 163 + "d700d7", // 164 + "d700ff", // 165 + "d75f00", // 166 + "d75f5f", // 167 + "d75f87", // 168 + "d75faf", // 169 + "d75fd7", // 170 + "d75fff", // 171 + "d78700", // 172 + "d7875f", // 173 + "d78787", // 174 + "d787af", // 175 + "d787d7", // 176 + "d787ff", // 177 + "d7af00", // 178 + "d7af5f", // 179 + "d7af87", // 180 + "d7afaf", // 181 + "d7afd7", // 182 + "d7afff", // 183 + "d7d700", // 184 + "d7d75f", // 185 + "d7d787", // 186 + "d7d7af", // 187 + "d7d7d7", // 188 + "d7d7ff", // 189 + "d7ff00", // 190 + "d7ff5f", // 191 + "d7ff87", // 192 + "d7ffaf", // 193 + "d7ffd7", // 194 + "d7ffff", // 195 + "ff0000", // 196 + "ff005f", // 197 + "ff0087", // 198 + "ff00af", // 199 + "ff00d7", // 200 + "ff00ff", // 201 + "ff5f00", // 202 + "ff5f5f", // 203 + "ff5f87", // 204 + "ff5faf", // 205 + "ff5fd7", // 206 + "ff5fff", // 207 + "ff8700", // 208 + "ff875f", // 209 + "ff8787", // 210 + "ff87af", // 211 + "ff87d7", // 212 + "ff87ff", // 213 + "ffaf00", // 214 + "ffaf5f", // 215 + "ffaf87", // 216 + "ffafaf", // 217 + "ffafd7", // 218 + "ffafff", // 219 + "ffd700", // 220 + "ffd75f", // 221 + "ffd787", // 222 + "ffd7af", // 223 + "ffd7d7", // 224 + "ffd7ff", // 225 + "ffff00", // 226 + "ffff5f", // 227 + "ffff87", // 228 + "ffffaf", // 229 + "ffffd7", // 230 + "ffffff", // 231 + "080808", // 232 + "121212", // 233 + "1c1c1c", // 234 + "262626", // 235 + "303030", // 236 + "3a3a3a", // 237 + "444444", // 238 + "4e4e4e", // 239 + "585858", // 240 + "626262", // 241 + "6c6c6c", // 242 + "767676", // 243 + "808080", // 244 + "8a8a8a", // 245 + "949494", // 246 + "9e9e9e", // 247 + "a8a8a8", // 248 + "b2b2b2", // 249 + "bcbcbc", // 250 + "c6c6c6", // 251 + "d0d0d0", // 252 + "dadada", // 253 + "e4e4e4", // 254 + "eeeeee", // 255 +]; diff --git a/vendor/ttfx/src/utils/mod.rs b/vendor/ttfx/src/utils/mod.rs new file mode 100644 index 0000000..8f19c12 --- /dev/null +++ b/vendor/ttfx/src/utils/mod.rs @@ -0,0 +1,9 @@ +pub mod easing; +pub mod ansi; +pub mod geometry; +pub mod graphics; +pub mod hexterm; +pub mod pycompat; +pub mod ordered_map; +pub mod rng; +pub mod spanning_tree; diff --git a/vendor/ttfx/src/utils/ordered_map.rs b/vendor/ttfx/src/utils/ordered_map.rs new file mode 100644 index 0000000..4eb1121 --- /dev/null +++ b/vendor/ttfx/src/utils/ordered_map.rs @@ -0,0 +1,191 @@ +//! Insertion-ordered string-keyed map with Python-dict iteration semantics. +//! Used everywhere upstream iterates dict values (plan.md §4.3): motion.paths, +//! animation.scenes, effect-level dicts. Small maps use a compact linear scan; +//! larger maps add a lookup index while retaining the canonical entry order. + +use std::cell::Cell; +use std::collections::HashMap; +use std::rc::Rc; + +const INDEX_THRESHOLD: usize = 8; +const NO_CACHED_LOOKUP: usize = usize::MAX; + +/// Keys are shared so that long-lived handles (Motion::active_path, +/// Animation::active_scene) can hold the map's own key allocation; lookups +/// then settle on a pointer compare instead of a memcmp. +#[derive(Debug, Clone)] +pub struct OrderedMap { + entries: Vec<(Rc, V)>, + index: Option, usize>>>, + last_lookup: Cell, +} + +/// Same string, same allocation — true only for keys handed out by this map. +#[inline] +fn same_allocation(entry: &str, key: &str) -> bool { + std::ptr::eq(entry.as_ptr(), key.as_ptr()) && entry.len() == key.len() +} + +impl OrderedMap { + pub fn new() -> Self { + OrderedMap { entries: Vec::new(), index: None, last_lookup: Cell::new(NO_CACHED_LOOKUP) } + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn contains_key(&self, key: &str) -> bool { + self.position(key).is_some() + } + + /// Python dict semantics: overwriting an existing key keeps its position. + pub fn insert(&mut self, key: impl Into>, value: V) { + let key: Rc = key.into(); + if let Some(position) = self.position(&key) { + self.entries[position].1 = value; + return; + } + let position = self.entries.len(); + if let Some(index) = &mut self.index { + index.insert(key.clone(), position); + } + self.entries.push((key, value)); + self.last_lookup.set(position); + if self.entries.len() == INDEX_THRESHOLD { + let index = self + .entries + .iter() + .enumerate() + .map(|(position, (key, _))| (key.clone(), position)) + .collect(); + self.index = Some(Box::new(index)); + } + } + + pub fn get(&self, key: &str) -> Option<&V> { + self.position(key).map(|position| &self.entries[position].1) + } + + pub fn get_mut(&mut self, key: &str) -> Option<&mut V> { + let position = self.position(key)?; + Some(&mut self.entries[position].1) + } + + pub fn keys(&self) -> impl Iterator> { + self.entries.iter().map(|(k, _)| k) + } + + /// The map's own handle for `key`, for callers that want later lookups to + /// hit the pointer fast path. + pub fn shared_key(&self, key: &str) -> Option> { + self.position(key).map(|position| Rc::clone(&self.entries[position].0)) + } + + /// Entry slot for `key`, for callers that read the same entry several times + /// in a row. Slots stay valid until an entry is removed. + pub fn slot(&self, key: &str) -> Option { + self.position(key) + } + + pub fn at(&self, slot: usize) -> &V { + &self.entries[slot].1 + } + + pub fn at_mut(&mut self, slot: usize) -> &mut V { + &mut self.entries[slot].1 + } + + pub fn key_at(&self, slot: usize) -> &Rc { + &self.entries[slot].0 + } + + pub fn values(&self) -> impl Iterator { + self.entries.iter().map(|(_, v)| v) + } + + pub fn values_mut(&mut self) -> impl Iterator { + self.entries.iter_mut().map(|(_, v)| v) + } + + pub fn iter(&self) -> impl Iterator, &V)> { + self.entries.iter().map(|(k, v)| (k, v)) + } + + /// Python dict.pop(key, None): removes the entry, preserving the order of + /// the remaining entries. + pub fn remove(&mut self, key: &str) -> Option { + let pos = self.position(key)?; + if let Some(index) = &mut self.index { + index.remove(key); + for indexed_position in index.values_mut() { + if *indexed_position > pos { + *indexed_position -= 1; + } + } + } + self.last_lookup.set(NO_CACHED_LOOKUP); + Some(self.entries.remove(pos).1) + } + + pub fn clear(&mut self) { + self.entries.clear(); + self.last_lookup.set(NO_CACHED_LOOKUP); + if let Some(index) = &mut self.index { + index.clear(); + } + } + + fn position(&self, key: &str) -> Option { + let cached = self.last_lookup.get(); + if cached < self.entries.len() { + let entry_key = &self.entries[cached].0; + if same_allocation(entry_key, key) || **entry_key == *key { + return Some(cached); + } + } + let position = match &self.index { + Some(index) => index.get(key).copied(), + None => self + .entries + .iter() + .position(|(entry_key, _)| same_allocation(entry_key, key) || **entry_key == *key), + }; + self.last_lookup.set(position.unwrap_or(NO_CACHED_LOOKUP)); + position + } +} + +impl Default for OrderedMap { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn indexed_maps_preserve_order_and_lookup_after_mutation() { + let mut map = OrderedMap::new(); + for value in 0..12 { + map.insert(value.to_string(), value); + } + map.insert("3".to_string(), 30); + assert_eq!(map.get("3"), Some(&30)); + assert_eq!(map.keys().map(|key| &**key).collect::>()[..5], ["0", "1", "2", "3", "4"]); + + assert_eq!(map.remove("4"), Some(4)); + assert_eq!(map.get("5"), Some(&5)); + assert!(!map.contains_key("4")); + + map.clear(); + map.insert("fresh".to_string(), 99); + assert_eq!(map.get("fresh"), Some(&99)); + } +} diff --git a/vendor/ttfx/src/utils/pycompat.rs b/vendor/ttfx/src/utils/pycompat.rs new file mode 100644 index 0000000..86f4f6c --- /dev/null +++ b/vendor/ttfx/src/utils/pycompat.rs @@ -0,0 +1,78 @@ +//! Helpers reproducing Python semantics where they differ from Rust defaults. +//! Every call site that transcribes a Python `round()` or `//` must go through +//! these — see plan.md §5. + +/// Python's built-in `round()`: banker's rounding (half-to-even), returning i64. +/// Rust's `f64::round` is half-away-from-zero, which differs at exact .5 values. +pub fn round_half_even(x: f64) -> i64 { + let floor = x.floor(); + let diff = x - floor; + if diff > 0.5 { + floor as i64 + 1 + } else if diff < 0.5 { + floor as i64 + } else { + // exactly .5 — round to even + let f = floor as i64; + if f % 2 == 0 { + f + } else { + f + 1 + } + } +} + +/// Python's `//` on integers: floor division. Rust's `/` truncates toward zero +/// and `div_euclid` rounds toward a non-negative remainder — both differ from +/// floor when signs are involved (7 // -2 == -4 in Python). +pub fn floor_div(a: i64, b: i64) -> i64 { + let q = a / b; + if a % b != 0 && (a < 0) != (b < 0) { + q - 1 + } else { + q + } +} + +/// Python's `%` on integers: result takes the sign of the divisor. +pub fn py_mod(a: i64, b: i64) -> i64 { + let r = a % b; + if r != 0 && (r < 0) != (b < 0) { + r + b + } else { + r + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_matches_python() { + // Golden values from CPython: round(0.5)=0, round(1.5)=2, round(2.5)=2, + // round(-0.5)=0, round(-1.5)=-2, round(0.4999)=0, round(1.4999)=1 + assert_eq!(round_half_even(0.5), 0); + assert_eq!(round_half_even(1.5), 2); + assert_eq!(round_half_even(2.5), 2); + assert_eq!(round_half_even(3.5), 4); + assert_eq!(round_half_even(-0.5), 0); + assert_eq!(round_half_even(-1.5), -2); + assert_eq!(round_half_even(-2.5), -2); + assert_eq!(round_half_even(0.4999), 0); + assert_eq!(round_half_even(1.4999), 1); + assert_eq!(round_half_even(2.6), 3); + assert_eq!(round_half_even(-2.6), -3); + // Values that aren't exactly representable don't hit the .5 branch: + // round(2.675) == 3 in Python (2.675 is actually 2.67499999...) + assert_eq!(round_half_even(2.675), 3); + } + + #[test] + fn floor_div_matches_python() { + assert_eq!(floor_div(7, 2), 3); + assert_eq!(floor_div(-7, 2), -4); + assert_eq!(floor_div(7, -2), -4); + assert_eq!(floor_div(-7, -2), 3); + } +} diff --git a/vendor/ttfx/src/utils/rng.rs b/vendor/ttfx/src/utils/rng.rs new file mode 100644 index 0000000..0b88ae5 --- /dev/null +++ b/vendor/ttfx/src/utils/rng.rs @@ -0,0 +1,133 @@ +//! Engine RNG: xoshiro256++ with Python-`random`-shaped helpers. +//! +//! The helper semantics here are the parity contract: tools/parity/shim.py +//! implements the exact same algorithms in Python and monkeypatches the +//! `random` module with them, so both implementations draw identical +//! sequences given the same seed (plan.md §7). Do not change any helper's +//! algorithm without updating the shim in lockstep. + +pub struct Rng { + s: [u64; 4], +} + +impl Rng { + pub fn seeded(seed: u64) -> Self { + // SplitMix64 expansion of the seed into the xoshiro state, the + // reference-recommended initialization. + let mut sm = seed; + let mut next = || { + sm = sm.wrapping_add(0x9E3779B97F4A7C15); + let mut z = sm; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + z ^ (z >> 31) + }; + Rng { s: [next(), next(), next(), next()] } + } + + pub fn from_entropy() -> Self { + let mut buf = [0u8; 8]; + // /dev/urandom is always present on the Unix targets we support + // (Linux and macOS). + use std::io::Read; + std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut buf)) + .expect("failed to read /dev/urandom"); + Rng::seeded(u64::from_le_bytes(buf)) + } + + /// Core generator: xoshiro256++ next(). + fn next_u64(&mut self) -> u64 { + let result = self.s[0].wrapping_add(self.s[3]).rotate_left(23).wrapping_add(self.s[0]); + let t = self.s[1] << 17; + self.s[2] ^= self.s[0]; + self.s[3] ^= self.s[1]; + self.s[1] ^= self.s[2]; + self.s[0] ^= self.s[3]; + self.s[2] ^= t; + self.s[3] = self.s[3].rotate_left(45); + result + } + + /// Python random.random() shape: float in [0, 1) with 53 bits of precision. + pub fn random(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64) + } + + /// Uniform integer in [0, n) via Lemire-free simple rejection on bit masks — + /// deterministic and trivially portable to the Python shim. + fn randbelow(&mut self, n: u64) -> u64 { + assert!(n > 0, "randbelow(0)"); + let bits = 64 - (n - 1).leading_zeros(); + loop { + let r = self.next_u64() >> (64 - bits.max(1)); + if r < n { + return r; + } + } + } + + /// random.randint(a, b): inclusive both ends. + pub fn randint(&mut self, a: i64, b: i64) -> i64 { + assert!(a <= b, "randint range empty: {a}..={b}"); + a + self.randbelow((b - a + 1) as u64) as i64 + } + + /// random.randrange(a, b): half-open. + pub fn randrange(&mut self, a: i64, b: i64) -> i64 { + assert!(a < b, "randrange range empty: {a}..{b}"); + a + self.randbelow((b - a) as u64) as i64 + } + + /// random.choice(seq): seq[randbelow(len)]. + pub fn choice<'a, T>(&mut self, seq: &'a [T]) -> &'a T { + assert!(!seq.is_empty(), "choice on empty sequence"); + &seq[self.randbelow(seq.len() as u64) as usize] + } + + /// random.choice by index, for callers that need an owned element. + pub fn choice_index(&mut self, len: usize) -> usize { + assert!(len > 0, "choice on empty sequence"); + self.randbelow(len as u64) as usize + } + + /// random.uniform(a, b): a + (b-a) * random(). + pub fn uniform(&mut self, a: f64, b: f64) -> f64 { + a + (b - a) * self.random() + } + + /// random.shuffle: Fisher-Yates from the top, exactly CPython's loop + /// (for i in reversed(range(1, len(x))): j = randbelow(i+1); swap). + pub fn shuffle(&mut self, seq: &mut [T]) { + for i in (1..seq.len()).rev() { + let j = self.randbelow((i + 1) as u64) as usize; + seq.swap(i, j); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_for_seed() { + let mut a = Rng::seeded(42); + let mut b = Rng::seeded(42); + for _ in 0..100 { + assert_eq!(a.next_u64(), b.next_u64()); + } + } + + #[test] + fn ranges_respected() { + let mut r = Rng::seeded(7); + for _ in 0..1000 { + let v = r.randint(-3, 3); + assert!((-3..=3).contains(&v)); + let u = r.uniform(1.0, 2.0); + assert!((1.0..2.0).contains(&u) || u == 2.0); + assert!(r.random() < 1.0); + } + } +} diff --git a/vendor/ttfx/src/utils/spanning_tree.rs b/vendor/ttfx/src/utils/spanning_tree.rs new file mode 100644 index 0000000..7d0b1d3 --- /dev/null +++ b/vendor/ttfx/src/utils/spanning_tree.rs @@ -0,0 +1,332 @@ +//! Spanning-tree generators, ported from utils/spanningtree/. AldousBroder is +//! deliberately not ported: no shipped effect uses it (plan.md §5 divergences). +//! +//! Canonical ordering note (plan.md §4.3): `EffectCharacter.links` is a Python +//! set; BreadthFirst iterates it. Our canonical order is ascending +//! character_id — links are kept sorted-by-id on insert, and the parity shim +//! patches the Python side to `sorted(links, key=character_id)`. + +use std::collections::BTreeMap; + +use crate::engine::character::CharId; +use crate::engine::ctx::EngineCtx; + +/// EffectCharacter._link: bidirectional set-add (id-sorted, see module note). +pub fn link_characters(ctx: &mut EngineCtx, a: CharId, b: CharId) { + let insert_sorted = |links: &mut Vec, id: CharId| { + if let Err(pos) = links.binary_search(&id) { + links.insert(pos, id); + } + }; + insert_sorted(&mut ctx.terminal.arena[a.0 as usize].links, b); + insert_sorted(&mut ctx.terminal.arena[b.0 as usize].links, a); +} + +/// SpanningTreeGenerator.get_neighbors: neighbors in dict order (north, east, +/// south, west), optional text-boundary and unlinked filters. +fn get_neighbors(ctx: &EngineCtx, id: CharId, unlinked_only: bool, limit_to_text_boundary: bool) -> Vec { + let ch = &ctx.terminal.arena[id.0 as usize]; + let mut neighbors: Vec = [ch.neighbors.north, ch.neighbors.east, ch.neighbors.south, ch.neighbors.west] + .into_iter() + .flatten() + .collect(); + if limit_to_text_boundary { + neighbors.retain(|&n| ctx.terminal.canvas.coord_is_in_text(ctx.terminal.arena[n.0 as usize].input_coord)); + } + if unlinked_only { + neighbors.retain(|&n| ctx.terminal.arena[n.0 as usize].links.is_empty()); + } + neighbors +} + +fn default_starting_char(ctx: &mut EngineCtx, within_text_boundary: bool) -> Result { + let coord = ctx.terminal.canvas.random_coord(&mut ctx.rng, false, within_text_boundary); + ctx.terminal + .get_character_by_input_coord(coord) + .ok_or_else(|| "Unable to find a starting character.".to_string()) +} + +/// algo/primssimple.py PrimsSimple. +pub struct PrimsSimple { + pub limit_to_text_boundary: bool, + current_char: CharId, + pub char_last_linked: Option, + pub char_link_order: Vec, + pub edge_chars: Vec, + pub edge_last_added: Option, + pub edge_last_popped: Option, + pub complete: bool, +} + +impl PrimsSimple { + pub fn new( + ctx: &mut EngineCtx, + starting_char: Option, + limit_to_text_boundary: bool, + ) -> Result { + let starting_char = match starting_char { + Some(c) => c, + None => default_starting_char(ctx, limit_to_text_boundary)?, + }; + Ok(PrimsSimple { + limit_to_text_boundary, + current_char: starting_char, + char_last_linked: Some(starting_char), + char_link_order: vec![starting_char], + edge_chars: vec![starting_char], + edge_last_added: Some(starting_char), + edge_last_popped: None, + complete: false, + }) + } + + /// Faithful quirk: `complete` flips only when edge_chars is already empty + /// at call entry. + pub fn step(&mut self, ctx: &mut EngineCtx) { + if !self.edge_chars.is_empty() { + let idx = ctx.rng.randrange(0, self.edge_chars.len() as i64) as usize; + self.current_char = self.edge_chars.remove(idx); + self.edge_last_popped = Some(self.current_char); + let mut unlinked_neighbors = get_neighbors(ctx, self.current_char, true, self.limit_to_text_boundary); + if !unlinked_neighbors.is_empty() { + let idx = ctx.rng.randrange(0, unlinked_neighbors.len() as i64) as usize; + let next_char = unlinked_neighbors.remove(idx); + link_characters(ctx, self.current_char, next_char); + self.char_link_order.push(next_char); + self.char_last_linked = Some(next_char); + if !unlinked_neighbors.is_empty() { + self.edge_chars.push(self.current_char); + } + let next_neighbors = get_neighbors(ctx, next_char, true, self.limit_to_text_boundary); + if !next_neighbors.is_empty() { + self.edge_chars.push(next_char); + self.edge_last_added = Some(next_char); + } + } + } else { + self.complete = true; + } + } +} + +/// algo/primsweighted.py WeightedLink. +#[derive(Debug, Clone, Copy)] +pub struct WeightedLink { + pub char_a: CharId, + pub char_b: CharId, + pub weight: i64, +} + +/// algo/primsweighted.py PrimsWeighted. +pub struct PrimsWeighted { + pub limit_to_text_boundary: bool, + char_weights: std::collections::HashMap, + pub char_last_linked: Option, + pub char_link_order: Vec, + pub neighbors_last_added: Vec, + pub complete: bool, + pending_weighted_links: BTreeMap>, +} + +impl PrimsWeighted { + pub fn new( + ctx: &mut EngineCtx, + starting_char: Option, + limit_to_text_boundary: bool, + ) -> Result { + let starting_char = match starting_char { + Some(c) => c, + None => default_starting_char(ctx, limit_to_text_boundary)?, + }; + // weights assigned over get_characters(inner+outer fill, default sort) + // — that ordering is the RNG consumption order, faithfully + let filter = crate::engine::terminal::CharacterFilter { + input_chars: true, + inner_fill_chars: true, + outer_fill_chars: true, + added_chars: false, + }; + let ordered = { + let terminal = &ctx.terminal; + let mut all = terminal.collect_characters(filter); + all.sort_by_key(|&id| { + let c = terminal.arena[id.0 as usize].input_coord; + (-c.row, c.column) + }); + all + }; + let mut char_weights = std::collections::HashMap::new(); + for id in ordered { + char_weights.insert(id, ctx.rng.randint(0, 99)); + } + let mut generator = PrimsWeighted { + limit_to_text_boundary, + char_weights, + char_last_linked: Some(starting_char), + char_link_order: vec![starting_char], + neighbors_last_added: Vec::new(), + complete: false, + pending_weighted_links: BTreeMap::new(), + }; + generator.add_weighted_links(ctx, starting_char); + Ok(generator) + } + + fn add_weighted_links(&mut self, ctx: &EngineCtx, char_id: CharId) { + self.neighbors_last_added.clear(); + for neighbor in get_neighbors(ctx, char_id, true, self.limit_to_text_boundary) { + self.neighbors_last_added.push(neighbor); + let weight = self.char_weights[&neighbor]; + self.pending_weighted_links + .entry(weight) + .or_default() + .push(WeightedLink { char_a: char_id, char_b: neighbor, weight }); + } + } + + fn get_lowest_weight_link(&mut self, ctx: &mut EngineCtx) -> Option { + while !self.pending_weighted_links.is_empty() { + let lowest_weight = *self.pending_weighted_links.keys().next().unwrap(); + let links_at_weight = self.pending_weighted_links.get_mut(&lowest_weight).unwrap(); + let idx = ctx.rng.randrange(0, links_at_weight.len() as i64) as usize; + let link = links_at_weight.remove(idx); + if links_at_weight.is_empty() { + self.pending_weighted_links.remove(&lowest_weight); + } + if ctx.terminal.arena[link.char_b.0 as usize].links.is_empty() { + return Some(link); + } + } + None + } + + pub fn step(&mut self, ctx: &mut EngineCtx) { + if !self.pending_weighted_links.is_empty() { + let Some(next_link) = self.get_lowest_weight_link(ctx) else { + self.complete = true; + return; + }; + link_characters(ctx, next_link.char_a, next_link.char_b); + self.char_last_linked = Some(next_link.char_b); + self.char_link_order.push(next_link.char_b); + self.add_weighted_links(ctx, next_link.char_b); + } else { + self.complete = true; + self.char_last_linked = None; + self.neighbors_last_added.clear(); + } + } +} + +/// algo/recursivebacktracker.py RecursiveBacktracker. +pub struct RecursiveBacktracker { + pub limit_to_text_boundary: bool, + current_char: CharId, + pub char_last_linked: Option, + pub char_link_order: Vec, + pub stack: Vec, + pub stack_last_popped: Option, + pub complete: bool, +} + +impl RecursiveBacktracker { + pub fn new( + ctx: &mut EngineCtx, + starting_char: Option, + limit_to_text_boundary: bool, + ) -> Result { + let starting_char = match starting_char { + Some(c) => c, + None => default_starting_char(ctx, limit_to_text_boundary)?, + }; + Ok(RecursiveBacktracker { + limit_to_text_boundary, + current_char: starting_char, + char_last_linked: Some(starting_char), + char_link_order: vec![starting_char], + stack: vec![starting_char], + stack_last_popped: None, + complete: false, + }) + } + + pub fn step(&mut self, ctx: &mut EngineCtx) { + self.char_last_linked = None; + self.stack_last_popped = None; + if !self.stack.is_empty() { + let unvisited = get_neighbors(ctx, self.current_char, true, self.limit_to_text_boundary); + if !unvisited.is_empty() { + let next_char = *ctx.rng.choice(&unvisited); + link_characters(ctx, self.current_char, next_char); + self.char_link_order.push(next_char); + self.char_last_linked = Some(next_char); + self.stack.push(next_char); + self.current_char = next_char; + } else { + self.stack_last_popped = self.stack.pop(); + if let Some(&top) = self.stack.last() { + self.current_char = top; + } + } + } else { + self.complete = true; + } + } +} + +/// algo/breadthfirst.py BreadthFirst: traverses the linked graph layer by +/// layer. No randomness of its own; `links` iteration is ascending id (the +/// canonical order; shim-matched on the Python side). +pub struct BreadthFirst { + pub starting_char: CharId, + frontier: Vec, + explored: std::collections::HashSet, + pub explored_last_step: Vec, + pub char_explore_order: Vec, + pub complete: bool, +} + +impl BreadthFirst { + pub fn new( + ctx: &mut EngineCtx, + starting_char: Option, + limit_to_text_boundary: bool, + ) -> Result { + let starting_char = match starting_char { + Some(c) => c, + None => default_starting_char(ctx, limit_to_text_boundary)?, + }; + Ok(BreadthFirst { + starting_char, + frontier: vec![starting_char], + explored: std::collections::HashSet::from([starting_char]), + explored_last_step: Vec::new(), + char_explore_order: Vec::new(), + complete: false, + }) + } + + pub fn step(&mut self, ctx: &EngineCtx) { + self.explored_last_step.clear(); + if self.frontier.is_empty() { + self.complete = true; + return; + } + let mut new_edges: Vec = Vec::new(); + while !self.frontier.is_empty() { + let position = self.frontier.remove(0); + let links = ctx.terminal.arena[position.0 as usize].links.clone(); + let position_new_edges: Vec = links + .into_iter() + .filter(|n| !self.explored.contains(n) && !self.frontier.contains(n) && !new_edges.contains(n)) + .collect(); + for &character in &position_new_edges { + self.explored.insert(character); + self.explored_last_step.push(character); + self.char_explore_order.push(character); + } + new_edges.extend(position_new_edges); + } + self.frontier = new_edges; + } +} diff --git a/vendor/ttfx/tests/easing_goldens.rs b/vendor/ttfx/tests/easing_goldens.rs new file mode 100644 index 0000000..0d5a01a --- /dev/null +++ b/vendor/ttfx/tests/easing_goldens.rs @@ -0,0 +1,85 @@ +//! Bit-exact comparison of every easing function against CPython-generated +//! goldens (tools/goldens/gen_easing.py). A mismatch prints the first diverging +//! sample with both bit patterns. + +use ttfx::utils::easing::Easing; + +const EASING_GOLDEN_ORDER: &[Easing] = &[ + Easing::Linear, + Easing::InSine, + Easing::OutSine, + Easing::InOutSine, + Easing::InQuad, + Easing::OutQuad, + Easing::InOutQuad, + Easing::InCubic, + Easing::OutCubic, + Easing::InOutCubic, + Easing::InQuart, + Easing::OutQuart, + Easing::InOutQuart, + Easing::InQuint, + Easing::OutQuint, + Easing::InOutQuint, + Easing::InExpo, + Easing::OutExpo, + Easing::InOutExpo, + Easing::InCirc, + Easing::OutCirc, + Easing::InOutCirc, + Easing::InBack, + Easing::OutBack, + Easing::InOutBack, + Easing::InElastic, + Easing::OutElastic, + Easing::InOutElastic, + Easing::InBounce, + Easing::OutBounce, + Easing::InOutBounce, + Easing::CubicBezier(0.25, 0.1, 0.25, 1.0), + Easing::CubicBezier(0.42, 0.0, 0.58, 1.0), + Easing::CubicBezier(0.68, -0.55, 0.265, 1.55), +]; + +#[test] +fn easing_matches_python_bit_exactly() { + let data = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/easing_goldens.bin")) + .expect("run tools/goldens/gen_easing.py first"); + assert_eq!(data.len(), EASING_GOLDEN_ORDER.len() * 1001 * 8); + let mut offset = 0; + let mut mismatches = 0; + for easing in EASING_GOLDEN_ORDER { + for i in 0..=1000 { + let expected = f64::from_le_bytes(data[offset..offset + 8].try_into().unwrap()); + offset += 8; + let p = i as f64 / 1000.0; + let actual = easing.ease(p); + // Bit-exactness is only promised on the pinned parity platform + // (Linux/glibc — plan.md §9 "pin the parity platform"). Elsewhere + // the system libm rounds sin/cos/pow a last-ulp differently + // (measured max 2.3e-16 absolute on macOS), so other platforms get + // the boundary-tolerant assertion instead. Quantized effect output + // absorbs this either way. + let within_tolerance = if cfg!(all(target_os = "linux", target_env = "gnu")) { + // CubicBezier tolerates 1 ulp even here: in optimized builds + // LLVM const-folds some powf calls, which can differ from + // runtime libm by an ulp. + let tolerance = if matches!(easing, Easing::CubicBezier(..)) { 1 } else { 0 }; + actual.to_bits().abs_diff(expected.to_bits()) <= tolerance + } else { + (actual - expected).abs() <= 1e-15 + }; + if !within_tolerance { + if mismatches < 5 { + eprintln!( + "{easing:?} at p={p}: expected {expected:?} ({:016x}), got {actual:?} ({:016x})", + expected.to_bits(), + actual.to_bits() + ); + } + mismatches += 1; + } + } + } + assert_eq!(mismatches, 0, "{mismatches} easing samples diverge from Python"); +} diff --git a/vendor/ttfx/tests/engine_traces.rs b/vendor/ttfx/tests/engine_traces.rs new file mode 100644 index 0000000..dc65c56 --- /dev/null +++ b/vendor/ttfx/tests/engine_traces.rs @@ -0,0 +1,301 @@ +//! Engine state-machine traces vs the reference implementation +//! (tools/goldens/gen_engine_traces.py). Each scenario replays identically and +//! the full event + state log must match line for line. + +use std::collections::BTreeSet; + +use ttfx::engine::animation::{SyncMetric, VisualParams}; +use ttfx::engine::character::CharId; +use ttfx::engine::ctx::{Clock, EngineCtx, NoopHooks}; +use ttfx::engine::events::{CallerKey, Event, EventAction}; +use ttfx::engine::terminal::{CharacterFilter, CharacterSort, TerminalConfig}; +use ttfx::utils::easing::Easing; +use ttfx::utils::geometry::Coord; +use ttfx::utils::graphics::{Color, ColorPair, Gradient}; +use ttfx::utils::rng::Rng; + +fn make_ctx() -> EngineCtx { + let config = TerminalConfig { + canvas_width: 20, + canvas_height: 10, + ignore_terminal_dimensions: true, + frame_rate: 0, + ..Default::default() + }; + let mut ctx = EngineCtx::new( + "abcdef\nghijkl", + config, + Rng::seeded(0), + Clock::virtual_with_frame_rate(60), + ) + .unwrap(); + ctx.event_log = Some(Vec::new()); + ctx +} + +fn chars(ctx: &mut EngineCtx, n: usize) -> Vec { + let filter = CharacterFilter::default(); + let mut rng = Rng::seeded(0); // sort is non-random; rng unused + ctx.terminal.get_characters(&mut rng, filter, CharacterSort::TopToBottomLeftToRight)[..n].to_vec() +} + +fn esc(s: &str) -> String { + s.replace('\x1b', "\\e") +} + +fn snapshot(ctx: &mut EngineCtx, log: &mut Vec, tick: i64, ids: &[CharId]) { + // event log entries accumulated inside ctx get flushed first + log.append(ctx.event_log.as_mut().unwrap()); + for &id in ids { + let ch = &ctx.terminal.arena[id.0 as usize]; + let ap = ch.motion.active_path.as_deref().unwrap_or("-"); + let sc = ch.animation.active_scene.as_deref().unwrap_or("-"); + let active = if ch.is_active() { "True" } else { "False" }; + log.push(format!( + "tick={tick} char={} coord={},{} layer={} path={ap} scene={sc} vis={} active={active}", + ch.character_id, + ch.motion.current_coord.column, + ch.motion.current_coord.row, + ch.layer, + esc(ch.animation.current_character_visual.formatted_symbol.as_str()), + )); + } +} + +fn run_ticks(ctx: &mut EngineCtx, log: &mut Vec, ids: &[CharId], n: i64, start: i64) { + let mut active: BTreeSet = ids.iter().copied().collect(); + for tick in start..start + n { + let snapshot_ids: Vec = active.iter().copied().collect(); + for id in snapshot_ids { + ctx.tick(&mut NoopHooks, id); + } + let inactive: Vec = + active.iter().copied().filter(|&id| !ctx.terminal.arena[id.0 as usize].is_active()).collect(); + for id in inactive { + active.remove(&id); + } + snapshot(ctx, log, tick, ids); + } +} + +fn flush(ctx: &mut EngineCtx, log: &mut Vec) { + log.append(ctx.event_log.as_mut().unwrap()); +} + +fn scenario_motion_basic(log: &mut Vec) { + log.push("=== scenario_motion_basic ===".into()); + let mut ctx = make_ctx(); + let ids = chars(&mut ctx, 2); + let (a, b) = (ids[0], ids[1]); + { + let motion = &mut ctx.terminal.arena[a.0 as usize].motion; + motion.new_path(0.7, None, None, 0, false, "pa").unwrap(); + let pa = motion.paths.get_mut("pa").unwrap(); + pa.new_waypoint(Coord::new(15, 8), None, "").unwrap(); + pa.new_waypoint(Coord::new(18, 2), Some(vec![Coord::new(1, 1)]), "").unwrap(); + let motion = &mut ctx.terminal.arena[b.0 as usize].motion; + motion.new_path(1.3, Some(Easing::OutBack), None, 0, false, "pb").unwrap(); + motion.paths.get_mut("pb").unwrap().new_waypoint(Coord::new(3, 9), None, "").unwrap(); + } + ctx.activate_path(&mut NoopHooks, a, "pa"); + ctx.activate_path(&mut NoopHooks, b, "pb"); + run_ticks(&mut ctx, log, &ids, 30, 0); + flush(&mut ctx, log); +} + +fn scenario_hold_and_loop(log: &mut Vec) { + log.push("=== scenario_hold_and_loop ===".into()); + let mut ctx = make_ctx(); + let ids = chars(&mut ctx, 2); + let (a, b) = (ids[0], ids[1]); + { + let motion = &mut ctx.terminal.arena[a.0 as usize].motion; + motion.new_path(2.0, None, None, 3, false, "hold").unwrap(); + motion.paths.get_mut("hold").unwrap().new_waypoint(Coord::new(10, 5), None, "").unwrap(); + let motion = &mut ctx.terminal.arena[b.0 as usize].motion; + motion.new_path(2.0, None, None, 0, true, "looper").unwrap(); + let pb = motion.paths.get_mut("looper").unwrap(); + pb.new_waypoint(Coord::new(6, 3), None, "").unwrap(); + pb.new_waypoint(Coord::new(9, 6), None, "").unwrap(); + } + ctx.activate_path(&mut NoopHooks, a, "hold"); + ctx.activate_path(&mut NoopHooks, b, "looper"); + run_ticks(&mut ctx, log, &ids, 20, 0); + flush(&mut ctx, log); +} + +fn scenario_chained_paths_and_events(log: &mut Vec) { + log.push("=== scenario_chained_paths_and_events ===".into()); + let mut ctx = make_ctx(); + let ids = chars(&mut ctx, 1); + let a = ids[0]; + { + let motion = &mut ctx.terminal.arena[a.0 as usize].motion; + motion.new_path(1.5, None, None, 0, false, "p1").unwrap(); + motion.paths.get_mut("p1").unwrap().new_waypoint(Coord::new(5, 5), None, "").unwrap(); + motion.new_path(1.5, None, Some(2), 0, false, "p2").unwrap(); + motion.paths.get_mut("p2").unwrap().new_waypoint(Coord::new(10, 2), None, "").unwrap(); + motion.new_path(1.5, None, None, 0, false, "p3").unwrap(); + motion.paths.get_mut("p3").unwrap().new_waypoint(Coord::new(1, 1), None, "").unwrap(); + } + ctx.chain_paths(a, &["p1".into(), "p2".into(), "p3".into()], false).unwrap(); + ctx.register_event( + a, + Event::PathComplete, + CallerKey::Path("p3".into()), + EventAction::SetCoordinate(Coord::new(19, 9)), + ) + .unwrap(); + ctx.register_event(a, Event::PathHolding, CallerKey::Path("p1".into()), EventAction::SetLayer(7)).unwrap(); + ctx.activate_path(&mut NoopHooks, a, "p1"); + run_ticks(&mut ctx, log, &ids, 25, 0); + flush(&mut ctx, log); +} + +fn scenario_scenes(log: &mut Vec) { + log.push("=== scenario_scenes ===".into()); + let mut ctx = make_ctx(); + let ids = chars(&mut ctx, 3); + let (a, b, c) = (ids[0], ids[1], ids[2]); + { + let ch = &mut ctx.terminal.arena[a.0 as usize]; + let uses = ch.uses_input_preexisting_colors; + ch.animation.new_scene(false, None, None, "plain", uses); + let scene = ch.animation.scenes.get_mut("plain").unwrap(); + scene + .add_frame( + "X", + 2, + VisualParams { + colors: Some(ColorPair::new(Some(Color::from_hex("ff0000").unwrap()), None)), + ..Default::default() + }, + ) + .unwrap(); + scene + .add_frame( + "Y", + 3, + VisualParams { + colors: Some(ColorPair::new( + Some(Color::from_hex("00ff00").unwrap()), + Some(Color::from_xterm(21)), + )), + ..Default::default() + }, + ) + .unwrap(); + scene.add_frame("Z", 1, VisualParams { bold: true, ..Default::default() }).unwrap(); + } + ctx.activate_scene(&mut NoopHooks, a, "plain"); + { + let ch = &mut ctx.terminal.arena[b.0 as usize]; + let uses = ch.uses_input_preexisting_colors; + ch.animation.new_scene(true, None, None, "looping", uses); + let scene = ch.animation.scenes.get_mut("looping").unwrap(); + scene.add_frame("1", 2, VisualParams::default()).unwrap(); + scene.add_frame("2", 2, VisualParams::default()).unwrap(); + } + ctx.activate_scene(&mut NoopHooks, b, "looping"); + { + let ch = &mut ctx.terminal.arena[c.0 as usize]; + let uses = ch.uses_input_preexisting_colors; + ch.animation.new_scene(false, None, Some(Easing::InOutCubic), "eased", uses); + let grad = Gradient::with_steps( + &[Color::from_hex("000000").unwrap(), Color::from_hex("ffffff").unwrap()], + 8, + false, + ) + .unwrap(); + let scene = ch.animation.scenes.get_mut("eased").unwrap(); + scene + .apply_gradient_to_symbols(&["*".into(), "+".into(), "o".into()], 2, Some(&grad), None) + .unwrap(); + } + ctx.activate_scene(&mut NoopHooks, c, "eased"); + run_ticks(&mut ctx, log, &ids, 24, 0); + flush(&mut ctx, log); +} + +fn scenario_synced_scene(log: &mut Vec) { + log.push("=== scenario_synced_scene ===".into()); + let mut ctx = make_ctx(); + let ids = chars(&mut ctx, 2); + for (idx, (sync, pid)) in [(SyncMetric::Step, "sp"), (SyncMetric::Distance, "dp")].iter().enumerate() { + let id = ids[idx]; + let scene_id = format!("sync_{pid}"); + { + let ch = &mut ctx.terminal.arena[id.0 as usize]; + ch.motion.new_path(0.9, None, None, 0, false, pid).unwrap(); + let path = ch.motion.paths.get_mut(pid).unwrap(); + path.new_waypoint(Coord::new(16, 9), None, "").unwrap(); + path.new_waypoint(Coord::new(2, 2), None, "").unwrap(); + let uses = ch.uses_input_preexisting_colors; + ch.animation.new_scene(false, Some(*sync), None, &scene_id, uses); + let scene = ch.animation.scenes.get_mut(&scene_id).unwrap(); + for sym in ["a", "b", "c", "d", "e", "f", "g", "h"] { + scene.add_frame(sym, 1, VisualParams::default()).unwrap(); + } + } + ctx.activate_path(&mut NoopHooks, id, pid); + ctx.activate_scene(&mut NoopHooks, id, &scene_id); + } + run_ticks(&mut ctx, log, &ids, 30, 0); + flush(&mut ctx, log); +} + +fn scenario_scene_events_and_resume(log: &mut Vec) { + log.push("=== scenario_scene_events_and_resume ===".into()); + let mut ctx = make_ctx(); + let ids = chars(&mut ctx, 1); + let a = ids[0]; + { + let ch = &mut ctx.terminal.arena[a.0 as usize]; + let uses = ch.uses_input_preexisting_colors; + ch.animation.new_scene(false, None, None, "s1", uses); + let s1 = ch.animation.scenes.get_mut("s1").unwrap(); + s1.add_frame("A", 3, VisualParams::default()).unwrap(); + s1.add_frame("B", 3, VisualParams::default()).unwrap(); + ch.animation.new_scene(false, None, None, "s2", uses); + ch.animation.scenes.get_mut("s2").unwrap().add_frame("C", 2, VisualParams::default()).unwrap(); + ch.motion.new_path(1.0, None, None, 0, false, "mover").unwrap(); + ch.motion.paths.get_mut("mover").unwrap().new_waypoint(Coord::new(8, 8), None, "").unwrap(); + } + ctx.register_event(a, Event::SceneComplete, CallerKey::Scene("s1".into()), EventAction::ActivateScene("s2".into())) + .unwrap(); + ctx.register_event(a, Event::SceneComplete, CallerKey::Scene("s2".into()), EventAction::ActivatePath("mover".into())) + .unwrap(); + ctx.activate_scene(&mut NoopHooks, a, "s1"); + run_ticks(&mut ctx, log, &ids, 2, 0); + ctx.activate_scene(&mut NoopHooks, a, "s1"); + run_ticks(&mut ctx, log, &ids, 20, 2); + flush(&mut ctx, log); +} + +#[test] +fn engine_traces_match_python() { + let fixture = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/engine_traces.txt")) + .expect("run tools/goldens/gen_engine_traces.py first"); + let expected: Vec<&str> = fixture.lines().collect(); + + let mut log: Vec = Vec::new(); + scenario_motion_basic(&mut log); + scenario_hold_and_loop(&mut log); + scenario_chained_paths_and_events(&mut log); + scenario_scenes(&mut log); + scenario_synced_scene(&mut log); + scenario_scene_events_and_resume(&mut log); + + let mut mismatches = 0; + for i in 0..expected.len().max(log.len()) { + let e = expected.get(i).copied().unwrap_or(""); + let a = log.get(i).map(String::as_str).unwrap_or(""); + if e != a { + if mismatches < 8 { + eprintln!("line {i}:\n expected: {e}\n actual: {a}"); + } + mismatches += 1; + } + } + assert_eq!(mismatches, 0, "{mismatches} trace lines diverge (of {})", expected.len()); +} diff --git a/vendor/ttfx/tests/fixtures/easing_goldens.bin b/vendor/ttfx/tests/fixtures/easing_goldens.bin new file mode 100644 index 0000000..71ea2f8 Binary files /dev/null and b/vendor/ttfx/tests/fixtures/easing_goldens.bin differ diff --git a/vendor/ttfx/tests/fixtures/engine_traces.txt b/vendor/ttfx/tests/fixtures/engine_traces.txt new file mode 100644 index 0000000..225eeed --- /dev/null +++ b/vendor/ttfx/tests/fixtures/engine_traces.txt @@ -0,0 +1,343 @@ +=== scenario_motion_basic === +EVENT char=0 PATH_ACTIVATED caller=path:pa +EVENT char=1 PATH_ACTIVATED caller=path:pb +EVENT char=0 SEGMENT_ENTERED caller=wp:0 +EVENT char=1 SEGMENT_ENTERED caller=wp:0 +tick=0 char=0 coord=2,2 layer=0 path=pa scene=- vis=a active=True +tick=0 char=1 coord=2,5 layer=0 path=pb scene=- vis=b active=True +tick=1 char=0 coord=2,2 layer=0 path=pa scene=- vis=a active=True +tick=1 char=1 coord=3,7 layer=0 path=pb scene=- vis=b active=True +tick=2 char=0 coord=3,3 layer=0 path=pa scene=- vis=a active=True +tick=2 char=1 coord=3,8 layer=0 path=pb scene=- vis=b active=True +tick=3 char=0 coord=3,3 layer=0 path=pa scene=- vis=a active=True +tick=3 char=1 coord=3,9 layer=0 path=pb scene=- vis=b active=True +EVENT char=1 SEGMENT_EXITED caller=wp:0 +tick=4 char=0 coord=4,3 layer=0 path=pa scene=- vis=a active=True +tick=4 char=1 coord=3,9 layer=0 path=pb scene=- vis=b active=True +tick=5 char=0 coord=4,3 layer=0 path=pa scene=- vis=a active=True +tick=5 char=1 coord=3,10 layer=0 path=pb scene=- vis=b active=True +tick=6 char=0 coord=5,4 layer=0 path=pa scene=- vis=a active=True +tick=6 char=1 coord=3,10 layer=0 path=pb scene=- vis=b active=True +tick=7 char=0 coord=5,4 layer=0 path=pa scene=- vis=a active=True +tick=7 char=1 coord=3,10 layer=0 path=pb scene=- vis=b active=True +tick=8 char=0 coord=6,4 layer=0 path=pa scene=- vis=a active=True +tick=8 char=1 coord=3,9 layer=0 path=pb scene=- vis=b active=True +tick=9 char=0 coord=6,4 layer=0 path=pa scene=- vis=a active=True +tick=9 char=1 coord=3,9 layer=0 path=pb scene=- vis=b active=True +EVENT char=1 PATH_COMPLETE caller=path:pb +tick=10 char=0 coord=7,4 layer=0 path=pa scene=- vis=a active=True +tick=10 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=11 char=0 coord=7,5 layer=0 path=pa scene=- vis=a active=True +tick=11 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=12 char=0 coord=8,5 layer=0 path=pa scene=- vis=a active=True +tick=12 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=13 char=0 coord=8,5 layer=0 path=pa scene=- vis=a active=True +tick=13 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=14 char=0 coord=9,5 layer=0 path=pa scene=- vis=a active=True +tick=14 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=15 char=0 coord=9,6 layer=0 path=pa scene=- vis=a active=True +tick=15 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=16 char=0 coord=10,6 layer=0 path=pa scene=- vis=a active=True +tick=16 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=17 char=0 coord=10,6 layer=0 path=pa scene=- vis=a active=True +tick=17 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=18 char=0 coord=11,6 layer=0 path=pa scene=- vis=a active=True +tick=18 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=19 char=0 coord=12,7 layer=0 path=pa scene=- vis=a active=True +tick=19 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=20 char=0 coord=12,7 layer=0 path=pa scene=- vis=a active=True +tick=20 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=21 char=0 coord=13,7 layer=0 path=pa scene=- vis=a active=True +tick=21 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=22 char=0 coord=13,7 layer=0 path=pa scene=- vis=a active=True +tick=22 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=23 char=0 coord=14,7 layer=0 path=pa scene=- vis=a active=True +tick=23 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=24 char=0 coord=14,8 layer=0 path=pa scene=- vis=a active=True +tick=24 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=25 char=0 coord=15,8 layer=0 path=pa scene=- vis=a active=True +tick=25 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +EVENT char=0 SEGMENT_EXITED caller=wp:0 +EVENT char=0 SEGMENT_ENTERED caller=wp:1 +tick=26 char=0 coord=15,8 layer=0 path=pa scene=- vis=a active=True +tick=26 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=27 char=0 coord=14,7 layer=0 path=pa scene=- vis=a active=True +tick=27 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=28 char=0 coord=13,7 layer=0 path=pa scene=- vis=a active=True +tick=28 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +tick=29 char=0 coord=12,6 layer=0 path=pa scene=- vis=a active=True +tick=29 char=1 coord=3,9 layer=0 path=- scene=- vis=b active=False +=== scenario_hold_and_loop === +EVENT char=0 PATH_ACTIVATED caller=path:hold +EVENT char=1 PATH_ACTIVATED caller=path:looper +EVENT char=0 SEGMENT_ENTERED caller=wp:0 +EVENT char=1 SEGMENT_ENTERED caller=wp:0 +tick=0 char=0 coord=3,3 layer=0 path=hold scene=- vis=a active=True +tick=0 char=1 coord=4,2 layer=0 path=looper scene=- vis=b active=True +tick=1 char=0 coord=5,3 layer=0 path=hold scene=- vis=a active=True +tick=1 char=1 coord=5,3 layer=0 path=looper scene=- vis=b active=True +EVENT char=1 SEGMENT_EXITED caller=wp:0 +EVENT char=1 SEGMENT_ENTERED caller=wp:1 +tick=2 char=0 coord=6,4 layer=0 path=hold scene=- vis=a active=True +tick=2 char=1 coord=6,4 layer=0 path=looper scene=- vis=b active=True +tick=3 char=0 coord=8,4 layer=0 path=hold scene=- vis=a active=True +tick=3 char=1 coord=7,4 layer=0 path=looper scene=- vis=b active=True +EVENT char=0 PATH_HOLDING caller=path:hold +tick=4 char=0 coord=10,5 layer=0 path=hold scene=- vis=a active=True +tick=4 char=1 coord=8,5 layer=0 path=looper scene=- vis=b active=True +EVENT char=1 PATH_ACTIVATED caller=path:looper +tick=5 char=0 coord=10,5 layer=0 path=hold scene=- vis=a active=True +tick=5 char=1 coord=9,6 layer=0 path=looper scene=- vis=b active=True +EVENT char=1 SEGMENT_ENTERED caller=wp:0 +tick=6 char=0 coord=10,5 layer=0 path=hold scene=- vis=a active=True +tick=6 char=1 coord=8,5 layer=0 path=looper scene=- vis=b active=True +EVENT char=0 PATH_COMPLETE caller=path:hold +tick=7 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=7 char=1 coord=7,4 layer=0 path=looper scene=- vis=b active=True +tick=8 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=8 char=1 coord=6,3 layer=0 path=looper scene=- vis=b active=True +EVENT char=1 SEGMENT_EXITED caller=wp:0 +EVENT char=1 SEGMENT_ENTERED caller=wp:1 +tick=9 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=9 char=1 coord=6,3 layer=0 path=looper scene=- vis=b active=True +tick=10 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=10 char=1 coord=7,4 layer=0 path=looper scene=- vis=b active=True +tick=11 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=11 char=1 coord=8,5 layer=0 path=looper scene=- vis=b active=True +EVENT char=1 PATH_ACTIVATED caller=path:looper +tick=12 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=12 char=1 coord=9,6 layer=0 path=looper scene=- vis=b active=True +EVENT char=1 SEGMENT_ENTERED caller=wp:0 +tick=13 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=13 char=1 coord=8,5 layer=0 path=looper scene=- vis=b active=True +tick=14 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=14 char=1 coord=7,4 layer=0 path=looper scene=- vis=b active=True +tick=15 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=15 char=1 coord=6,3 layer=0 path=looper scene=- vis=b active=True +EVENT char=1 SEGMENT_EXITED caller=wp:0 +EVENT char=1 SEGMENT_ENTERED caller=wp:1 +tick=16 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=16 char=1 coord=6,3 layer=0 path=looper scene=- vis=b active=True +tick=17 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=17 char=1 coord=7,4 layer=0 path=looper scene=- vis=b active=True +tick=18 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=18 char=1 coord=8,5 layer=0 path=looper scene=- vis=b active=True +EVENT char=1 PATH_ACTIVATED caller=path:looper +tick=19 char=0 coord=10,5 layer=0 path=- scene=- vis=a active=False +tick=19 char=1 coord=9,6 layer=0 path=looper scene=- vis=b active=True +=== scenario_chained_paths_and_events === +EVENT char=0 PATH_ACTIVATED caller=path:p1 +EVENT char=0 SEGMENT_ENTERED caller=wp:0 +tick=0 char=0 coord=2,3 layer=0 path=p1 scene=- vis=a active=True +tick=1 char=0 coord=3,3 layer=0 path=p1 scene=- vis=a active=True +tick=2 char=0 coord=3,4 layer=0 path=p1 scene=- vis=a active=True +tick=3 char=0 coord=4,4 layer=0 path=p1 scene=- vis=a active=True +EVENT char=0 PATH_COMPLETE caller=path:p1 +EVENT char=0 PATH_ACTIVATED caller=path:p2 +tick=4 char=0 coord=5,5 layer=2 path=p2 scene=- vis=a active=True +EVENT char=0 SEGMENT_ENTERED caller=wp:0 +tick=5 char=0 coord=6,4 layer=2 path=p2 scene=- vis=a active=True +tick=6 char=0 coord=7,4 layer=2 path=p2 scene=- vis=a active=True +tick=7 char=0 coord=8,3 layer=2 path=p2 scene=- vis=a active=True +tick=8 char=0 coord=9,3 layer=2 path=p2 scene=- vis=a active=True +EVENT char=0 PATH_COMPLETE caller=path:p2 +EVENT char=0 PATH_ACTIVATED caller=path:p3 +tick=9 char=0 coord=10,2 layer=2 path=p3 scene=- vis=a active=True +EVENT char=0 SEGMENT_ENTERED caller=wp:0 +tick=10 char=0 coord=8,2 layer=2 path=p3 scene=- vis=a active=True +tick=11 char=0 coord=7,2 layer=2 path=p3 scene=- vis=a active=True +tick=12 char=0 coord=6,2 layer=2 path=p3 scene=- vis=a active=True +tick=13 char=0 coord=4,1 layer=2 path=p3 scene=- vis=a active=True +tick=14 char=0 coord=2,1 layer=2 path=p3 scene=- vis=a active=True +EVENT char=0 PATH_COMPLETE caller=path:p3 +tick=15 char=0 coord=19,9 layer=2 path=- scene=- vis=a active=False +tick=16 char=0 coord=19,9 layer=2 path=- scene=- vis=a active=False +tick=17 char=0 coord=19,9 layer=2 path=- scene=- vis=a active=False +tick=18 char=0 coord=19,9 layer=2 path=- scene=- vis=a active=False +tick=19 char=0 coord=19,9 layer=2 path=- scene=- vis=a active=False +tick=20 char=0 coord=19,9 layer=2 path=- scene=- vis=a active=False +tick=21 char=0 coord=19,9 layer=2 path=- scene=- vis=a active=False +tick=22 char=0 coord=19,9 layer=2 path=- scene=- vis=a active=False +tick=23 char=0 coord=19,9 layer=2 path=- scene=- vis=a active=False +tick=24 char=0 coord=19,9 layer=2 path=- scene=- vis=a active=False +=== scenario_scenes === +EVENT char=0 SCENE_ACTIVATED caller=scene:plain +EVENT char=1 SCENE_ACTIVATED caller=scene:looping +EVENT char=2 SCENE_ACTIVATED caller=scene:eased +EVENT char=1 SCENE_COMPLETE caller=scene:looping +tick=0 char=0 coord=1,2 layer=0 path=- scene=plain vis=\e[38;2;255;0;0mX\e[0m active=True +tick=0 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=0 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;0;0;0m*\e[0m active=True +tick=1 char=0 coord=1,2 layer=0 path=- scene=plain vis=\e[38;2;255;0;0mX\e[0m active=True +tick=1 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=1 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;0;0;0m*\e[0m active=True +tick=2 char=0 coord=1,2 layer=0 path=- scene=plain vis=\e[38;2;0;255;0m\e[48;2;0;0;255mY\e[0m active=True +tick=2 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=2 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;0;0;0m*\e[0m active=True +tick=3 char=0 coord=1,2 layer=0 path=- scene=plain vis=\e[38;2;0;255;0m\e[48;2;0;0;255mY\e[0m active=True +tick=3 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=3 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;0;0;0m*\e[0m active=True +tick=4 char=0 coord=1,2 layer=0 path=- scene=plain vis=\e[38;2;0;255;0m\e[48;2;0;0;255mY\e[0m active=True +tick=4 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=4 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;0;0;0m*\e[0m active=True +EVENT char=0 SCENE_COMPLETE caller=scene:plain +tick=5 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=5 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=5 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;0;0;0m*\e[0m active=True +tick=6 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=6 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=6 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;31;31;31m*\e[0m active=True +tick=7 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=7 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=7 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;62;62;62m*\e[0m active=True +tick=8 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=8 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=8 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;93;93;93m+\e[0m active=True +tick=9 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=9 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=9 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;124;124;124m+\e[0m active=True +tick=10 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=10 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=10 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;155;155;155m+\e[0m active=True +tick=11 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=11 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=11 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;186;186;186mo\e[0m active=True +tick=12 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=12 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=12 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;217;217;217mo\e[0m active=True +tick=13 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=13 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=13 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;255;255;255mo\e[0m active=True +tick=14 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=14 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=14 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;255;255;255mo\e[0m active=True +tick=15 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=15 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=15 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;255;255;255mo\e[0m active=True +tick=16 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=16 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=16 char=2 coord=3,2 layer=0 path=- scene=eased vis=\e[38;2;255;255;255mo\e[0m active=True +EVENT char=2 SCENE_COMPLETE caller=scene:eased +tick=17 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=17 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=17 char=2 coord=3,2 layer=0 path=- scene=- vis=\e[38;2;255;255;255mo\e[0m active=False +tick=18 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=18 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=18 char=2 coord=3,2 layer=0 path=- scene=- vis=\e[38;2;255;255;255mo\e[0m active=False +tick=19 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=19 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=19 char=2 coord=3,2 layer=0 path=- scene=- vis=\e[38;2;255;255;255mo\e[0m active=False +tick=20 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=20 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=20 char=2 coord=3,2 layer=0 path=- scene=- vis=\e[38;2;255;255;255mo\e[0m active=False +tick=21 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=21 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=21 char=2 coord=3,2 layer=0 path=- scene=- vis=\e[38;2;255;255;255mo\e[0m active=False +tick=22 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=22 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=22 char=2 coord=3,2 layer=0 path=- scene=- vis=\e[38;2;255;255;255mo\e[0m active=False +tick=23 char=0 coord=1,2 layer=0 path=- scene=- vis=\e[1mZ\e[0m active=False +tick=23 char=1 coord=2,2 layer=0 path=- scene=looping vis=1 active=False +tick=23 char=2 coord=3,2 layer=0 path=- scene=- vis=\e[38;2;255;255;255mo\e[0m active=False +=== scenario_synced_scene === +EVENT char=0 PATH_ACTIVATED caller=path:sp +EVENT char=0 SCENE_ACTIVATED caller=scene:sync_sp +EVENT char=1 PATH_ACTIVATED caller=path:dp +EVENT char=1 SCENE_ACTIVATED caller=scene:sync_dp +EVENT char=0 SEGMENT_ENTERED caller=wp:0 +EVENT char=1 SEGMENT_ENTERED caller=wp:0 +tick=0 char=0 coord=2,2 layer=0 path=sp scene=sync_sp vis=a active=True +tick=0 char=1 coord=3,2 layer=0 path=dp scene=sync_dp vis=a active=True +tick=1 char=0 coord=2,3 layer=0 path=sp scene=sync_sp vis=a active=True +tick=1 char=1 coord=3,3 layer=0 path=dp scene=sync_dp vis=a active=True +tick=2 char=0 coord=3,3 layer=0 path=sp scene=sync_sp vis=a active=True +tick=2 char=1 coord=4,3 layer=0 path=dp scene=sync_dp vis=a active=True +tick=3 char=0 coord=4,3 layer=0 path=sp scene=sync_sp vis=b active=True +tick=3 char=1 coord=5,3 layer=0 path=dp scene=sync_dp vis=b active=True +tick=4 char=0 coord=4,4 layer=0 path=sp scene=sync_sp vis=b active=True +tick=4 char=1 coord=5,4 layer=0 path=dp scene=sync_dp vis=b active=True +tick=5 char=0 coord=5,4 layer=0 path=sp scene=sync_sp vis=b active=True +tick=5 char=1 coord=6,4 layer=0 path=dp scene=sync_dp vis=b active=True +tick=6 char=0 coord=6,4 layer=0 path=sp scene=sync_sp vis=b active=True +tick=6 char=1 coord=6,4 layer=0 path=dp scene=sync_dp vis=b active=True +tick=7 char=0 coord=6,4 layer=0 path=sp scene=sync_sp vis=b active=True +tick=7 char=1 coord=7,5 layer=0 path=dp scene=sync_dp vis=b active=True +tick=8 char=0 coord=7,5 layer=0 path=sp scene=sync_sp vis=b active=True +tick=8 char=1 coord=8,5 layer=0 path=dp scene=sync_dp vis=b active=True +tick=9 char=0 coord=8,5 layer=0 path=sp scene=sync_sp vis=c active=True +tick=9 char=1 coord=8,5 layer=0 path=dp scene=sync_dp vis=c active=True +tick=10 char=0 coord=8,5 layer=0 path=sp scene=sync_sp vis=c active=True +tick=10 char=1 coord=9,6 layer=0 path=dp scene=sync_dp vis=c active=True +tick=11 char=0 coord=9,6 layer=0 path=sp scene=sync_sp vis=c active=True +tick=11 char=1 coord=10,6 layer=0 path=dp scene=sync_dp vis=c active=True +tick=12 char=0 coord=10,6 layer=0 path=sp scene=sync_sp vis=c active=True +tick=12 char=1 coord=10,6 layer=0 path=dp scene=sync_dp vis=c active=True +tick=13 char=0 coord=10,6 layer=0 path=sp scene=sync_sp vis=c active=True +tick=13 char=1 coord=11,6 layer=0 path=dp scene=sync_dp vis=c active=True +tick=14 char=0 coord=11,7 layer=0 path=sp scene=sync_sp vis=c active=True +tick=14 char=1 coord=12,7 layer=0 path=dp scene=sync_dp vis=c active=True +tick=15 char=0 coord=11,7 layer=0 path=sp scene=sync_sp vis=c active=True +tick=15 char=1 coord=12,7 layer=0 path=dp scene=sync_dp vis=d active=True +tick=16 char=0 coord=12,7 layer=0 path=sp scene=sync_sp vis=d active=True +tick=16 char=1 coord=13,7 layer=0 path=dp scene=sync_dp vis=d active=True +tick=17 char=0 coord=13,8 layer=0 path=sp scene=sync_sp vis=d active=True +tick=17 char=1 coord=13,8 layer=0 path=dp scene=sync_dp vis=d active=True +tick=18 char=0 coord=13,8 layer=0 path=sp scene=sync_sp vis=d active=True +tick=18 char=1 coord=14,8 layer=0 path=dp scene=sync_dp vis=d active=True +tick=19 char=0 coord=14,8 layer=0 path=sp scene=sync_sp vis=d active=True +tick=19 char=1 coord=15,8 layer=0 path=dp scene=sync_dp vis=d active=True +tick=20 char=0 coord=15,8 layer=0 path=sp scene=sync_sp vis=d active=True +tick=20 char=1 coord=15,9 layer=0 path=dp scene=sync_dp vis=d active=True +tick=21 char=0 coord=15,9 layer=0 path=sp scene=sync_sp vis=d active=True +tick=21 char=1 coord=16,9 layer=0 path=dp scene=sync_dp vis=e active=True +EVENT char=0 SEGMENT_EXITED caller=wp:0 +EVENT char=0 SEGMENT_ENTERED caller=wp:1 +EVENT char=1 SEGMENT_EXITED caller=wp:0 +EVENT char=1 SEGMENT_ENTERED caller=wp:1 +tick=22 char=0 coord=16,9 layer=0 path=sp scene=sync_sp vis=e active=True +tick=22 char=1 coord=15,9 layer=0 path=dp scene=sync_dp vis=e active=True +tick=23 char=0 coord=15,9 layer=0 path=sp scene=sync_sp vis=e active=True +tick=23 char=1 coord=15,8 layer=0 path=dp scene=sync_dp vis=e active=True +tick=24 char=0 coord=15,8 layer=0 path=sp scene=sync_sp vis=e active=True +tick=24 char=1 coord=14,8 layer=0 path=dp scene=sync_dp vis=e active=True +tick=25 char=0 coord=14,8 layer=0 path=sp scene=sync_sp vis=e active=True +tick=25 char=1 coord=13,8 layer=0 path=dp scene=sync_dp vis=e active=True +tick=26 char=0 coord=13,8 layer=0 path=sp scene=sync_sp vis=e active=True +tick=26 char=1 coord=13,7 layer=0 path=dp scene=sync_dp vis=e active=True +tick=27 char=0 coord=13,7 layer=0 path=sp scene=sync_sp vis=e active=True +tick=27 char=1 coord=12,7 layer=0 path=dp scene=sync_dp vis=e active=True +tick=28 char=0 coord=12,7 layer=0 path=sp scene=sync_sp vis=f active=True +tick=28 char=1 coord=12,7 layer=0 path=dp scene=sync_dp vis=f active=True +tick=29 char=0 coord=12,7 layer=0 path=sp scene=sync_sp vis=f active=True +tick=29 char=1 coord=11,6 layer=0 path=dp scene=sync_dp vis=f active=True +=== scenario_scene_events_and_resume === +EVENT char=0 SCENE_ACTIVATED caller=scene:s1 +tick=0 char=0 coord=1,2 layer=0 path=- scene=s1 vis=A active=True +tick=1 char=0 coord=1,2 layer=0 path=- scene=s1 vis=A active=True +EVENT char=0 SCENE_ACTIVATED caller=scene:s1 +tick=2 char=0 coord=1,2 layer=0 path=- scene=s1 vis=A active=True +tick=3 char=0 coord=1,2 layer=0 path=- scene=s1 vis=B active=True +tick=4 char=0 coord=1,2 layer=0 path=- scene=s1 vis=B active=True +EVENT char=0 SCENE_COMPLETE caller=scene:s1 +EVENT char=0 SCENE_ACTIVATED caller=scene:s2 +tick=5 char=0 coord=1,2 layer=0 path=- scene=s2 vis=C active=True +tick=6 char=0 coord=1,2 layer=0 path=- scene=s2 vis=C active=True +EVENT char=0 SCENE_COMPLETE caller=scene:s2 +EVENT char=0 PATH_ACTIVATED caller=path:mover +tick=7 char=0 coord=1,2 layer=0 path=mover scene=- vis=C active=True +EVENT char=0 SEGMENT_ENTERED caller=wp:0 +tick=8 char=0 coord=2,2 layer=0 path=mover scene=- vis=C active=True +tick=9 char=0 coord=2,3 layer=0 path=mover scene=- vis=C active=True +tick=10 char=0 coord=2,3 layer=0 path=mover scene=- vis=C active=True +tick=11 char=0 coord=3,4 layer=0 path=mover scene=- vis=C active=True +tick=12 char=0 coord=4,4 layer=0 path=mover scene=- vis=C active=True +tick=13 char=0 coord=4,5 layer=0 path=mover scene=- vis=C active=True +tick=14 char=0 coord=4,5 layer=0 path=mover scene=- vis=C active=True +tick=15 char=0 coord=5,5 layer=0 path=mover scene=- vis=C active=True +tick=16 char=0 coord=6,6 layer=0 path=mover scene=- vis=C active=True +tick=17 char=0 coord=6,6 layer=0 path=mover scene=- vis=C active=True +tick=18 char=0 coord=6,7 layer=0 path=mover scene=- vis=C active=True +tick=19 char=0 coord=7,7 layer=0 path=mover scene=- vis=C active=True +tick=20 char=0 coord=8,8 layer=0 path=mover scene=- vis=C active=True +EVENT char=0 PATH_COMPLETE caller=path:mover +tick=21 char=0 coord=8,8 layer=0 path=- scene=- vis=C active=False diff --git a/vendor/ttfx/tests/fixtures/geometry_goldens.txt b/vendor/ttfx/tests/fixtures/geometry_goldens.txt new file mode 100644 index 0000000..68969c9 --- /dev/null +++ b/vendor/ttfx/tests/fixtures/geometry_goldens.txt @@ -0,0 +1,87 @@ +on_circle r=1 l=0 u=True: 12,10;11,11;9,11;8,10;9,9;11,9 +on_circle r=1 l=0 u=False: 12,10;11,11;9,11;8,10;9,9;11,9 +on_circle r=1 l=7 u=True: 12,10;11,11;10,11;8,10;10,9;11,9 +on_circle r=1 l=7 u=False: 12,10;11,11;10,11;8,10;8,10;10,9;11,9 +on_circle r=1 l=100 u=True: 12,10;12,11;11,11;10,11;9,11;8,11;8,10;8,9;9,9;10,9;11,9;12,9 +on_circle r=1 l=100 u=False: 12,10;12,10;12,10;12,10;12,10;12,10;12,10;12,10;12,10;12,11;12,11;12,11;11,11;11,11;11,11;11,11;11,11;11,11;11,11;11,11;11,11;10,11;10,11;10,11;10,11;10,11;10,11;10,11;10,11;10,11;9,11;9,11;9,11;9,11;9,11;9,11;9,11;9,11;9,11;8,11;8,11;8,11;8,10;8,10;8,10;8,10;8,10;8,10;8,10;8,10;8,10;8,10;8,10;8,10;8,10;8,10;8,10;8,10;8,10;8,9;8,9;8,9;9,9;9,9;9,9;9,9;9,9;9,9;9,9;9,9;9,9;10,9;10,9;10,9;10,9;10,9;10,9;10,9;10,9;10,9;11,9;11,9;11,9;11,9;11,9;11,9;11,9;11,9;11,9;12,9;12,9;12,9;12,10;12,10;12,10;12,10;12,10;12,10;12,10;12,10 +on_circle r=2 l=0 u=True: 14,10;14,11;12,12;10,12;9,12;7,11;6,10;7,9;9,8;10,8;12,8;14,9 +on_circle r=2 l=0 u=False: 14,10;14,11;12,12;10,12;9,12;7,11;6,10;6,10;7,9;9,8;10,8;12,8;14,9 +on_circle r=2 l=7 u=True: 14,10;12,12;9,12;6,11;6,9;9,8;12,8 +on_circle r=2 l=7 u=False: 14,10;12,12;9,12;6,11;6,9;9,8;12,8 +on_circle r=2 l=100 u=True: 14,10;14,11;13,11;13,12;12,12;11,12;10,12;9,12;8,12;7,12;7,11;6,11;6,10;6,9;7,9;7,8;8,8;9,8;10,8;11,8;12,8;13,8;13,9;14,9 +on_circle r=2 l=100 u=False: 14,10;14,10;14,10;14,10;14,10;14,11;14,11;14,11;14,11;13,11;13,11;13,11;13,11;13,11;13,12;12,12;12,12;12,12;12,12;11,12;11,12;11,12;11,12;11,12;10,12;10,12;10,12;9,12;9,12;9,12;9,12;9,12;8,12;8,12;8,12;8,12;7,12;7,11;7,11;7,11;7,11;7,11;6,11;6,11;6,11;6,11;6,10;6,10;6,10;6,10;6,10;6,10;6,10;6,10;6,10;6,9;6,9;6,9;6,9;7,9;7,9;7,9;7,9;7,9;7,8;8,8;8,8;8,8;8,8;9,8;9,8;9,8;9,8;9,8;10,8;10,8;10,8;11,8;11,8;11,8;11,8;11,8;12,8;12,8;12,8;12,8;13,8;13,9;13,9;13,9;13,9;13,9;14,9;14,9;14,9;14,9;14,10;14,10;14,10;14,10 +on_circle r=3 l=0 u=True: 16,10;16,11;15,12;13,13;11,13;10,13;8,13;6,12;5,11;4,10;5,9;6,8;8,7;10,7;11,7;13,7;15,8;16,9 +on_circle r=3 l=0 u=False: 16,10;16,11;15,12;13,13;11,13;10,13;8,13;6,12;5,11;4,10;4,10;5,9;6,8;8,7;10,7;11,7;13,7;15,8;16,9 +on_circle r=3 l=7 u=True: 16,10;14,12;9,13;5,11;5,9;9,7;14,8 +on_circle r=3 l=7 u=False: 16,10;14,12;9,13;5,11;5,9;9,7;14,8 +on_circle r=3 l=100 u=True: 16,10;16,11;15,11;15,12;14,12;13,13;12,13;11,13;10,13;9,13;8,13;7,13;6,12;5,12;5,11;4,11;4,10;4,9;5,9;5,8;6,8;7,7;8,7;9,7;10,7;11,7;12,7;13,7;14,8;15,8;15,9;16,9 +on_circle r=3 l=100 u=False: 16,10;16,10;16,10;16,11;16,11;16,11;16,11;15,11;15,11;15,12;15,12;15,12;14,12;14,12;14,12;14,12;13,13;13,13;13,13;12,13;12,13;11,13;11,13;11,13;10,13;10,13;10,13;9,13;9,13;9,13;8,13;8,13;7,13;7,13;7,13;6,12;6,12;6,12;6,12;5,12;5,12;5,12;5,11;5,11;4,11;4,11;4,11;4,11;4,10;4,10;4,10;4,10;4,10;4,9;4,9;4,9;4,9;5,9;5,9;5,8;5,8;5,8;6,8;6,8;6,8;6,8;7,7;7,7;7,7;8,7;8,7;9,7;9,7;9,7;10,7;10,7;10,7;11,7;11,7;11,7;12,7;12,7;13,7;13,7;13,7;14,8;14,8;14,8;14,8;15,8;15,8;15,8;15,9;15,9;16,9;16,9;16,9;16,9;16,10;16,10 +on_circle r=5 l=0 u=True: 20,10;20,11;19,12;18,13;17,14;15,14;13,15;12,15;9,15;7,15;6,14;4,14;2,13;1,12;0,11;0,9;1,8;2,7;4,6;6,6;7,5;9,5;12,5;13,5;15,6;17,6;18,7;19,8;20,9 +on_circle r=5 l=0 u=False: 20,10;20,11;19,12;18,13;17,14;15,14;13,15;12,15;9,15;7,15;6,14;4,14;2,13;1,12;0,11;0,11;0,9;0,9;1,8;2,7;4,6;6,6;7,5;9,5;12,5;13,5;15,6;17,6;18,7;19,8;20,9 +on_circle r=5 l=7 u=True: 20,10;16,14;8,15;1,12;1,8;8,5;16,6 +on_circle r=5 l=7 u=False: 20,10;16,14;8,15;1,12;1,8;8,5;16,6 +on_circle r=5 l=100 u=True: 20,10;20,11;20,12;19,12;18,13;17,13;17,14;16,14;15,14;14,15;13,15;12,15;11,15;10,15;9,15;8,15;7,15;6,15;5,14;4,14;3,14;3,13;2,13;1,12;0,12;0,11;0,10;0,9;0,8;1,8;2,7;3,7;3,6;4,6;5,6;6,5;7,5;8,5;9,5;10,5;11,5;12,5;13,5;14,5;15,6;16,6;17,6;17,7;18,7;19,8;20,8;20,9 +on_circle r=5 l=100 u=False: 20,10;20,10;20,11;20,11;20,11;20,12;19,12;19,12;19,12;18,13;18,13;18,13;17,13;17,14;16,14;16,14;15,14;15,14;14,15;14,15;13,15;12,15;12,15;11,15;11,15;10,15;9,15;9,15;8,15;8,15;7,15;6,15;6,15;5,14;5,14;4,14;4,14;3,14;3,13;2,13;2,13;2,13;1,12;1,12;1,12;0,12;0,11;0,11;0,11;0,10;0,10;0,10;0,9;0,9;0,9;0,8;1,8;1,8;1,8;2,7;2,7;2,7;3,7;3,6;4,6;4,6;5,6;5,6;6,5;6,5;7,5;8,5;8,5;9,5;9,5;10,5;11,5;11,5;12,5;12,5;13,5;14,5;14,5;15,6;15,6;16,6;16,6;17,6;17,7;18,7;18,7;18,7;19,8;19,8;19,8;20,8;20,9;20,9;20,9;20,10 +on_circle r=8 l=0 u=True: 26,10;26,11;25,12;25,13;24,14;23,15;22,15;20,16;19,17;17,17;15,18;13,18;11,18;9,18;7,18;5,18;3,17;1,17;0,16;-2,15;-3,15;-4,14;-5,13;-5,12;-6,11;-6,10;-6,9;-5,8;-5,7;-4,6;-3,5;-2,5;0,4;1,3;3,3;5,2;7,2;9,2;11,2;13,2;15,2;17,3;19,3;20,4;22,5;23,5;24,6;25,7;25,8;26,9 +on_circle r=8 l=0 u=False: 26,10;26,11;25,12;25,13;24,14;23,15;22,15;20,16;19,17;17,17;15,18;13,18;11,18;9,18;7,18;5,18;3,17;1,17;0,16;-2,15;-3,15;-4,14;-5,13;-5,12;-6,11;-6,10;-6,9;-5,8;-5,7;-4,6;-3,5;-2,5;0,4;1,3;3,3;5,2;7,2;9,2;11,2;13,2;15,2;17,3;19,3;20,4;22,5;23,5;24,6;25,7;25,8;26,9 +on_circle r=8 l=7 u=True: 26,10;20,16;6,18;-4,13;-4,7;6,2;20,4 +on_circle r=8 l=7 u=False: 26,10;20,16;6,18;-4,13;-4,7;6,2;20,4 +on_circle r=8 l=100 u=True: 26,10;26,11;25,12;25,13;24,13;24,14;23,15;22,15;21,16;20,16;19,16;19,17;18,17;17,17;16,17;15,18;14,18;13,18;12,18;11,18;10,18;9,18;8,18;7,18;6,18;5,18;4,17;3,17;2,17;1,17;1,16;0,16;-1,16;-2,15;-3,15;-4,14;-4,13;-5,13;-5,12;-6,11;-6,10;-6,9;-5,8;-5,7;-4,7;-4,6;-3,5;-2,5;-1,4;0,4;1,4;1,3;2,3;3,3;4,3;5,2;6,2;7,2;8,2;9,2;10,2;11,2;12,2;13,2;14,2;15,2;16,3;17,3;18,3;19,3;19,4;20,4;21,4;22,5;23,5;24,6;24,7;25,7;25,8;26,9 +on_circle r=8 l=100 u=False: 26,10;26,11;26,11;26,11;25,12;25,12;25,13;24,13;24,14;24,14;23,15;22,15;22,15;21,16;20,16;19,16;19,17;18,17;17,17;16,17;15,18;14,18;13,18;12,18;11,18;10,18;9,18;8,18;7,18;6,18;5,18;4,17;3,17;2,17;1,17;1,16;0,16;-1,16;-2,15;-2,15;-3,15;-4,14;-4,14;-4,13;-5,13;-5,12;-5,12;-6,11;-6,11;-6,11;-6,10;-6,9;-6,9;-6,9;-5,8;-5,8;-5,7;-4,7;-4,6;-4,6;-3,5;-2,5;-2,5;-1,4;0,4;1,4;1,3;2,3;3,3;4,3;5,2;6,2;7,2;8,2;9,2;10,2;11,2;12,2;13,2;14,2;15,2;16,3;17,3;18,3;19,3;19,4;20,4;21,4;22,5;22,5;23,5;24,6;24,6;24,7;25,7;25,8;25,8;26,9;26,9;26,9 +on_circle r=13 l=0 u=True: 36,10;36,11;36,12;35,13;35,14;34,15;33,16;32,17;31,17;30,18;29,19;27,20;26,20;24,21;22,21;21,22;19,22;17,23;15,23;13,23;11,23;9,23;7,23;5,23;3,23;1,22;-1,22;-2,21;-4,21;-6,20;-7,20;-9,19;-10,18;-11,17;-12,17;-13,16;-14,15;-15,14;-15,13;-16,12;-16,11;-16,10;-16,9;-16,8;-15,7;-15,6;-14,5;-13,4;-12,3;-11,3;-10,2;-9,1;-7,0;-6,0;-4,-1;-2,-1;-1,-2;1,-2;3,-3;5,-3;7,-3;9,-3;11,-3;13,-3;15,-3;17,-3;19,-2;21,-2;22,-1;24,-1;26,0;27,0;29,1;30,2;31,3;32,3;33,4;34,5;35,6;35,7;36,8;36,9 +on_circle r=13 l=0 u=False: 36,10;36,11;36,12;35,13;35,14;34,15;33,16;32,17;31,17;30,18;29,19;27,20;26,20;24,21;22,21;21,22;19,22;17,23;15,23;13,23;11,23;9,23;7,23;5,23;3,23;1,22;-1,22;-2,21;-4,21;-6,20;-7,20;-9,19;-10,18;-11,17;-12,17;-13,16;-14,15;-15,14;-15,13;-16,12;-16,11;-16,10;-16,9;-16,8;-15,7;-15,6;-14,5;-13,4;-12,3;-11,3;-10,2;-9,1;-7,0;-6,0;-4,-1;-2,-1;-1,-2;1,-2;3,-3;5,-3;7,-3;9,-3;11,-3;13,-3;15,-3;17,-3;19,-2;21,-2;22,-1;24,-1;26,0;27,0;29,1;30,2;31,3;32,3;33,4;34,5;35,6;35,7;36,8;36,9 +on_circle r=13 l=7 u=True: 36,10;26,20;4,23;-13,16;-13,4;4,-3;26,0 +on_circle r=13 l=7 u=False: 36,10;26,20;4,23;-13,16;-13,4;4,-3;26,0 +on_circle r=13 l=100 u=True: 36,10;36,11;36,12;35,13;35,14;34,15;34,16;33,16;32,17;31,18;30,18;29,19;28,19;27,20;25,21;24,21;23,21;21,22;20,22;18,22;16,23;15,23;13,23;12,23;10,23;8,23;7,23;5,23;4,23;2,22;0,22;-1,22;-3,21;-4,21;-5,21;-7,20;-8,19;-9,19;-10,18;-11,18;-12,17;-13,16;-14,16;-14,15;-15,14;-15,13;-16,12;-16,11;-16,10;-16,9;-16,8;-15,7;-15,6;-14,5;-14,4;-13,4;-12,3;-11,2;-10,2;-9,1;-8,1;-7,0;-5,-1;-4,-1;-3,-1;-1,-2;0,-2;2,-2;4,-3;5,-3;7,-3;8,-3;10,-3;12,-3;13,-3;15,-3;16,-3;18,-2;20,-2;21,-2;23,-1;24,-1;25,-1;27,0;28,1;29,1;30,2;31,2;32,3;33,4;34,4;34,5;35,6;35,7;36,8;36,9 +on_circle r=13 l=100 u=False: 36,10;36,11;36,12;36,12;35,13;35,14;34,15;34,16;33,16;32,17;31,18;30,18;29,19;28,19;27,20;25,21;24,21;23,21;21,22;20,22;18,22;16,23;15,23;13,23;12,23;10,23;8,23;7,23;5,23;4,23;2,22;0,22;-1,22;-3,21;-4,21;-5,21;-7,20;-8,19;-9,19;-10,18;-11,18;-12,17;-13,16;-14,16;-14,15;-15,14;-15,13;-16,12;-16,12;-16,11;-16,10;-16,9;-16,8;-16,8;-15,7;-15,6;-14,5;-14,4;-13,4;-12,3;-11,2;-10,2;-9,1;-8,1;-7,0;-5,-1;-4,-1;-3,-1;-1,-2;0,-2;2,-2;4,-3;5,-3;7,-3;8,-3;10,-3;12,-3;13,-3;15,-3;16,-3;18,-2;20,-2;21,-2;23,-1;24,-1;25,-1;27,0;28,1;29,1;30,2;31,2;32,3;33,4;34,4;34,5;35,6;35,7;36,8;36,8;36,9 +on_circle r=20 l=0 u=True: 50,10;50,11;50,12;50,13;49,14;49,15;48,16;48,17;47,18;46,19;45,20;44,20;43,21;42,22;41,23;39,24;38,24;36,25;35,26;33,26;32,27;30,27;28,28;26,28;25,29;23,29;21,29;19,29;17,30;15,30;13,30;11,30;9,30;7,30;5,30;3,30;1,29;-1,29;-3,29;-5,29;-6,28;-8,28;-10,27;-12,27;-13,26;-15,26;-16,25;-18,24;-19,24;-21,23;-22,22;-23,21;-24,20;-25,20;-26,19;-27,18;-28,17;-28,16;-29,15;-29,14;-30,13;-30,12;-30,11;-30,10;-30,9;-30,8;-30,7;-29,6;-29,5;-28,4;-28,3;-27,2;-26,1;-25,0;-24,0;-23,-1;-22,-2;-21,-3;-19,-4;-18,-4;-16,-5;-15,-6;-13,-6;-12,-7;-10,-7;-8,-8;-6,-8;-5,-9;-3,-9;-1,-9;1,-9;3,-10;5,-10;7,-10;9,-10;11,-10;13,-10;15,-10;17,-10;19,-9;21,-9;23,-9;25,-9;26,-8;28,-8;30,-7;32,-7;33,-6;35,-6;36,-5;38,-4;39,-4;41,-3;42,-2;43,-1;44,0;45,0;46,1;47,2;48,3;48,4;49,5;49,6;50,7;50,8;50,9 +on_circle r=20 l=0 u=False: 50,10;50,11;50,12;50,13;49,14;49,15;48,16;48,17;47,18;46,19;45,20;44,20;43,21;42,22;41,23;39,24;38,24;36,25;35,26;33,26;32,27;30,27;28,28;26,28;25,29;23,29;21,29;19,29;17,30;15,30;13,30;11,30;9,30;7,30;5,30;3,30;1,29;-1,29;-3,29;-5,29;-6,28;-8,28;-10,27;-12,27;-13,26;-15,26;-16,25;-18,24;-19,24;-21,23;-22,22;-23,21;-24,20;-25,20;-26,19;-27,18;-28,17;-28,16;-29,15;-29,14;-30,13;-30,12;-30,11;-30,10;-30,9;-30,8;-30,7;-29,6;-29,5;-28,4;-28,3;-27,2;-26,1;-25,0;-24,0;-23,-1;-22,-2;-21,-3;-19,-4;-18,-4;-16,-5;-15,-6;-13,-6;-12,-7;-10,-7;-8,-8;-6,-8;-5,-9;-3,-9;-1,-9;1,-9;3,-10;5,-10;7,-10;9,-10;11,-10;13,-10;15,-10;17,-10;19,-9;21,-9;23,-9;25,-9;26,-8;28,-8;30,-7;32,-7;33,-6;35,-6;36,-5;38,-4;39,-4;41,-3;42,-2;43,-1;44,0;45,0;46,1;47,2;48,3;48,4;49,5;49,6;50,7;50,8;50,9 +on_circle r=20 l=7 u=True: 50,10;35,26;1,29;-26,19;-26,1;1,-9;35,-6 +on_circle r=20 l=7 u=False: 50,10;35,26;1,29;-26,19;-26,1;1,-9;35,-6 +on_circle r=20 l=100 u=True: 50,10;50,11;50,13;49,14;49,15;48,16;47,17;46,19;45,20;44,21;42,22;41,23;39,24;37,25;35,25;34,26;31,27;29,28;27,28;25,29;22,29;20,29;17,30;15,30;13,30;10,30;7,30;5,30;3,30;0,29;-2,29;-5,29;-7,28;-9,28;-11,27;-14,26;-15,25;-17,25;-19,24;-21,23;-22,22;-24,21;-25,20;-26,19;-27,17;-28,16;-29,15;-29,14;-30,13;-30,11;-30,10;-30,9;-30,7;-29,6;-29,5;-28,4;-27,3;-26,1;-25,0;-24,-1;-22,-2;-21,-3;-19,-4;-17,-5;-15,-5;-14,-6;-11,-7;-9,-8;-7,-8;-5,-9;-2,-9;0,-9;3,-10;5,-10;7,-10;10,-10;13,-10;15,-10;17,-10;20,-9;22,-9;25,-9;27,-8;29,-8;31,-7;34,-6;35,-5;37,-5;39,-4;41,-3;42,-2;44,-1;45,0;46,1;47,3;48,4;49,5;49,6;50,7;50,9 +on_circle r=20 l=100 u=False: 50,10;50,11;50,13;49,14;49,15;48,16;47,17;46,19;45,20;44,21;42,22;41,23;39,24;37,25;35,25;34,26;31,27;29,28;27,28;25,29;22,29;20,29;17,30;15,30;13,30;10,30;7,30;5,30;3,30;0,29;-2,29;-5,29;-7,28;-9,28;-11,27;-14,26;-15,25;-17,25;-19,24;-21,23;-22,22;-24,21;-25,20;-26,19;-27,17;-28,16;-29,15;-29,14;-30,13;-30,11;-30,10;-30,9;-30,7;-29,6;-29,5;-28,4;-27,3;-26,1;-25,0;-24,-1;-22,-2;-21,-3;-19,-4;-17,-5;-15,-5;-14,-6;-11,-7;-9,-8;-7,-8;-5,-9;-2,-9;0,-9;3,-10;5,-10;7,-10;10,-10;13,-10;15,-10;17,-10;20,-9;22,-9;25,-9;27,-8;29,-8;31,-7;34,-6;35,-5;37,-5;39,-4;41,-3;42,-2;44,-1;45,0;46,1;47,3;48,4;49,5;49,6;50,7;50,9 +in_circle d=1: 4,-3;5,-3;6,-3 +in_circle d=2: 3,-3;4,-3;5,-4;5,-3;5,-2;6,-3;7,-3 +in_circle d=3: 2,-3;3,-4;3,-3;3,-2;4,-4;4,-3;4,-2;5,-4;5,-3;5,-2;6,-4;6,-3;6,-2;7,-4;7,-3;7,-2;8,-3 +in_circle d=4: 1,-3;2,-4;2,-3;2,-2;3,-4;3,-3;3,-2;4,-4;4,-3;4,-2;5,-5;5,-4;5,-3;5,-2;5,-1;6,-4;6,-3;6,-2;7,-4;7,-3;7,-2;8,-4;8,-3;8,-2;9,-3 +in_circle d=7: -2,-3;-1,-4;-1,-3;-1,-2;0,-5;0,-4;0,-3;0,-2;0,-1;1,-5;1,-4;1,-3;1,-2;1,-1;2,-6;2,-5;2,-4;2,-3;2,-2;2,-1;2,0;3,-6;3,-5;3,-4;3,-3;3,-2;3,-1;3,0;4,-6;4,-5;4,-4;4,-3;4,-2;4,-1;4,0;5,-6;5,-5;5,-4;5,-3;5,-2;5,-1;5,0;6,-6;6,-5;6,-4;6,-3;6,-2;6,-1;6,0;7,-6;7,-5;7,-4;7,-3;7,-2;7,-1;7,0;8,-6;8,-5;8,-4;8,-3;8,-2;8,-1;8,0;9,-5;9,-4;9,-3;9,-2;9,-1;10,-5;10,-4;10,-3;10,-2;10,-1;11,-4;11,-3;11,-2;12,-3 +in_circle d=10: -5,-3;-4,-5;-4,-4;-4,-3;-4,-2;-4,-1;-3,-6;-3,-5;-3,-4;-3,-3;-3,-2;-3,-1;-3,0;-2,-6;-2,-5;-2,-4;-2,-3;-2,-2;-2,-1;-2,0;-1,-7;-1,-6;-1,-5;-1,-4;-1,-3;-1,-2;-1,-1;-1,0;-1,1;0,-7;0,-6;0,-5;0,-4;0,-3;0,-2;0,-1;0,0;0,1;1,-7;1,-6;1,-5;1,-4;1,-3;1,-2;1,-1;1,0;1,1;2,-7;2,-6;2,-5;2,-4;2,-3;2,-2;2,-1;2,0;2,1;3,-7;3,-6;3,-5;3,-4;3,-3;3,-2;3,-1;3,0;3,1;4,-7;4,-6;4,-5;4,-4;4,-3;4,-2;4,-1;4,0;4,1;5,-8;5,-7;5,-6;5,-5;5,-4;5,-3;5,-2;5,-1;5,0;5,1;5,2;6,-7;6,-6;6,-5;6,-4;6,-3;6,-2;6,-1;6,0;6,1;7,-7;7,-6;7,-5;7,-4;7,-3;7,-2;7,-1;7,0;7,1;8,-7;8,-6;8,-5;8,-4;8,-3;8,-2;8,-1;8,0;8,1;9,-7;9,-6;9,-5;9,-4;9,-3;9,-2;9,-1;9,0;9,1;10,-7;10,-6;10,-5;10,-4;10,-3;10,-2;10,-1;10,0;10,1;11,-7;11,-6;11,-5;11,-4;11,-3;11,-2;11,-1;11,0;11,1;12,-6;12,-5;12,-4;12,-3;12,-2;12,-1;12,0;13,-6;13,-5;13,-4;13,-3;13,-2;13,-1;13,0;14,-5;14,-4;14,-3;14,-2;14,-1;15,-3 +in_circle d=15: -10,-3;-9,-5;-9,-4;-9,-3;-9,-2;-9,-1;-8,-6;-8,-5;-8,-4;-8,-3;-8,-2;-8,-1;-8,0;-7,-7;-7,-6;-7,-5;-7,-4;-7,-3;-7,-2;-7,-1;-7,0;-7,1;-6,-8;-6,-7;-6,-6;-6,-5;-6,-4;-6,-3;-6,-2;-6,-1;-6,0;-6,1;-6,2;-5,-8;-5,-7;-5,-6;-5,-5;-5,-4;-5,-3;-5,-2;-5,-1;-5,0;-5,1;-5,2;-4,-9;-4,-8;-4,-7;-4,-6;-4,-5;-4,-4;-4,-3;-4,-2;-4,-1;-4,0;-4,1;-4,2;-4,3;-3,-9;-3,-8;-3,-7;-3,-6;-3,-5;-3,-4;-3,-3;-3,-2;-3,-1;-3,0;-3,1;-3,2;-3,3;-2,-9;-2,-8;-2,-7;-2,-6;-2,-5;-2,-4;-2,-3;-2,-2;-2,-1;-2,0;-2,1;-2,2;-2,3;-1,-9;-1,-8;-1,-7;-1,-6;-1,-5;-1,-4;-1,-3;-1,-2;-1,-1;-1,0;-1,1;-1,2;-1,3;0,-10;0,-9;0,-8;0,-7;0,-6;0,-5;0,-4;0,-3;0,-2;0,-1;0,0;0,1;0,2;0,3;0,4;1,-10;1,-9;1,-8;1,-7;1,-6;1,-5;1,-4;1,-3;1,-2;1,-1;1,0;1,1;1,2;1,3;1,4;2,-10;2,-9;2,-8;2,-7;2,-6;2,-5;2,-4;2,-3;2,-2;2,-1;2,0;2,1;2,2;2,3;2,4;3,-10;3,-9;3,-8;3,-7;3,-6;3,-5;3,-4;3,-3;3,-2;3,-1;3,0;3,1;3,2;3,3;3,4;4,-10;4,-9;4,-8;4,-7;4,-6;4,-5;4,-4;4,-3;4,-2;4,-1;4,0;4,1;4,2;4,3;4,4;5,-10;5,-9;5,-8;5,-7;5,-6;5,-5;5,-4;5,-3;5,-2;5,-1;5,0;5,1;5,2;5,3;5,4;6,-10;6,-9;6,-8;6,-7;6,-6;6,-5;6,-4;6,-3;6,-2;6,-1;6,0;6,1;6,2;6,3;6,4;7,-10;7,-9;7,-8;7,-7;7,-6;7,-5;7,-4;7,-3;7,-2;7,-1;7,0;7,1;7,2;7,3;7,4;8,-10;8,-9;8,-8;8,-7;8,-6;8,-5;8,-4;8,-3;8,-2;8,-1;8,0;8,1;8,2;8,3;8,4;9,-10;9,-9;9,-8;9,-7;9,-6;9,-5;9,-4;9,-3;9,-2;9,-1;9,0;9,1;9,2;9,3;9,4;10,-10;10,-9;10,-8;10,-7;10,-6;10,-5;10,-4;10,-3;10,-2;10,-1;10,0;10,1;10,2;10,3;10,4;11,-9;11,-8;11,-7;11,-6;11,-5;11,-4;11,-3;11,-2;11,-1;11,0;11,1;11,2;11,3;12,-9;12,-8;12,-7;12,-6;12,-5;12,-4;12,-3;12,-2;12,-1;12,0;12,1;12,2;12,3;13,-9;13,-8;13,-7;13,-6;13,-5;13,-4;13,-3;13,-2;13,-1;13,0;13,1;13,2;13,3;14,-9;14,-8;14,-7;14,-6;14,-5;14,-4;14,-3;14,-2;14,-1;14,0;14,1;14,2;14,3;15,-8;15,-7;15,-6;15,-5;15,-4;15,-3;15,-2;15,-1;15,0;15,1;15,2;16,-8;16,-7;16,-6;16,-5;16,-4;16,-3;16,-2;16,-1;16,0;16,1;16,2;17,-7;17,-6;17,-5;17,-4;17,-3;17,-2;17,-1;17,0;17,1;18,-6;18,-5;18,-4;18,-3;18,-2;18,-1;18,0;19,-5;19,-4;19,-3;19,-2;19,-1;20,-3 +in_rect d=0: +in_rect d=1: 2,3;2,4;2,5;3,3;3,4;3,5;4,3;4,4;4,5 +in_rect d=2: 1,2;1,3;1,4;1,5;1,6;2,2;2,3;2,4;2,5;2,6;3,2;3,3;3,4;3,5;3,6;4,2;4,3;4,4;4,5;4,6;5,2;5,3;5,4;5,5;5,6 +in_rect d=5: -2,-1;-2,0;-2,1;-2,2;-2,3;-2,4;-2,5;-2,6;-2,7;-2,8;-2,9;-1,-1;-1,0;-1,1;-1,2;-1,3;-1,4;-1,5;-1,6;-1,7;-1,8;-1,9;0,-1;0,0;0,1;0,2;0,3;0,4;0,5;0,6;0,7;0,8;0,9;1,-1;1,0;1,1;1,2;1,3;1,4;1,5;1,6;1,7;1,8;1,9;2,-1;2,0;2,1;2,2;2,3;2,4;2,5;2,6;2,7;2,8;2,9;3,-1;3,0;3,1;3,2;3,3;3,4;3,5;3,6;3,7;3,8;3,9;4,-1;4,0;4,1;4,2;4,3;4,4;4,5;4,6;4,7;4,8;4,9;5,-1;5,0;5,1;5,2;5,3;5,4;5,5;5,6;5,7;5,8;5,9;6,-1;6,0;6,1;6,2;6,3;6,4;6,5;6,6;6,7;6,8;6,9;7,-1;7,0;7,1;7,2;7,3;7,4;7,5;7,6;7,7;7,8;7,9;8,-1;8,0;8,1;8,2;8,3;8,4;8,5;8,6;8,7;8,8;8,9 +on_rect 0,3: +on_rect 3,0: +on_rect 1,1: -1,-1;-1,0;-1,1;0,-1;0,1;1,-1;1,0;1,1 +on_rect 4,2: -4,-2;-4,-1;-4,0;-4,1;-4,2;-3,-2;-3,2;-2,-2;-2,2;-1,-2;-1,2;0,-2;0,2;1,-2;1,2;2,-2;2,2;3,-2;3,2;4,-2;4,-1;4,0;4,1;4,2 +on_rect 5,7: -5,-7;-5,-6;-5,-5;-5,-4;-5,-3;-5,-2;-5,-1;-5,0;-5,1;-5,2;-5,3;-5,4;-5,5;-5,6;-5,7;-4,-7;-4,7;-3,-7;-3,7;-2,-7;-2,7;-1,-7;-1,7;0,-7;0,7;1,-7;1,7;2,-7;2,7;3,-7;3,7;4,-7;4,7;5,-7;5,-6;5,-5;5,-4;5,-3;5,-2;5,-1;5,0;5,1;5,2;5,3;5,4;5,5;5,6;5,7 +extrapolate 0,0->10,5+0.0: 10,5 +extrapolate 0,0->10,5+1.5: 11,6 +extrapolate 0,0->10,5+4.0: 14,7 +extrapolate 0,0->10,5+10.25: 19,10 +extrapolate 0,0->10,5+-2.0: 8,4 +extrapolate 3,3->3,3+0.0: 3,3 +extrapolate 3,3->3,3+1.5: 3,3 +extrapolate 3,3->3,3+4.0: 3,3 +extrapolate 3,3->3,3+10.25: 3,3 +extrapolate 3,3->3,3+-2.0: 3,3 +extrapolate -5,2->7,-9+0.0: 7,-9 +extrapolate -5,2->7,-9+1.5: 8,-10 +extrapolate -5,2->7,-9+4.0: 10,-12 +extrapolate -5,2->7,-9+10.25: 15,-16 +extrapolate -5,2->7,-9+-2.0: 6,-8 +bezier 1cp: 0,0;0,1;1,2;2,3;2,3;2,4;3,4;4,5;4,5;4,5;5,5;6,5;6,5;6,5;7,4;8,4;8,3;8,3;9,2;10,1;10,0 +bezier_len 1cp: c0f25d8e1d8a3340 +bezier 2cp: 0,0;0,1;1,2;1,2;2,3;2,3;3,3;4,3;4,3;5,3;5,3;6,3;6,2;7,2;8,2;8,2;9,2;10,2;11,3;11,3;12,4 +bezier_len 2cp: e6df618d756c3140 +bezier 3cp: -4,-4;-3,0;-2,4;-1,6;0,8;1,9;1,10;2,10;2,10;3,9;3,8;3,7;3,6;3,5;3,3;3,2;3,0;3,-1;3,-3;4,-4;6,-6 +bezier_len 3cp: ecd308d3f7ce4b40 +on_line: -7,9;-6,9;-6,8;-5,8;-4,7;-3,7;-2,7;-1,6;0,6;0,5;1,5;2,4;3,4;4,3;5,3;6,2;6,2;7,2;8,1;9,1;10,0;11,0;11,-1;12,-1;13,-2;14,-2;15,-2;16,-3;17,-3;17,-4;18,-4 +line_len double=False: 263577e24b152840 +line_len double=True: 6256637d9db23340 +norm_dist 1,1: ee11c846c375e83f +norm_dist 5,3: 7e7dd856cd22cb3f +norm_dist 10,8: 000000000000f03f +norm_dist 3,8: 7423ad8699f8eb3f +norm_dist 10,1: 7704e948087eea3f diff --git a/vendor/ttfx/tests/fixtures/graphics_goldens.txt b/vendor/ttfx/tests/fixtures/graphics_goldens.txt new file mode 100644 index 0000000..3ed53e0 --- /dev/null +++ b/vendor/ttfx/tests/fixtures/graphics_goldens.txt @@ -0,0 +1,44 @@ +grad 8A008A+00D1FF+FFFFFF s=(12,) loop=False: 8a008a;7e1193;72229c;6633a5;5a44ae;4e55b7;4266c0;3677c9;2a88d2;1e99db;12aae4;06bbed;00D1FF;15d4ff;2ad7ff;3fdaff;54ddff;69e0ff;7ee3ff;93e6ff;a8e9ff;bdecff;d2efff;e7f2ff;FFFFFF +grad 8A008A+00D1FF+FFFFFF s=(6, 3) loop=False: 8a008a;73229d;5c44b0;4566c3;2e88d6;17aae9;00D1FF;55e0ff;aaefff;FFFFFF +grad ffffff+000000 s=(10,) loop=False: ffffff;e5e5e5;cbcbcb;b1b1b1;979797;7d7d7d;636363;494949;2f2f2f;151515;000000 +grad 000000+ffffff s=(7,) loop=False: 000000;242424;484848;6c6c6c;909090;b4b4b4;d8d8d8;ffffff +grad ff0000+00ff00+0000ff s=(5,) loop=True: ff0000;cc3300;996600;669900;33cc00;00ff00;00cc33;009966;006699;0033cc;0000ff;3300cc;660099;990066;cc0033;ff0000 +grad 123456 s=(4,) loop=False: 123456;123456;123456;123456 +grad ff5733+33ff57+5733ff+f0f0f0 s=(3, 9) loop=False: ff5733;bb8f3f;77c74b;33ff57;37e869;3bd17b;3fba8d;43a39f;478cb1;4b75c3;4f5ed5;5347e7;5733ff;6848fd;795dfb;8a72f9;9b87f7;ac9cf5;bdb1f3;cec6f1;dfdbef;f0f0f0 +grad 0a0b0c+f1e2d3 s=(1,) loop=False: 0a0b0c;f1e2d3 +frac 0.0: 8a008a +frac 0.05: 7e1193 +frac 0.1: 72229c +frac 0.15: 6633a5 +frac 0.2: 5a44ae +frac 0.25: 4266c0 +frac 0.3: 3677c9 +frac 0.35: 2a88d2 +frac 0.4: 1e99db +frac 0.45: 06bbed +frac 0.5: 00D1FF +frac 0.55: 15d4ff +frac 0.6: 2ad7ff +frac 0.65: 54ddff +frac 0.7: 69e0ff +frac 0.75: 7ee3ff +frac 0.8: 93e6ff +frac 0.85: bdecff +frac 0.9: d2efff +frac 0.95: e7f2ff +frac 1.0: FFFFFF +mapping VERTICAL: 1,1=5a44ae;2,1=5a44ae;3,1=5a44ae;4,1=5a44ae;5,1=5a44ae;6,1=5a44ae;7,1=5a44ae;8,1=5a44ae;1,2=1e99db;2,2=1e99db;3,2=1e99db;4,2=1e99db;5,2=1e99db;6,2=1e99db;7,2=1e99db;8,2=1e99db;1,3=2ad7ff;2,3=2ad7ff;3,3=2ad7ff;4,3=2ad7ff;5,3=2ad7ff;6,3=2ad7ff;7,3=2ad7ff;8,3=2ad7ff;1,4=93e6ff;2,4=93e6ff;3,4=93e6ff;4,4=93e6ff;5,4=93e6ff;6,4=93e6ff;7,4=93e6ff;8,4=93e6ff;1,5=FFFFFF;2,5=FFFFFF;3,5=FFFFFF;4,5=FFFFFF;5,5=FFFFFF;6,5=FFFFFF;7,5=FFFFFF;8,5=FFFFFF +mapping_offset VERTICAL: 3,2=5a44ae;4,2=5a44ae;5,2=5a44ae;6,2=5a44ae;7,2=5a44ae;8,2=5a44ae;9,2=5a44ae;3,3=1e99db;4,3=1e99db;5,3=1e99db;6,3=1e99db;7,3=1e99db;8,3=1e99db;9,3=1e99db;3,4=2ad7ff;4,4=2ad7ff;5,4=2ad7ff;6,4=2ad7ff;7,4=2ad7ff;8,4=2ad7ff;9,4=2ad7ff;3,5=93e6ff;4,5=93e6ff;5,5=93e6ff;6,5=93e6ff;7,5=93e6ff;8,5=93e6ff;9,5=93e6ff;3,6=FFFFFF;4,6=FFFFFF;5,6=FFFFFF;6,6=FFFFFF;7,6=FFFFFF;8,6=FFFFFF;9,6=FFFFFF +mapping HORIZONTAL: 1,1=6633a5;1,2=6633a5;1,3=6633a5;1,4=6633a5;1,5=6633a5;2,1=4266c0;2,2=4266c0;2,3=4266c0;2,4=4266c0;2,5=4266c0;3,1=1e99db;3,2=1e99db;3,3=1e99db;3,4=1e99db;3,5=1e99db;4,1=00D1FF;4,2=00D1FF;4,3=00D1FF;4,4=00D1FF;4,5=00D1FF;5,1=3fdaff;5,2=3fdaff;5,3=3fdaff;5,4=3fdaff;5,5=3fdaff;6,1=7ee3ff;6,2=7ee3ff;6,3=7ee3ff;6,4=7ee3ff;6,5=7ee3ff;7,1=bdecff;7,2=bdecff;7,3=bdecff;7,4=bdecff;7,5=bdecff;8,1=FFFFFF;8,2=FFFFFF;8,3=FFFFFF;8,4=FFFFFF;8,5=FFFFFF +mapping_offset HORIZONTAL: 3,2=6633a5;3,3=6633a5;3,4=6633a5;3,5=6633a5;3,6=6633a5;4,2=3677c9;4,3=3677c9;4,4=3677c9;4,5=3677c9;4,6=3677c9;5,2=12aae4;5,3=12aae4;5,4=12aae4;5,5=12aae4;5,6=12aae4;6,2=2ad7ff;6,3=2ad7ff;6,4=2ad7ff;6,5=2ad7ff;6,6=2ad7ff;7,2=69e0ff;7,3=69e0ff;7,4=69e0ff;7,5=69e0ff;7,6=69e0ff;8,2=bdecff;8,3=bdecff;8,4=bdecff;8,5=bdecff;8,6=bdecff;9,2=FFFFFF;9,3=FFFFFF;9,4=FFFFFF;9,5=FFFFFF;9,6=FFFFFF +mapping RADIAL: 1,1=54ddff;2,1=2ad7ff;3,1=00D1FF;4,1=06bbed;5,1=00D1FF;6,1=2ad7ff;7,1=54ddff;8,1=93e6ff;1,2=00D1FF;2,2=2a88d2;3,2=4e55b7;4,2=6633a5;5,2=4e55b7;6,2=2a88d2;7,2=00D1FF;8,2=54ddff;1,3=00D1FF;2,3=2a88d2;3,3=4e55b7;4,3=6633a5;5,3=4e55b7;6,3=2a88d2;7,3=00D1FF;8,3=54ddff;1,4=54ddff;2,4=2ad7ff;3,4=00D1FF;4,4=06bbed;5,4=00D1FF;6,4=2ad7ff;7,4=54ddff;8,4=93e6ff;1,5=d2efff;2,5=bdecff;3,5=93e6ff;4,5=93e6ff;5,5=93e6ff;6,5=bdecff;7,5=d2efff;8,5=FFFFFF +mapping_offset RADIAL: 3,2=3fdaff;4,2=15d4ff;5,2=00D1FF;6,2=00D1FF;7,2=15d4ff;8,2=3fdaff;9,2=7ee3ff;3,3=06bbed;4,3=3677c9;5,3=5a44ae;6,3=5a44ae;7,3=3677c9;8,3=06bbed;9,3=2ad7ff;3,4=06bbed;4,4=3677c9;5,4=5a44ae;6,4=5a44ae;7,4=3677c9;8,4=06bbed;9,4=2ad7ff;3,5=3fdaff;4,5=15d4ff;5,5=00D1FF;6,5=00D1FF;7,5=15d4ff;8,5=3fdaff;9,5=7ee3ff;3,6=d2efff;4,6=bdecff;5,6=a8e9ff;6,6=a8e9ff;7,6=bdecff;8,6=d2efff;9,6=FFFFFF +mapping DIAGONAL: 1,1=5a44ae;2,1=4e55b7;3,1=4266c0;4,1=2a88d2;5,1=1e99db;6,1=06bbed;7,1=00D1FF;8,1=15d4ff;1,2=4266c0;2,2=2a88d2;3,2=1e99db;4,2=06bbed;5,2=00D1FF;6,2=15d4ff;7,2=3fdaff;8,2=54ddff;1,3=1e99db;2,3=06bbed;3,3=00D1FF;4,3=15d4ff;5,3=3fdaff;6,3=54ddff;7,3=7ee3ff;8,3=93e6ff;1,4=00D1FF;2,4=15d4ff;3,4=3fdaff;4,4=54ddff;5,4=7ee3ff;6,4=93e6ff;7,4=a8e9ff;8,4=d2efff;1,5=3fdaff;2,5=54ddff;3,5=7ee3ff;4,5=93e6ff;5,5=a8e9ff;6,5=d2efff;7,5=e7f2ff;8,5=FFFFFF +mapping_offset DIAGONAL: 3,2=5a44ae;4,2=4e55b7;5,2=3677c9;6,2=2a88d2;7,2=12aae4;8,2=06bbed;9,2=15d4ff;3,3=3677c9;4,3=2a88d2;5,3=12aae4;6,3=06bbed;7,3=15d4ff;8,3=2ad7ff;9,3=54ddff;3,4=12aae4;4,4=06bbed;5,4=15d4ff;6,4=2ad7ff;7,4=54ddff;8,4=69e0ff;9,4=93e6ff;3,5=15d4ff;4,5=2ad7ff;5,5=54ddff;6,5=69e0ff;7,5=93e6ff;8,5=a8e9ff;9,5=d2efff;3,6=54ddff;4,6=69e0ff;5,6=93e6ff;6,6=a8e9ff;7,6=d2efff;8,6=e7f2ff;9,6=FFFFFF +shift 0.0: ff8040 +shift 0.1: e77841 +shift 0.25: c36c44 +shift 0.5: 875848 +shift 0.75: 4b444c +shift 0.99: 12304f +shift 1.0: 103050 diff --git a/vendor/ttfx/tests/geometry_goldens.rs b/vendor/ttfx/tests/geometry_goldens.rs new file mode 100644 index 0000000..c653f8e --- /dev/null +++ b/vendor/ttfx/tests/geometry_goldens.rs @@ -0,0 +1,118 @@ +//! Regenerates the canonical geometry golden lines in Rust and diffs against +//! the CPython-generated fixture (tools/goldens/gen_geometry.py). + +use ttfx::utils::geometry::{self, Coord}; + +fn fbits(x: f64) -> String { + x.to_le_bytes().iter().map(|b| format!("{b:02x}")).collect() +} + +fn coords(cs: &[Coord]) -> String { + cs.iter().map(|c| format!("{},{}", c.column, c.row)).collect::>().join(";") +} + +fn generate_lines() -> Vec { + let mut lines: Vec = Vec::new(); + + for radius in [1, 2, 3, 5, 8, 13, 20] { + for limit in [0, 7, 100] { + for unique in [true, false] { + let got = geometry::find_coords_on_circle(Coord::new(10, 10), radius, limit, unique); + let u = if unique { "True" } else { "False" }; + lines.push(format!("on_circle r={radius} l={limit} u={u}: {}", coords(&got))); + } + } + } + + for diameter in [1, 2, 3, 4, 7, 10, 15] { + let got = geometry::find_coords_in_circle(Coord::new(5, -3), diameter); + lines.push(format!("in_circle d={diameter}: {}", coords(&got))); + } + + for distance in [0, 1, 2, 5] { + lines.push(format!("in_rect d={distance}: {}", coords(&geometry::find_coords_in_rect(Coord::new(3, 4), distance)))); + } + + for (hw, hh) in [(0, 3), (3, 0), (1, 1), (4, 2), (5, 7)] { + lines.push(format!("on_rect {hw},{hh}: {}", coords(&geometry::find_coords_on_rect(Coord::new(0, 0), hw, hh)))); + } + + for (origin, target) in [ + (Coord::new(0, 0), Coord::new(10, 5)), + (Coord::new(3, 3), Coord::new(3, 3)), + (Coord::new(-5, 2), Coord::new(7, -9)), + ] { + for offset in [0.0, 1.5, 4.0, 10.25, -2.0] { + let c = geometry::extrapolate_along_ray(origin, target, offset); + lines.push(format!( + "extrapolate {},{}->{},{}+{offset:?}: {},{}", + origin.column, origin.row, target.column, target.row, c.column, c.row + )); + } + } + + let bezier_cases: Vec<(Coord, Vec, Coord)> = vec![ + (Coord::new(0, 0), vec![Coord::new(5, 10)], Coord::new(10, 0)), + (Coord::new(0, 0), vec![Coord::new(3, 8), Coord::new(7, -2)], Coord::new(12, 4)), + ( + Coord::new(-4, -4), + vec![Coord::new(0, 20), Coord::new(9, 9), Coord::new(-3, 2)], + Coord::new(6, -6), + ), + ]; + for (start, control, end) in &bezier_cases { + let mut pts = Vec::new(); + for i in 0..=20 { + let t = i as f64 / 20.0; + pts.push(geometry::find_coord_on_bezier_curve(*start, control, *end, t)); + } + lines.push(format!("bezier {}cp: {}", control.len(), coords(&pts))); + lines.push(format!( + "bezier_len {}cp: {}", + control.len(), + fbits(geometry::find_length_of_bezier_curve(*start, control, *end)) + )); + } + + let mut line_pts = Vec::new(); + for i in -5..=25 { + let t = i as f64 / 20.0; + line_pts.push(geometry::find_coord_on_line(Coord::new(-3, 7), Coord::new(14, -2), t)); + } + lines.push(format!("on_line: {}", coords(&line_pts))); + + for double in [false, true] { + let v = geometry::find_length_of_line(Coord::new(1, 2), Coord::new(-7, 11), double); + let d = if double { "True" } else { "False" }; + lines.push(format!("line_len double={d}: {}", fbits(v))); + } + + for coord in [Coord::new(1, 1), Coord::new(5, 3), Coord::new(10, 8), Coord::new(3, 8), Coord::new(10, 1)] { + let v = geometry::find_normalized_distance_from_center(1, 8, 1, 10, coord).unwrap(); + lines.push(format!("norm_dist {},{}: {}", coord.column, coord.row, fbits(v))); + } + + lines +} + +#[test] +fn geometry_matches_python() { + let fixture = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/geometry_goldens.txt" + )) + .expect("run tools/goldens/gen_geometry.py first"); + let expected: Vec<&str> = fixture.lines().collect(); + let actual = generate_lines(); + assert_eq!(expected.len(), actual.len(), "line count mismatch"); + let mut mismatches = 0; + for (e, a) in expected.iter().zip(actual.iter()) { + if e != a { + if mismatches < 5 { + eprintln!("expected: {e}\n actual: {a}\n"); + } + mismatches += 1; + } + } + assert_eq!(mismatches, 0, "{mismatches} geometry golden lines diverge"); +} diff --git a/vendor/ttfx/tests/graphics_goldens.rs b/vendor/ttfx/tests/graphics_goldens.rs new file mode 100644 index 0000000..dea42ff --- /dev/null +++ b/vendor/ttfx/tests/graphics_goldens.rs @@ -0,0 +1,100 @@ +//! Gradient/color goldens vs CPython (tools/goldens/gen_graphics.py). + +use ttfx::utils::graphics::{shift_color_towards, Color, Gradient, GradientDirection}; + +fn generate_lines() -> Vec { + let mut lines: Vec = Vec::new(); + + let grad_cases: Vec<(Vec<&str>, Vec, bool)> = vec![ + (vec!["8A008A", "00D1FF", "FFFFFF"], vec![12], false), + (vec!["8A008A", "00D1FF", "FFFFFF"], vec![6, 3], false), + (vec!["ffffff", "000000"], vec![10], false), + (vec!["000000", "ffffff"], vec![7], false), + (vec!["ff0000", "00ff00", "0000ff"], vec![5], true), + (vec!["123456"], vec![4], false), + (vec!["ff5733", "33ff57", "5733ff", "f0f0f0"], vec![3, 9], false), + (vec!["0a0b0c", "f1e2d3"], vec![1], false), + ]; + for (stops, steps, do_loop) in &grad_cases { + let colors: Vec = stops.iter().map(|s| Color::from_hex(s).unwrap()).collect(); + // tuple-shaped steps in the Python generator (never scalar), so skip + // the int-only validation like upstream does for tuples + let g = Gradient::new(&colors, steps, false, *do_loop).unwrap(); + let steps_repr = if steps.len() == 1 { + format!("({},)", steps[0]) + } else { + format!("({})", steps.iter().map(|s| s.to_string()).collect::>().join(", ")) + }; + let py_loop = if *do_loop { "True" } else { "False" }; + lines.push(format!( + "grad {} s={steps_repr} loop={py_loop}: {}", + stops.join("+"), + g.spectrum.iter().map(|c| c.rgb_color.clone()).collect::>().join(";") + )); + } + + let colors: Vec = ["8A008A", "00D1FF", "FFFFFF"].iter().map(|s| Color::from_hex(s).unwrap()).collect(); + let g = Gradient::with_steps(&colors, 12, false).unwrap(); + for i in 0..=20 { + let f = i as f64 / 20.0; + let f_label = if f == f.trunc() { format!("{f:.1}") } else { format!("{f}") }; + lines.push(format!("frac {f_label}: {}", g.get_color_at_fraction(f).unwrap().rgb_color)); + } + + for (name, direction) in [ + ("VERTICAL", GradientDirection::Vertical), + ("HORIZONTAL", GradientDirection::Horizontal), + ("RADIAL", GradientDirection::Radial), + ("DIAGONAL", GradientDirection::Diagonal), + ] { + for (label, (min_row, max_row, min_column, max_column)) in + [("mapping", (1, 5, 1, 8)), ("mapping_offset", (2, 6, 3, 9))] + { + let mapping = g + .build_coordinate_color_mapping(min_row, max_row, min_column, max_column, direction) + .unwrap(); + let entries = mapping + .iter() + .map(|(c, col)| format!("{},{}={}", c.column, c.row, col.rgb_color)) + .collect::>() + .join(";"); + lines.push(format!("{label} {name}: {entries}")); + } + } + + for factor in [0.0, 0.1, 0.25, 0.5, 0.75, 0.99, 1.0] { + let c = shift_color_towards( + &Color::from_hex("ff8040").unwrap(), + &Color::from_hex("103050").unwrap(), + factor, + ) + .unwrap(); + let f_label = if factor == factor.trunc() { format!("{factor:.1}") } else { format!("{factor}") }; + lines.push(format!("shift {f_label}: {}", c.rgb_color)); + } + + lines +} + +#[test] +fn graphics_matches_python() { + let fixture = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/graphics_goldens.txt" + )) + .expect("run tools/goldens/gen_graphics.py first"); + let expected: Vec<&str> = fixture.lines().collect(); + let mut actual_iter = generate_lines().into_iter(); + let mut mismatches = 0; + for e in &expected { + let a = actual_iter.next().unwrap_or_default(); + // mapping lines are ordered — compare in order + if *e != a { + if mismatches < 5 { + eprintln!("expected: {e}\n actual: {a}\n"); + } + mismatches += 1; + } + } + assert_eq!(mismatches, 0, "{mismatches} graphics golden lines diverge"); +} diff --git a/vendor/ttfx/tests/terminal_grouping.rs b/vendor/ttfx/tests/terminal_grouping.rs new file mode 100644 index 0000000..6c22645 --- /dev/null +++ b/vendor/ttfx/tests/terminal_grouping.rs @@ -0,0 +1,174 @@ +use ttfx::engine::character::CharId; +use ttfx::engine::terminal::{CharacterFilter, CharacterGroup, Terminal, TerminalConfig}; +use ttfx::utils::geometry::Coord; + +const GROUPINGS: [CharacterGroup; 10] = [ + CharacterGroup::ColumnLeftToRight, + CharacterGroup::ColumnRightToLeft, + CharacterGroup::RowTopToBottom, + CharacterGroup::RowBottomToTop, + CharacterGroup::DiagonalBottomLeftToTopRight, + CharacterGroup::DiagonalTopRightToBottomLeft, + CharacterGroup::DiagonalTopLeftToBottomRight, + CharacterGroup::DiagonalBottomRightToTopLeft, + CharacterGroup::CenterToOutside, + CharacterGroup::OutsideToCenter, +]; + +fn reference_grouping( + terminal: &Terminal, + filter: CharacterFilter, + grouping: CharacterGroup, +) -> Vec> { + let mut all = terminal.collect_characters(filter); + all.sort_by_key(|&id| { + let c = terminal.arena[id.0 as usize].input_coord; + (c.row, c.column) + }); + let coord = |id: &CharId| terminal.arena[id.0 as usize].input_coord; + match grouping { + CharacterGroup::ColumnLeftToRight | CharacterGroup::ColumnRightToLeft => { + let mut groups = Vec::new(); + for key in 0..=terminal.canvas.right { + let group = all.iter().copied().filter(|id| coord(id).column == key).collect::>(); + if !group.is_empty() { + groups.push(group); + } + } + if grouping == CharacterGroup::ColumnRightToLeft { + groups.reverse(); + } + groups + } + CharacterGroup::RowBottomToTop | CharacterGroup::RowTopToBottom => { + let mut groups = Vec::new(); + for key in 0..=terminal.canvas.top { + let group = all.iter().copied().filter(|id| coord(id).row == key).collect::>(); + if !group.is_empty() { + groups.push(group); + } + } + if grouping == CharacterGroup::RowTopToBottom { + groups.reverse(); + } + groups + } + CharacterGroup::DiagonalBottomLeftToTopRight | CharacterGroup::DiagonalTopRightToBottomLeft => { + let mut groups = Vec::new(); + for key in 0..=(terminal.canvas.top + terminal.canvas.right) { + let group = all + .iter() + .copied() + .filter(|id| { + let c = coord(id); + c.row + c.column == key + }) + .collect::>(); + if !group.is_empty() { + groups.push(group); + } + } + if grouping == CharacterGroup::DiagonalTopRightToBottomLeft { + groups.reverse(); + } + groups + } + CharacterGroup::DiagonalTopLeftToBottomRight | CharacterGroup::DiagonalBottomRightToTopLeft => { + let mut groups = Vec::new(); + for key in + (terminal.canvas.left - terminal.canvas.top)..=(terminal.canvas.right - terminal.canvas.bottom) + { + let group = all + .iter() + .copied() + .filter(|id| { + let c = coord(id); + c.column - c.row == key + }) + .collect::>(); + if !group.is_empty() { + groups.push(group); + } + } + if grouping == CharacterGroup::DiagonalBottomRightToTopLeft { + groups.reverse(); + } + groups + } + CharacterGroup::CenterToOutside | CharacterGroup::OutsideToCenter => { + let mut distances: Vec<(i64, Vec)> = Vec::new(); + for id in all { + let c = coord(&id); + let distance = (c.column - terminal.canvas.text_center.column).abs() + + (c.row - terminal.canvas.text_center.row).abs(); + if let Some((_, group)) = distances.iter_mut().find(|(key, _)| *key == distance) { + group.push(id); + } else { + distances.push((distance, vec![id])); + } + } + distances.sort_by_key(|(distance, _)| *distance); + if grouping == CharacterGroup::OutsideToCenter { + distances.reverse(); + } + distances.into_iter().map(|(_, group)| group).collect() + } + } +} + +#[test] +fn direct_buckets_match_scan_grouping_order_and_bounds() { + let mut terminal = Terminal::new( + "A C\n DE\nF G", + TerminalConfig { + canvas_width: 8, + canvas_height: 6, + ignore_terminal_dimensions: true, + ..Default::default() + }, + ) + .unwrap(); + + for coord in [ + Coord::new(0, 0), + Coord::new(-5, 2), + Coord::new(2, 99), + Coord::new(1, 3), + Coord::new(1, 3), + Coord::new(99, 99), + ] { + terminal.add_character("+", coord); + } + + let filters = [ + CharacterFilter::default(), + CharacterFilter { + input_chars: true, + inner_fill_chars: true, + outer_fill_chars: true, + added_chars: true, + }, + CharacterFilter { + input_chars: false, + inner_fill_chars: false, + outer_fill_chars: false, + added_chars: true, + }, + CharacterFilter { + input_chars: false, + inner_fill_chars: false, + outer_fill_chars: false, + added_chars: false, + }, + ]; + + for filter in filters { + for grouping in GROUPINGS { + assert_eq!( + terminal.get_characters_grouped(filter, grouping), + reference_grouping(&terminal, filter, grouping), + "grouping {grouping:?} diverged" + ); + } + } +} diff --git a/vendor/ttfx/tools/demo/build_gallery.py b/vendor/ttfx/tools/demo/build_gallery.py new file mode 100644 index 0000000..367b7b4 --- /dev/null +++ b/vendor/ttfx/tools/demo/build_gallery.py @@ -0,0 +1,289 @@ +"""Build the effects gallery artifact: every effect's GIF embedded as a data URI. + +Usage: build_gallery.py GIF_DIR OUT.html +""" + +from __future__ import annotations + +import base64 +import importlib +import pkgutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "reference/tte")) + +import terminaltexteffects.effects as fx # noqa: E402 + +gif_dir, out_path = Path(sys.argv[1]), Path(sys.argv[2]) + +# one-line descriptions straight from upstream's own help text +helps: dict[str, str] = {} +for mi in pkgutil.iter_modules(fx.__path__): + m = importlib.import_module(f"terminaltexteffects.effects.{mi.name}") + if hasattr(m, "get_effect_resources"): + name, _, cfg = m.get_effect_resources() + helps[name] = cfg.parser_spec.help.strip().rstrip(".") + +# demo-only argument tweaks, so the caption matches what the GIF actually shows +extra_args: dict[str, str] = {} +args_file = gif_dir / "args.txt" +if args_file.exists(): + for line in args_file.read_text().splitlines(): + if "|" in line: + k, v = line.split("|", 1) + extra_args[k] = v.strip() + +effects = sorted(p.stem for p in gif_dir.glob("*.gif")) + +cards = [] +for name in effects: + b64 = base64.b64encode((gif_dir / f"{name}.gif").read_bytes()).decode() + desc = helps.get(name, "") + cmd = f"ttfx {name}" + (f" {extra_args[name]}" if name in extra_args else "") + cards.append(f"""
+
{name} effect animating the Omarchy logo
+
+

{name}

+

{desc}

+ $ omarchy-show-logo | {cmd} +
+
""") + +total_kb = sum((gif_dir / f"{n}.gif").stat().st_size for n in effects) // 1024 + +html = f"""ttfx — all 37 effects + + +
+
+

ttfx · rust port of terminaltexteffects

+

All 37 effects_

+

Every effect in the library, animating the Omarchy logo straight + from /usr/share/omarchy/logo.txt. These frames come out of the Rust + binary — and each one is byte-identical to what the original Python produces + from the same input and seed.

+
+
37effects
+
354parity cases
+
3.3 MBstatic binary
+
2 msstartup
+
0runtime deps
+
+
+ +
+{chr(10).join(cards)} +
+ +
+
{len(effects)} effects · {total_kb} KB of animation · 81×10 logo on an 84×13 canvas, seed 3
+
Descriptions are upstream's own help text. Two effects take demo-only arguments so + a seven-letter word shows the effect off: errorcorrect needs enough swap pairs, and the + time-budgeted effects are shortened.
+
+
+""" + +out_path.write_text(html) +print(f"{out_path}: {len(effects)} effects, {out_path.stat().st_size // 1024} KB") diff --git a/vendor/ttfx/tools/demo/make_gif.py b/vendor/ttfx/tools/demo/make_gif.py new file mode 100644 index 0000000..b5c5aab --- /dev/null +++ b/vendor/ttfx/tools/demo/make_gif.py @@ -0,0 +1,220 @@ +"""Render an effect's frame dump to an animated GIF. + +Parses ttfx --parity-dump output (length-prefixed frames), converts each +frame's ANSI grid into an SVG, and pipes the sequence through ImageMagick. +No Python dependencies beyond the stdlib. + +Usage: make_gif.py OUT.gif EFFECT [effect args...] +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BIN = ROOT / "target/release/ttfx" + +import os + +# Overridable so the same renderer handles a short word or the full ASCII logo. +TEXT_FILE = os.environ.get("TTFX_DEMO_TEXT_FILE") +TEXT = Path(TEXT_FILE).read_text().rstrip("\n") if TEXT_FILE else "Omarchy" +COLS = int(os.environ.get("TTFX_DEMO_COLS", 44)) +ROWS = int(os.environ.get("TTFX_DEMO_ROWS", 13)) +CELL_W = float(os.environ.get("TTFX_DEMO_CELL_W", 11)) +CELL_H = float(os.environ.get("TTFX_DEMO_CELL_H", 20)) +FONT_SIZE = float(os.environ.get("TTFX_DEMO_FONT_SIZE", 16)) +MAX_FRAMES = int(os.environ.get("TTFX_DEMO_MAX_FRAMES", 70)) +BG = "#12121a" + +SGR = re.compile(r"\x1b\[([0-9;]*)m") + + +def parse_frame(frame: str) -> list[list[tuple[str, str, str | None, bool]]]: + """-> rows of (char, fg, bg, bold).""" + rows = [] + for line in frame.split("\n"): + cells = [] + fg, bg, bold = "#c8c8d0", None, False + i = 0 + while i < len(line): + m = SGR.match(line, i) + if m: + params = [p for p in m.group(1).split(";") if p != ""] or ["0"] + j = 0 + while j < len(params): + p = int(params[j]) + if p == 0: + fg, bg, bold = "#c8c8d0", None, False + elif p == 1: + bold = True + elif p in (38, 48) and j + 1 < len(params): + mode = int(params[j + 1]) + if mode == 2 and j + 4 < len(params): + c = "#%02x%02x%02x" % tuple(int(params[j + k]) for k in (2, 3, 4)) + j += 4 + elif mode == 5 and j + 2 < len(params): + c = xterm_hex(int(params[j + 2])) + j += 2 + else: + j += 1 + continue + if p == 38: + fg = c + else: + bg = c + j += 1 + i = m.end() + continue + cells.append((line[i], fg, bg, bold)) + i += 1 + rows.append(cells) + return rows + + +_XTERM = None + + +def xterm_hex(n: int) -> str: + global _XTERM + if _XTERM is None: + table = ROOT / "src/utils/hexterm_table.rs" + _XTERM = re.findall(r'"([0-9a-f]{6})"', table.read_text()) + return "#" + _XTERM[n] if n < len(_XTERM) else "#000000" + + +# Block-drawing glyphs are drawn as rects, not text: font glyphs don't tile +# seamlessly at arbitrary cell sizes, which would put seams through solid areas +# of the logo. Values are (y_fraction, height_fraction, opacity) within the cell. +BLOCKS = { + "█": (0.0, 1.0, 1.0), # █ full + "▄": (0.5, 0.5, 1.0), # ▄ lower half + "▀": (0.0, 0.5, 1.0), # ▀ upper half + "▓": (0.0, 1.0, 0.75), # ▓ dark shade + "▒": (0.0, 1.0, 0.5), # ▒ medium shade + "░": (0.0, 1.0, 0.25), # ░ light shade +} +# half-width blocks need an x offset too: (x_frac, w_frac) +HALF_BLOCKS = {"▌": (0.0, 0.5), "▐": (0.5, 0.5)} # ▌ ▐ + + +def svg_for(frame: str) -> str: + rows = parse_frame(frame) + w, h = COLS * CELL_W, ROWS * CELL_H + out = [ + f'', + f'', + ] + # backgrounds first + for r, cells in enumerate(rows[:ROWS]): + for c, (_, _, bg, _) in enumerate(cells[:COLS]): + if bg: + out.append( + f'' + ) + # block glyphs as rects, so solid areas tile without seams + for r, cells in enumerate(rows[:ROWS]): + for c, (ch, fg, _, _) in enumerate(cells[:COLS]): + if ch in BLOCKS: + yf, hf, op = BLOCKS[ch] + alpha = f' opacity="{op}"' if op < 1.0 else "" + out.append( + f'' + ) + elif ch in HALF_BLOCKS: + xf, wf = HALF_BLOCKS[ch] + out.append( + f'' + ) + # then glyphs, grouped into runs of identical style. textLength pins each + # run to an exact multiple of the cell width so long rows can't drift out + # of their columns when font metrics don't match the cell size. + for r, cells in enumerate(rows[:ROWS]): + y = r * CELL_H + FONT_SIZE * 0.8 + run, run_fg, run_bold, run_start = [], None, False, 0 + + def flush(): + if run and "".join(run).strip(): + text = "".join(run).replace("&", "&").replace("<", "<").replace(">", ">") + weight = ' font-weight="bold"' if run_bold else "" + out.append( + f'{text}' + ) + + for c, (ch, fg, _, bold) in enumerate(cells[:COLS]): + is_block = ch in BLOCKS or ch in HALF_BLOCKS + if is_block or fg != run_fg or bold != run_bold: + flush() + run, run_fg, run_bold, run_start = [], fg, bold, c + # blocks were already drawn as rects; keep them out of the text run + if not is_block: + run.append(ch) + else: + run_start = c + 1 + flush() + out.append("") + return "".join(out) + + +def read_frames(effect_args: list[str]) -> list[str]: + cmd = [ + str(BIN), "--seed", "3", "--frame-rate", "0", "--virtual-clock", "--parity-dump", + "--canvas-width", str(COLS), "--canvas-height", str(ROWS), + "--anchor-canvas", "c", "--anchor-text", "c", "--ignore-terminal-dimensions", + *effect_args, + ] + proc = subprocess.run(cmd, input=TEXT.encode(), capture_output=True) + data, frames, i = proc.stdout, [], 0 + while i < len(data): + nl = data.index(b"\n", i) + n = int(data[i:nl]) + i = nl + 1 + frames.append(data[i : i + n].decode()) + i += n + 1 + return frames + + +def main() -> int: + out_path, effect_args = sys.argv[1], sys.argv[2:] + frames = read_frames(effect_args) + if not frames: + print(f"no frames for {effect_args}", file=sys.stderr) + return 1 + # sample evenly, always keeping the final frame, and hold the last one + if len(frames) > MAX_FRAMES: + step = len(frames) / MAX_FRAMES + picked = [frames[min(int(i * step), len(frames) - 1)] for i in range(MAX_FRAMES)] + picked[-1] = frames[-1] + else: + picked = frames + picked += [frames[-1]] * 8 # hold the finished text + + # per-output temp dir so parallel invocations don't clobber each other + tmp = Path(out_path).parent / f"_svg_{Path(out_path).stem}" + tmp.mkdir(parents=True, exist_ok=True) + paths = [] + for idx, frame in enumerate(picked): + p = tmp / f"f{idx:04d}.svg" + p.write_text(svg_for(frame)) + paths.append(str(p)) + + subprocess.run( + ["magick", "-delay", "6", "-loop", "0", *paths, "-layers", "optimize", out_path], + check=True, + ) + for p in paths: + Path(p).unlink() + print(f"{out_path}: {len(picked)} frames, {Path(out_path).stat().st_size//1024}KB") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ttfx/tools/goldens/gen_easing.py b/vendor/ttfx/tools/goldens/gen_easing.py new file mode 100644 index 0000000..db03a91 --- /dev/null +++ b/vendor/ttfx/tools/goldens/gen_easing.py @@ -0,0 +1,39 @@ +"""Generate easing goldens: for each easing function, 1001 f64 samples over +[0, 1], written as little-endian binary. Rust test compares bit patterns. +Order must match EASING_GOLDEN_ORDER in tests/easing_goldens.rs. +""" + +from __future__ import annotations + +import struct +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "reference" / "tte")) + +from terminaltexteffects.utils import easing # noqa: E402 + +NAMED = [ + easing.linear, easing.in_sine, easing.out_sine, easing.in_out_sine, + easing.in_quad, easing.out_quad, easing.in_out_quad, + easing.in_cubic, easing.out_cubic, easing.in_out_cubic, + easing.in_quart, easing.out_quart, easing.in_out_quart, + easing.in_quint, easing.out_quint, easing.in_out_quint, + easing.in_expo, easing.out_expo, easing.in_out_expo, + easing.in_circ, easing.out_circ, easing.in_out_circ, + easing.in_back, easing.out_back, easing.in_out_back, + easing.in_elastic, easing.out_elastic, easing.in_out_elastic, + easing.in_bounce, easing.out_bounce, easing.in_out_bounce, +] + +BEZIERS = [(0.25, 0.1, 0.25, 1.0), (0.42, 0.0, 0.58, 1.0), (0.68, -0.55, 0.265, 1.55)] + +out = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "easing_goldens.bin" +out.parent.mkdir(parents=True, exist_ok=True) + +with out.open("wb") as f: + for fn in NAMED + [easing.make_easing(*b) for b in BEZIERS]: + for i in range(1001): + f.write(struct.pack(" str: + if isinstance(caller, motion_mod.Path): + return f"path:{caller.path_id}" + if isinstance(caller, motion_mod.Waypoint): + return f"wp:{caller.waypoint_id}" + if isinstance(caller, animation_mod.Scene): + return f"scene:{caller.scene_id}" + return f"?:{caller}" + + +_orig_handle = EventHandler._handle_event + + +def logged_handle(self, event, caller): + LOG.append(f"EVENT char={self.character.character_id} {event.name} caller={caller_label(caller)}") + _orig_handle(self, event, caller) + + +EventHandler._handle_event = logged_handle + + +def esc(s: str) -> str: + return s.replace("\x1b", "\\e") + + +def make_terminal() -> Terminal: + config = TerminalConfig._build_config() + config.canvas_width = 20 + config.canvas_height = 10 + config.ignore_terminal_dimensions = True + config.frame_rate = 0 + return Terminal("abcdef\nghijkl", config) + + +def snapshot(tick: int, chars) -> None: + for ch in chars: + ap = ch.motion.active_path.path_id if ch.motion.active_path else "-" + sc = ch.animation.active_scene.scene_id if ch.animation.active_scene else "-" + LOG.append( + f"tick={tick} char={ch.character_id} coord={ch.motion.current_coord.column}," + f"{ch.motion.current_coord.row} layer={ch.layer} path={ap} scene={sc} " + f"vis={esc(ch.animation.current_character_visual.formatted_symbol)} active={ch.is_active}" + ) + + +def run_ticks(chars, n: int, start: int = 0) -> None: + active = set(chars) + for tick in range(start, start + n): + for ch in tuple(sorted(active, key=lambda c: c.character_id)): + ch.tick() + active -= {c for c in active if not c.is_active} + snapshot(tick, chars) + + +def scenario_motion_basic() -> None: + LOG.append("=== scenario_motion_basic ===") + terminal = make_terminal() + chars = terminal.get_characters()[:2] + a, b = chars + pa = a.motion.new_path(speed=0.7, path_id="pa") + pa.new_waypoint(Coord(15, 8)) + pa.new_waypoint(Coord(18, 2), bezier_control=Coord(1, 1)) + pb = b.motion.new_path(speed=1.3, ease=easing.out_back, path_id="pb") + pb.new_waypoint(Coord(3, 9)) + a.motion.activate_path(pa) + b.motion.activate_path(pb) + run_ticks(chars, 30) + + +def scenario_hold_and_loop() -> None: + LOG.append("=== scenario_hold_and_loop ===") + terminal = make_terminal() + chars = terminal.get_characters()[:2] + a, b = chars + pa = a.motion.new_path(speed=2.0, hold_time=3, path_id="hold") + pa.new_waypoint(Coord(10, 5)) + pb = b.motion.new_path(speed=2.0, loop=True, path_id="looper") + pb.new_waypoint(Coord(6, 3)) + pb.new_waypoint(Coord(9, 6)) + a.motion.activate_path(pa) + b.motion.activate_path(pb) + run_ticks(chars, 20) + + +def scenario_chained_paths_and_events() -> None: + LOG.append("=== scenario_chained_paths_and_events ===") + terminal = make_terminal() + chars = terminal.get_characters()[:1] + a = chars[0] + p1 = a.motion.new_path(speed=1.5, path_id="p1") + p1.new_waypoint(Coord(5, 5)) + p2 = a.motion.new_path(speed=1.5, path_id="p2", layer=2) + p2.new_waypoint(Coord(10, 2)) + p3 = a.motion.new_path(speed=1.5, path_id="p3") + p3.new_waypoint(Coord(1, 1)) + a.motion.chain_paths([p1, p2, p3]) + a.event_handler.register_event( + EventHandler.Event.PATH_COMPLETE, p3, EventHandler.Action.SET_COORDINATE, Coord(19, 9) + ) + a.event_handler.register_event(EventHandler.Event.PATH_HOLDING, p1, EventHandler.Action.SET_LAYER, 7) + a.motion.activate_path(p1) + run_ticks(chars, 25) + + +def scenario_scenes() -> None: + LOG.append("=== scenario_scenes ===") + terminal = make_terminal() + chars = terminal.get_characters()[:3] + a, b, c = chars + sa = a.animation.new_scene(scene_id="plain") + sa.add_frame("X", 2, colors=ColorPair(fg=Color("ff0000"))) + sa.add_frame("Y", 3, colors=ColorPair(fg=Color("00ff00"), bg=Color(21))) + sa.add_frame("Z", 1, bold=True) + a.animation.activate_scene(sa) + + sb = b.animation.new_scene(scene_id="looping", is_looping=True) + sb.add_frame("1", 2) + sb.add_frame("2", 2) + b.animation.activate_scene(sb) + + sc = c.animation.new_scene(scene_id="eased", ease=easing.in_out_cubic) + grad = Gradient(Color("000000"), Color("ffffff"), steps=8) + sc.apply_gradient_to_symbols(["*", "+", "o"], 2, fg_gradient=grad) + c.animation.activate_scene(sc) + run_ticks(chars, 24) + + +def scenario_synced_scene() -> None: + LOG.append("=== scenario_synced_scene ===") + terminal = make_terminal() + chars = terminal.get_characters()[:2] + a, b = chars + for ch, sync, pid in ((a, animation_mod.Scene.SyncMetric.STEP, "sp"), (b, animation_mod.Scene.SyncMetric.DISTANCE, "dp")): + path = ch.motion.new_path(speed=0.9, path_id=pid) + path.new_waypoint(Coord(16, 9)) + path.new_waypoint(Coord(2, 2)) + scene = ch.animation.new_scene(sync=sync, scene_id=f"sync_{pid}") + for i, sym in enumerate("abcdefgh"): + scene.add_frame(sym, 1) + ch.motion.activate_path(path) + ch.animation.activate_scene(scene) + run_ticks(chars, 30) + + +def scenario_scene_events_and_resume() -> None: + LOG.append("=== scenario_scene_events_and_resume ===") + terminal = make_terminal() + chars = terminal.get_characters()[:1] + a = chars[0] + s1 = a.animation.new_scene(scene_id="s1") + s1.add_frame("A", 3) + s1.add_frame("B", 3) + s2 = a.animation.new_scene(scene_id="s2") + s2.add_frame("C", 2) + a.event_handler.register_event( + EventHandler.Event.SCENE_COMPLETE, s1, EventHandler.Action.ACTIVATE_SCENE, s2 + ) + path = a.motion.new_path(speed=1.0, path_id="mover") + path.new_waypoint(Coord(8, 8)) + a.event_handler.register_event( + EventHandler.Event.SCENE_COMPLETE, s2, EventHandler.Action.ACTIVATE_PATH, path + ) + a.animation.activate_scene(s1) + # partial play then re-activate to prove resume semantics + run_ticks(chars, 2) + a.animation.activate_scene(s1) + run_ticks(chars, 20, start=2) + + +SCENARIOS = [ + scenario_motion_basic, + scenario_hold_and_loop, + scenario_chained_paths_and_events, + scenario_scenes, + scenario_synced_scene, + scenario_scene_events_and_resume, +] + +for scenario in SCENARIOS: + scenario() + +out = P(__file__).resolve().parents[2] / "tests" / "fixtures" / "engine_traces.txt" +out.write_text("\n".join(LOG) + "\n") +print(f"wrote {out} ({len(LOG)} lines)") diff --git a/vendor/ttfx/tools/goldens/gen_geometry.py b/vendor/ttfx/tools/goldens/gen_geometry.py new file mode 100644 index 0000000..ca3a3ad --- /dev/null +++ b/vendor/ttfx/tools/goldens/gen_geometry.py @@ -0,0 +1,79 @@ +"""Generate geometry goldens as canonical text lines. The Rust test +(tests/geometry_goldens.rs) regenerates the same lines and diffs. +Floats are emitted as f64 bit patterns for exactness. +""" + +from __future__ import annotations + +import struct +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "reference" / "tte")) + +from terminaltexteffects.utils import geometry # noqa: E402 +from terminaltexteffects.utils.geometry import Coord # noqa: E402 + + +def fbits(x: float) -> str: + return struct.pack(" str: + return ";".join(f"{c.column},{c.row}" for c in cs) + + +lines: list[str] = [] + +for radius in [1, 2, 3, 5, 8, 13, 20]: + for limit in [0, 7, 100]: + for unique in [True, False]: + got = geometry.find_coords_on_circle(Coord(10, 10), radius, limit, unique=unique) + lines.append(f"on_circle r={radius} l={limit} u={unique}: {coords(got)}") + +for diameter in [1, 2, 3, 4, 7, 10, 15]: + got = geometry.find_coords_in_circle(Coord(5, -3), diameter) + lines.append(f"in_circle d={diameter}: {coords(got)}") + +for distance in [0, 1, 2, 5]: + lines.append(f"in_rect d={distance}: {coords(geometry.find_coords_in_rect(Coord(3, 4), distance))}") + +for hw, hh in [(0, 3), (3, 0), (1, 1), (4, 2), (5, 7)]: + lines.append(f"on_rect {hw},{hh}: {coords(geometry.find_coords_on_rect(Coord(0, 0), hw, hh))}") + +for origin, target in [(Coord(0, 0), Coord(10, 5)), (Coord(3, 3), Coord(3, 3)), (Coord(-5, 2), Coord(7, -9))]: + for offset in [0.0, 1.5, 4.0, 10.25, -2.0]: + c = geometry.extrapolate_along_ray(origin, target, offset) + lines.append(f"extrapolate {origin.column},{origin.row}->{target.column},{target.row}+{offset}: {c.column},{c.row}") + +bezier_cases = [ + (Coord(0, 0), (Coord(5, 10),), Coord(10, 0)), + (Coord(0, 0), (Coord(3, 8), Coord(7, -2)), Coord(12, 4)), + (Coord(-4, -4), (Coord(0, 20), Coord(9, 9), Coord(-3, 2)), Coord(6, -6)), +] +for start, control, end in bezier_cases: + pts = [] + for i in range(21): + t = i / 20 + c = geometry.find_coord_on_bezier_curve(start, control, end, t) + pts.append(c) + lines.append(f"bezier {len(control)}cp: {coords(pts)}") + lines.append(f"bezier_len {len(control)}cp: {fbits(geometry.find_length_of_bezier_curve(start, control, end))}") + +line_pts = [] +for i in range(-5, 26): + t = i / 20 + line_pts.append(geometry.find_coord_on_line(Coord(-3, 7), Coord(14, -2), t)) +lines.append(f"on_line: {coords(line_pts)}") + +for double in [False, True]: + v = geometry.find_length_of_line(Coord(1, 2), Coord(-7, 11), double_row_diff=double) + lines.append(f"line_len double={double}: {fbits(v)}") + +for coord in [Coord(1, 1), Coord(5, 3), Coord(10, 8), Coord(3, 8), Coord(10, 1)]: + v = geometry.find_normalized_distance_from_center(1, 8, 1, 10, coord) + lines.append(f"norm_dist {coord.column},{coord.row}: {fbits(v)}") + +out = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "geometry_goldens.txt" +out.write_text("\n".join(lines) + "\n") +print(f"wrote {out} ({len(lines)} lines)") diff --git a/vendor/ttfx/tools/goldens/gen_graphics.py b/vendor/ttfx/tools/goldens/gen_graphics.py new file mode 100644 index 0000000..47c9354 --- /dev/null +++ b/vendor/ttfx/tools/goldens/gen_graphics.py @@ -0,0 +1,47 @@ +"""Generate gradient/color goldens as canonical text lines.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "reference" / "tte")) + +from terminaltexteffects.utils.graphics import Color, Gradient, shift_color_towards # noqa: E402 + +lines: list[str] = [] + +grad_cases = [ + (["8A008A", "00D1FF", "FFFFFF"], (12,), False), + (["8A008A", "00D1FF", "FFFFFF"], (6, 3), False), + (["ffffff", "000000"], (10,), False), + (["000000", "ffffff"], (7,), False), + (["ff0000", "00ff00", "0000ff"], (5,), True), + (["123456"], (4,), False), + (["ff5733", "33ff57", "5733ff", "f0f0f0"], (3, 9), False), + (["0a0b0c", "f1e2d3"], (1,), False), +] +for stops, steps, loop in grad_cases: + g = Gradient(*[Color(s) for s in stops], steps=steps, loop=loop) + lines.append(f"grad {'+'.join(stops)} s={steps} loop={loop}: {';'.join(c.rgb_color for c in g.spectrum)}") + +g = Gradient(Color("8A008A"), Color("00D1FF"), Color("FFFFFF"), steps=12) +for i in range(21): + f = i / 20 + lines.append(f"frac {f}: {g.get_color_at_fraction(f).rgb_color}") + +for direction in ["VERTICAL", "HORIZONTAL", "RADIAL", "DIAGONAL"]: + mapping = g.build_coordinate_color_mapping(1, 5, 1, 8, getattr(Gradient.Direction, direction)) + entries = ";".join(f"{c.column},{c.row}={col.rgb_color}" for c, col in mapping.items()) + lines.append(f"mapping {direction}: {entries}") + mapping = g.build_coordinate_color_mapping(2, 6, 3, 9, getattr(Gradient.Direction, direction)) + entries = ";".join(f"{c.column},{c.row}={col.rgb_color}" for c, col in mapping.items()) + lines.append(f"mapping_offset {direction}: {entries}") + +for factor in [0.0, 0.1, 0.25, 0.5, 0.75, 0.99, 1.0]: + c = shift_color_towards(Color("ff8040"), Color("103050"), factor) + lines.append(f"shift {factor}: {c.rgb_color}") + +out = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "graphics_goldens.txt" +out.write_text("\n".join(lines) + "\n") +print(f"wrote {out} ({len(lines)} lines)") diff --git a/vendor/ttfx/tools/parity/cases.txt b/vendor/ttfx/tools/parity/cases.txt new file mode 100644 index 0000000..ad2a069 --- /dev/null +++ b/vendor/ttfx/tools/parity/cases.txt @@ -0,0 +1,178 @@ +beams-basic|basic.txt|beams +beams-custom|paragraph.txt|beams --beam-row-symbols - = --beam-column-symbols . : --beam-delay 2 --beam-row-speed-range 30-80 --beam-column-speed-range 12-20 --beam-gradient-stops ff0000 0000ff --beam-gradient-steps 4 --beam-gradient-frames 1 --final-gradient-direction horizontal --final-wipe-speed 5 +beams-dynamic|colored.txt|--existing-color-handling dynamic beams +beams-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c beams +binarypath-basic|basic.txt|binarypath +binarypath-custom|paragraph.txt|binarypath --binary-colors ff0000 00ff00 0000ff --movement-speed 2 --active-binary-groups 0.3 --final-gradient-direction vertical +binarypath-dynamic|colored.txt|--existing-color-handling dynamic binarypath +binarypath-single|single.txt|binarypath +binarypath-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c binarypath --active-binary-groups 0.15 +blackhole-always-xterm|colored.txt|--existing-color-handling always --xterm-colors blackhole +blackhole-anchored|paragraph.txt|--canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c blackhole +blackhole-basic|basic.txt|blackhole +blackhole-custom|paragraph.txt|blackhole --blackhole-color 00ffff --star-colors ff0000 00ff00 0000ff --final-gradient-stops ff0000 ffffff --final-gradient-steps 6 --final-gradient-direction horizontal +blackhole-dynamic|colored.txt|--existing-color-handling dynamic blackhole +blackhole-single|single.txt|blackhole +bouncyballs-basic|basic.txt|bouncyballs +bouncyballs-custom|paragraph.txt|bouncyballs --ball-colors ff0000 00ff00 0000ff --ball-symbols o . x --ball-delay 0 --movement-speed 1.2 --movement-easing out_quad --final-gradient-direction horizontal +bouncyballs-dynamic|colored.txt|--existing-color-handling dynamic bouncyballs +bouncyballs-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c bouncyballs +bubbles-basic|basic.txt|bubbles +bubbles-bottom|paragraph.txt|bubbles --pop-condition bottom --bubble-colors ff0000 00ff00 --pop-color 00ffff --movement-easing out_bounce +bubbles-dynamic|colored.txt|--existing-color-handling dynamic bubbles --bubble-delay 5 +bubbles-rainbow-anywhere|paragraph.txt|bubbles --rainbow --pop-condition anywhere --bubble-speed 0.8 --bubble-delay 8 +bubbles-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c bubbles +burn-basic|basic.txt|burn +burn-custom|paragraph.txt|burn --starting-color 404040 --burn-colors ff0000 ffa500 ffff00 --smoke-chance 1.0 --final-gradient-stops 00ff00 0000ff --final-gradient-steps 8 --final-gradient-direction horizontal +burn-dynamic|colored.txt|--existing-color-handling dynamic burn +burn-nosmoke|basic.txt|burn --smoke-chance 0.0 +burn-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c burn +colorshift-basic|basic.txt|colorshift +colorshift-custom|paragraph.txt|colorshift --gradient-stops ff0000 00ff00 0000ff --gradient-steps 6 --gradient-frames 3 --no-loop --travel-direction horizontal --reverse-travel-direction --cycles 1 --final-gradient-direction diagonal +colorshift-dynamic|colored.txt|--existing-color-handling dynamic colorshift --cycles 1 +colorshift-notravel|paragraph.txt|colorshift --no-travel --cycles 2 --skip-final-gradient +colorshift-vertical|basic.txt|colorshift --travel-direction vertical --cycles 1 +colorshift-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c colorshift --cycles 1 +crumble-always-xterm|colored.txt|--existing-color-handling always --xterm-colors crumble +crumble-anchored|paragraph.txt|--canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c crumble +crumble-basic|basic.txt|crumble +crumble-custom|paragraph.txt|crumble --final-gradient-stops ff0000 00ff00 0000ff --final-gradient-steps 6 --final-gradient-direction horizontal +crumble-dynamic|colored.txt|--existing-color-handling dynamic crumble +crumble-single|single.txt|crumble +decrypt-always-xterm|colored.txt|--existing-color-handling always --xterm-colors decrypt +decrypt-basic|basic.txt|decrypt +decrypt-custom|paragraph.txt|decrypt --typing-speed 3 --ciphertext-colors ff0000 00ff00 0000ff --final-gradient-stops eda000 00d1ff --final-gradient-direction horizontal +decrypt-dynamic|colored.txt|--existing-color-handling dynamic decrypt +decrypt-single|single.txt|decrypt --typing-speed 1 +decrypt-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c decrypt +errorcorrect-basic|basic.txt|errorcorrect +errorcorrect-dynamic|colored.txt|--existing-color-handling dynamic errorcorrect --error-pairs 0.5 +errorcorrect-heavy|paragraph.txt|errorcorrect --error-pairs 0.4 --swap-delay 2 --error-color ff8800 --correct-color 00ff88 --movement-speed 0.4 +errorcorrect-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c errorcorrect +fireworks-basic|basic.txt|fireworks +fireworks-custom|paragraph.txt|fireworks --explode-anywhere --firework-colors ff0000 00ff00 0000ff --firework-symbol * --firework-volume 0.12 --launch-delay 10 --explode-distance 0.4 --final-gradient-direction diagonal +fireworks-dynamic|colored.txt|--existing-color-handling dynamic fireworks +fireworks-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c fireworks --launch-delay 5 +highlight-basic|basic.txt|highlight +highlight-center|paragraph.txt|highlight --highlight-direction center_to_outside --highlight-brightness 2.5 +highlight-custom|paragraph.txt|highlight --highlight-brightness 0.5 --highlight-direction row_top_to_bottom --highlight-width 3 --final-gradient-stops ff0000 0000ff --final-gradient-steps 6 --final-gradient-direction horizontal +highlight-dynamic|colored.txt|--existing-color-handling dynamic highlight +highlight-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c highlight +laseretch-basic|basic.txt|laseretch +laseretch-dynamic|colored.txt|--existing-color-handling dynamic laseretch +laseretch-fast|paragraph.txt|laseretch --etch-speed 3 --etch-delay 0 --cool-gradient-stops 00ffe6 0077ff --laser-gradient-stops ff0000 ffff00 --spark-gradient-stops ffffff ff7b00 1a0900 --spark-cooling-frames 3 --final-gradient-stops ff0000 00ff00 --final-gradient-steps 6 --final-gradient-direction horizontal +laseretch-group-quirk|basic.txt|laseretch --etch-pattern row_top_to_bottom +laseretch-single|single.txt|laseretch +laseretch-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c laseretch --etch-speed 4 --etch-delay 0 +matrix-basic|basic.txt|matrix +matrix-custom|paragraph.txt|matrix --rain-time 2 --highlight-color ffffff --rain-color-gradient ff0000 00ff00 --rain-symbols a b c 1 2 3 --rain-fall-delay-range 1-5 --rain-column-delay-range 2-4 --symbol-swap-chance 0.05 --color-swap-chance 0.02 --resolve-delay 1 --final-gradient-stops ff0000 0000ff --final-gradient-steps 8 --final-gradient-frames 2 --final-gradient-direction vertical +matrix-dynamic|colored.txt|--existing-color-handling dynamic matrix --rain-time 1 +matrix-short|basic.txt|matrix --rain-time 1 +matrix-single|single.txt|matrix --rain-time 1 +matrix-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c matrix --rain-time 2 +middleout-always-xterm|colored.txt|--existing-color-handling always --xterm-colors middleout +middleout-anchored|paragraph.txt|--canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c middleout --expand-direction horizontal +middleout-basic|basic.txt|middleout +middleout-dynamic|colored.txt|--existing-color-handling dynamic middleout +middleout-horizontal|paragraph.txt|middleout --expand-direction horizontal --starting-color 00ff00 --center-movement-speed 0.3 --full-movement-speed 0.9 --center-easing out_bounce --full-easing in_quad +# name|input|args (each case runs with seeds 42 and 1337, max 400 frames) +orbittingvolley-basic|basic.txt|orbittingvolley +orbittingvolley-custom|paragraph.txt|orbittingvolley --top-launcher-symbol T --right-launcher-symbol R --bottom-launcher-symbol B --left-launcher-symbol L --launcher-movement-speed 1.4 --character-movement-speed 0.8 --volley-size 0.1 --launch-delay 10 --character-easing in_out_quad --final-gradient-direction vertical +orbittingvolley-dynamic|colored.txt|--existing-color-handling dynamic orbittingvolley --launch-delay 5 +orbittingvolley-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c orbittingvolley --volley-size 0.2 +overflow-always-xterm|colored.txt|--existing-color-handling always --xterm-colors overflow +overflow-anchored|paragraph.txt|--canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c overflow +overflow-basic|basic.txt|overflow +overflow-dynamic|colored.txt|--existing-color-handling dynamic overflow +overflow-fast|paragraph.txt|overflow --overflow-cycles-range 1-2 --overflow-speed 5 --overflow-gradient-stops ff0000 00ff00 --final-gradient-direction horizontal +overflow-single|single.txt|overflow --overflow-speed 1 +pour-always-xterm|colored.txt|--existing-color-handling always --xterm-colors pour --pour-direction right +pour-anchored|paragraph.txt|--canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c pour +pour-basic|basic.txt|pour +pour-dynamic|colored.txt|--existing-color-handling dynamic pour +pour-left|paragraph.txt|pour --pour-direction left --starting-color ff0000 --final-gradient-frames 3 +pour-up-fast|paragraph.txt|pour --pour-direction up --pour-speed 4 --gap 0 --movement-speed-range 0.3-0.9 --movement-easing out_bounce +print-always-xterm|colored.txt|--existing-color-handling always --xterm-colors print +print-anchored|paragraph.txt|--canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c print +print-basic|basic.txt|print +print-dynamic|colored.txt|--existing-color-handling dynamic print +print-fast|paragraph.txt|print --print-speed 4 --print-head-return-speed 3 --print-head-easing out_quad --final-gradient-direction vertical +print-single|single.txt|print --print-speed 1 --print-head-return-speed 0.5 +rain-basic|basic.txt|rain +rain-custom|paragraph.txt|rain --rain-colors 00ff00 ff0000 --rain-symbols . , --movement-speed 0.8-1.2 --movement-easing out_bounce --final-gradient-direction vertical +rain-dynamic|colored.txt|--existing-color-handling dynamic rain +rain-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c rain +randomsequence-anchored|paragraph.txt|--canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c randomsequence --speed 0.03 +randomsequence-basic|basic.txt|randomsequence +randomsequence-colored-always|colored.txt|--existing-color-handling always randomsequence +randomsequence-colored-dynamic|colored.txt|--existing-color-handling dynamic randomsequence +randomsequence-fast|basic.txt|randomsequence --speed 0.2 --final-gradient-frames 3 +randomsequence-nocolor|basic.txt|--no-color randomsequence +randomsequence-single|single.txt|randomsequence +randomsequence-xterm|paragraph.txt|--xterm-colors randomsequence --speed 0.05 +rings-basic|basic.txt|rings +rings-custom|paragraph.txt|rings --ring-colors ff0000 00ff00 --ring-gap 0.2 --spin-duration 50 --spin-speed 0.5-2.0 --disperse-duration 50 --spin-disperse-cycles 2 --final-gradient-direction horizontal +rings-dynamic|colored.txt|--existing-color-handling dynamic rings --spin-duration 60 --disperse-duration 60 +rings-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c rings --spin-duration 80 --disperse-duration 40 +slide-always-xterm|colored.txt|--existing-color-handling always --xterm-colors slide --grouping column +slide-anchored|paragraph.txt|--canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c slide --grouping diagonal --merge +slide-basic|basic.txt|slide +slide-column-merge|paragraph.txt|slide --grouping column --merge --gap 0 --movement-speed 0.5 +slide-diagonal-reverse|paragraph.txt|slide --grouping diagonal --reverse-direction --movement-easing out_bounce --final-gradient-frames 3 +slide-dynamic|colored.txt|--existing-color-handling dynamic slide +smoke-basic|basic.txt|smoke +smoke-custom|paragraph.txt|smoke --starting-color 333333 --smoke-symbols + @ % --smoke-gradient-stops 111111 eeeeee --final-gradient-stops ff0000 00ff00 --final-gradient-steps 6 --final-gradient-direction horizontal +smoke-dynamic|colored.txt|--existing-color-handling dynamic smoke +smoke-wholecanvas|basic.txt|smoke --use-whole-canvas +smoke-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c smoke +spotlights-always-xterm|colored.txt|--existing-color-handling always --xterm-colors spotlights --search-duration 50 +spotlights-anchored|paragraph.txt|--canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c spotlights --search-duration 60 +spotlights-basic|basic.txt|spotlights --search-duration 60 +spotlights-custom|paragraph.txt|spotlights --beam-width-ratio 1.5 --beam-falloff 0.5 --search-duration 40 --search-speed-range 0.5-1.0 --spotlight-count 5 --final-gradient-stops ff0000 00ff00 --final-gradient-steps 6 --final-gradient-direction horizontal +spotlights-dynamic|colored.txt|--existing-color-handling dynamic spotlights --search-duration 50 +spotlights-single|single.txt|spotlights --search-duration 30 --spotlight-count 1 +spray-basic|basic.txt|spray +spray-dynamic|colored.txt|--existing-color-handling dynamic spray +spray-nw-slow|paragraph.txt|spray --spray-position nw --spray-volume 0.02 --movement-speed-range 0.3-0.8 --movement-easing in_out_quad +spray-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c spray --spray-position center +swarm-always-xterm|colored.txt|--existing-color-handling always --xterm-colors swarm +swarm-anchored|paragraph.txt|--canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c swarm +swarm-basic|basic.txt|swarm +swarm-custom|paragraph.txt|swarm --base-color ff0000 00ff00 --flash-color ffffff --swarm-size 0.2 --swarm-coordination 0.5 --swarm-area-count-range 3-6 --final-gradient-stops ff00ff 00ffff --final-gradient-steps 6 --final-gradient-direction vertical +swarm-dynamic|colored.txt|--existing-color-handling dynamic swarm +swarm-single|single.txt|swarm +sweep-basic|basic.txt|sweep +sweep-center|paragraph.txt|sweep --first-sweep-direction center_to_outside --second-sweep-direction outside_to_center +sweep-custom|paragraph.txt|sweep --sweep-symbols # @ % --first-sweep-direction row_top_to_bottom --second-sweep-direction diagonal_bottom_left_to_top_right --final-gradient-stops ff0000 00ff00 --final-gradient-steps 12 --final-gradient-direction horizontal +sweep-dynamic|colored.txt|--existing-color-handling dynamic sweep +sweep-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c sweep +synthgrid-always-xterm|colored.txt|--existing-color-handling always --xterm-colors synthgrid +synthgrid-anchored|paragraph.txt|--canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c synthgrid +synthgrid-basic|basic.txt|synthgrid +synthgrid-custom|paragraph.txt|synthgrid --grid-gradient-stops CC00CC ffffff --grid-gradient-steps 6 --grid-gradient-direction horizontal --text-gradient-stops ff0000 00ff00 --text-gradient-steps 8 --text-gradient-direction diagonal --grid-row-symbol = --grid-column-symbol : --text-generation-symbols # @ % --max-active-blocks 0.5 +synthgrid-dynamic|colored.txt|--existing-color-handling dynamic synthgrid +synthgrid-single|single.txt|synthgrid +thunderstorm-basic|basic.txt|thunderstorm --storm-time 2 +thunderstorm-custom|paragraph.txt|thunderstorm --storm-time 3 --text-glow-time 1 --spark-glow-time 1 --lightning-color ffff00 +thunderstorm-dynamic|colored.txt|--existing-color-handling dynamic thunderstorm --storm-time 2 +thunderstorm-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c thunderstorm --storm-time 2 +unstable-always-xterm|colored.txt|--existing-color-handling always --xterm-colors unstable +unstable-anchored|paragraph.txt|--canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c unstable +unstable-basic|basic.txt|unstable +unstable-custom|paragraph.txt|unstable --unstable-color 00ffff --explosion-ease in_out_quad --explosion-speed 2 --reassembly-ease out_bounce --reassembly-speed 0.5 --final-gradient-direction horizontal +unstable-dynamic|colored.txt|--existing-color-handling dynamic unstable +unstable-single|single.txt|unstable --explosion-speed 0.4 +vhstape-basic|basic.txt|vhstape +vhstape-dynamic|colored.txt|--existing-color-handling dynamic vhstape --total-glitch-time 150 +vhstape-fast|paragraph.txt|vhstape --total-glitch-time 200 --glitch-line-chance 0.1 --noise-chance 0.01 +vhstape-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c vhstape --total-glitch-time 150 +waves-basic|basic.txt|waves +waves-center|paragraph.txt|waves --wave-direction center_to_outside --wave-count 1 +waves-custom|paragraph.txt|waves --wave-symbols ~ - = --wave-gradient-stops ff0000 0000ff --wave-gradient-steps 4 8 --wave-count 2 --wave-length 1 --wave-direction row_top_to_bottom --wave-easing out_bounce --final-gradient-stops 00ff00 --final-gradient-steps 6 --final-gradient-direction vertical +waves-dynamic|colored.txt|--existing-color-handling dynamic waves --wave-count 1 +waves-xterm-anchored|paragraph.txt|--xterm-colors --canvas-width 60 --canvas-height 20 --anchor-canvas c --anchor-text c waves --wave-count 1 +wipe-basic|basic.txt|wipe +wipe-center|paragraph.txt|wipe --wipe-direction center_to_outside --wipe-ease out_bounce +wipe-dynamic|colored.txt|--existing-color-handling dynamic wipe +wipe-row|paragraph.txt|wipe --wipe-direction row_top_to_bottom --wipe-delay 2 +wipe-xterm|basic.txt|--xterm-colors wipe --wipe-direction column_right_to_left diff --git a/vendor/ttfx/tools/parity/dump.py b/vendor/ttfx/tools/parity/dump.py new file mode 100644 index 0000000..9b21f2f --- /dev/null +++ b/vendor/ttfx/tools/parity/dump.py @@ -0,0 +1,81 @@ +"""Parity frame dumper for the reference TTE. + +Usage: dump.py --seed N [--max-frames N] [terminal args] [effect args] +Reads input from stdin, runs the effect deterministically (shim installed), +writes each frame to stdout as b"%d\n" % len(frame) + frame + b"\n". +""" + +from __future__ import annotations + +import argparse +import importlib +import pkgutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[0])) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "reference" / "tte")) + +import shim # noqa: E402 + +# peel off --seed / --max-frames before the real parser sees argv +argv = sys.argv[1:] +seed = 0 +max_frames = None +while argv and argv[0] in ("--seed", "--max-frames"): + flag = argv.pop(0) + value = int(argv.pop(0)) + if flag == "--seed": + seed = value + else: + max_frames = value + +shim.install(seed) + +import terminaltexteffects.effects # noqa: E402 +from terminaltexteffects.engine.terminal import Terminal, TerminalConfig # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(prog="tte-parity-dump") + TerminalConfig._populate_parser(parser) + subparsers = parser.add_subparsers(title="effect", dest="effect") + effects = {} + for module_info in pkgutil.iter_modules(terminaltexteffects.effects.__path__): + module = importlib.import_module(f"terminaltexteffects.effects.{module_info.name}") + if hasattr(module, "get_effect_resources"): + name, effect_cls, config_cls = module.get_effect_resources() + effects[name] = (effect_cls, config_cls) + config_cls._populate_parser(subparsers) + + args = parser.parse_args(argv) + if not args.effect: + print("no effect given", file=sys.stderr) + return 2 + + terminal_config = TerminalConfig._build_config(args) + shim.set_frame_rate(terminal_config.frame_rate) + effect_cls, config_cls = effects[args.effect] + effect_config = config_cls._build_config(args) + + input_data = sys.stdin.read() + effect = effect_cls(input_data, effect_config, terminal_config) + + out = sys.stdout.buffer + count = 0 + for frame in effect: + data = frame.encode("utf-8") + out.write(b"%d\n" % len(data)) + out.write(data) + out.write(b"\n") + shim.advance_frame() + count += 1 + if max_frames is not None and count >= max_frames: + break + out.flush() + print(f"frames={count}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ttfx/tools/parity/fetch_reference.sh b/vendor/ttfx/tools/parity/fetch_reference.sh new file mode 100755 index 0000000..21a448a --- /dev/null +++ b/vendor/ttfx/tools/parity/fetch_reference.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Fetch the pinned TerminalTextEffects reference used by the parity suites. +# +# The reference is NOT vendored into this repo — it's upstream's code, and it +# belongs to upstream. The parity harness clones it at one pinned commit so +# every comparison is against a known tree. +set -euo pipefail +cd "$(dirname "$0")/../.." + +REF_REPO="https://github.com/ChrisBuilds/terminaltexteffects" +REF_COMMIT="7a91dd9ca6ee0c4f4b1484efee0ecac1bb84104e" # v0.15.0 +DEST="reference/tte" + +if [ -d "$DEST/.git" ] && [ "$(git -C "$DEST" rev-parse HEAD 2>/dev/null)" = "$REF_COMMIT" ]; then + echo "reference already at $REF_COMMIT" + exit 0 +fi + +rm -rf "$DEST" +mkdir -p "$(dirname "$DEST")" +git clone -q --filter=blob:none "$REF_REPO" "$DEST" +git -C "$DEST" checkout -q "$REF_COMMIT" +echo "reference/tte pinned at $REF_COMMIT (v0.15.0)" diff --git a/vendor/ttfx/tools/parity/m0_dump.py b/vendor/ttfx/tools/parity/m0_dump.py new file mode 100644 index 0000000..8602147 --- /dev/null +++ b/vendor/ttfx/tools/parity/m0_dump.py @@ -0,0 +1,42 @@ +"""M0 parity dump: build a Terminal from CLI args, set every character in +character_by_input_coord visible, print the first frame. The Rust equivalent is +`ttfx --m0-dump`. Run with the pinned reference on PYTHONPATH. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "reference" / "tte")) + +from terminaltexteffects.engine.terminal import Terminal, TerminalConfig # noqa: E402 +from terminaltexteffects.utils.exceptions import UnsupportedAnsiSequenceError # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser() + TerminalConfig._populate_parser(parser) + args = parser.parse_args() + config = TerminalConfig._build_config(args) + + input_data = sys.stdin.read() + if not input_data.strip(): + print("NO INPUT.") + return 1 + + try: + terminal = Terminal(input_data, config) + except UnsupportedAnsiSequenceError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + for character in terminal.character_by_input_coord.values(): + terminal.set_character_visibility(character, is_visible=True) + print(terminal.get_formatted_output_string()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ttfx/tools/parity/m0_matrix.sh b/vendor/ttfx/tools/parity/m0_matrix.sh new file mode 100755 index 0000000..c4d2a2a --- /dev/null +++ b/vendor/ttfx/tools/parity/m0_matrix.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# M0 parity matrix: byte-compare Python and Rust first-frame dumps across +# inputs x terminal-config variants. Exit 0 iff everything matches. +set -u +cd "$(dirname "$0")/../.." + +RUST=./target/release/ttfx +PY="python3 tools/parity/m0_dump.py" +export COLUMNS=80 LINES=24 + +pass=0 +fail=0 +failed_cases=() + +inputs_dir=tools/parity/inputs +mkdir -p "$inputs_dir" + +# --- inputs --- +printf 'Hello, World!' > "$inputs_dir/simple.txt" +printf 'line one\nsecond line is longer\n\tindented\nshort\n' > "$inputs_dir/multiline.txt" +printf 'a\n\n\nb with trailing spaces \n leading\n' > "$inputs_dir/ragged.txt" +printf '\x1b[31mred\x1b[0m plain \x1b[1;32mboldgreen\x1b[0m\n\x1b[38;2;255;0;128mrgb\x1b[0m \x1b[48;5;42mbg8\x1b[0m\n' > "$inputs_dir/colored.txt" +printf '\x1b[93mbright\x1b[39mdefault\n\x1b[34mblue\x1b[1mboldblue\x1b[22munbold\x1b[0m\n' > "$inputs_dir/colorstate.txt" +printf 'over\rwritten\nnext\x1b[2Cgap\n' > "$inputs_dir/cursor.txt" +printf 'tab\ttab\t\tend\n' > "$inputs_dir/tabs.txt" +python3 -c "print('wide line ' * 12)" > "$inputs_dir/wide.txt" + +# --- config variants --- +declare -A variants=( + [default]="" + [anchor_c]="--anchor-canvas c --anchor-text c --canvas-width 60 --canvas-height 20" + [anchor_ne]="--anchor-canvas ne --anchor-text ne --canvas-width 60 --canvas-height 20" + [anchor_mixed]="--anchor-canvas n --anchor-text se --canvas-width 40 --canvas-height 12" + [canvas_terminal]="--canvas-width 0 --canvas-height 0" + [canvas_small]="--canvas-width 12 --canvas-height 4" + [wrap]="--wrap-text --canvas-width 20" + [tab8]="--tab-width 8" + [xterm]="--xterm-colors" + [nocolor]="--no-color" + [always]="--existing-color-handling always" + [always_xterm]="--existing-color-handling always --xterm-colors" + [always_nocolor]="--existing-color-handling always --no-color" + [ignore_dims]="--ignore-terminal-dimensions --canvas-width 120 --canvas-height 40" +) + +for input in "$inputs_dir"/*.txt; do + for variant in "${!variants[@]}"; do + opts=${variants[$variant]} + name="$(basename "$input" .txt)/$variant" + py_out=$($PY $opts < "$input" 2>&1) + py_rc=$? + rust_out=$($RUST --m0-dump $opts < "$input" 2>&1) + rust_rc=$? + if [ "$py_rc" -ne "$rust_rc" ]; then + fail=$((fail + 1)) + failed_cases+=("$name (exit: py=$py_rc rust=$rust_rc)") + continue + fi + if [ "$py_rc" -ne 0 ]; then + pass=$((pass + 1)) # both errored: condition parity + continue + fi + if [ "$py_out" == "$rust_out" ]; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + failed_cases+=("$name") + if [ "${VERBOSE:-0}" == "1" ]; then + diff <(printf '%s' "$py_out" | cat -A) <(printf '%s' "$rust_out" | cat -A) | head -20 + fi + fi + done +done + +echo "M0 parity: $pass passed, $fail failed" +if [ "$fail" -gt 0 ]; then + printf 'FAILED: %s\n' "${failed_cases[@]}" + exit 1 +fi diff --git a/vendor/ttfx/tools/parity/run_suite.sh b/vendor/ttfx/tools/parity/run_suite.sh new file mode 100755 index 0000000..133fffa --- /dev/null +++ b/vendor/ttfx/tools/parity/run_suite.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Effect parity suite: byte-compare Python and Rust frame dumps for every case +# in cases.txt (format: name|input_file|cli_args). Usage: run_suite.sh [filter] +set -u +cd "$(dirname "$0")/../.." + +RUST=./target/release/ttfx +PY="python3 tools/parity/dump.py" +export COLUMNS=80 LINES=24 +FILTER="${1:-}" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +# shared inputs +inputs=tools/parity/inputs +mkdir -p "$inputs" +printf 'Hello, World!\nThis is ttfx.\nRust vs Python' > "$inputs/basic.txt" +printf 'x' > "$inputs/single.txt" +printf '\x1b[31mred\x1b[0m plain \x1b[1;32mboldgreen\x1b[0m\n\x1b[38;2;255;0;128mrgb\x1b[0m \x1b[48;5;42mbg8\x1b[0m\n' > "$inputs/colored.txt" +python3 -c " +lines = ['The quick brown fox jumps over the lazy dog %d' % i for i in range(8)] +print('\n'.join(lines))" > "$inputs/paragraph.txt" + +pass=0; fail=0; failed_cases=() +while IFS='|' read -r name input args; do + [ -z "$name" ] && continue + case "$name" in \#*) continue ;; esac + if [ -n "$FILTER" ] && [[ "$name" != *"$FILTER"* ]]; then continue; fi + for seed in 42 1337; do + $PY --seed "$seed" --max-frames 400 $args < "$inputs/$input" > "$tmp/py.dump" 2>"$tmp/py.err" + py_rc=$? + $RUST --seed "$seed" --parity-dump --max-frames 400 $args < "$inputs/$input" > "$tmp/rs.dump" 2>"$tmp/rs.err" + rs_rc=$? + if [ $py_rc -ne $rs_rc ]; then + fail=$((fail+1)); failed_cases+=("$name seed=$seed (exit py=$py_rc rs=$rs_rc)") + continue + fi + if cmp -s "$tmp/py.dump" "$tmp/rs.dump"; then + pass=$((pass+1)) + else + fail=$((fail+1)); failed_cases+=("$name seed=$seed (first diff byte: $(cmp "$tmp/py.dump" "$tmp/rs.dump" 2>&1 | head -1))") + fi + done +done < tools/parity/cases.txt + +echo "effect parity: $pass passed, $fail failed" +if [ $fail -gt 0 ]; then printf 'FAILED: %s\n' "${failed_cases[@]}"; exit 1; fi diff --git a/vendor/ttfx/tools/parity/shim.py b/vendor/ttfx/tools/parity/shim.py new file mode 100644 index 0000000..d7f7de0 --- /dev/null +++ b/vendor/ttfx/tools/parity/shim.py @@ -0,0 +1,267 @@ +"""Parity shim: makes the reference TTE deterministic and RNG-compatible with ttfx. + +Install BEFORE importing terminaltexteffects: + import shim; shim.install(seed) + +Patches applied (plan.md §7): +- random.random/randint/randrange/choice/uniform/shuffle/seed -> xoshiro256++ + implementations bit-identical to src/utils/rng.rs (the parity contract; keep + in lockstep with rng.rs). +- time.time/time.monotonic -> virtual clock (advance via shim.advance_frame), + time.sleep -> no-op. +- BaseEffectIterator.update -> ticks in ascending character_id order. +- Terminal._update_terminal_state -> paints in (layer, character_id) order. +- BreadthFirst.step -> iterates links in ascending character_id order. +""" + +from __future__ import annotations + +MASK = (1 << 64) - 1 + + +class Xoshiro256pp: + def __init__(self, seed: int) -> None: + self.seed(seed) + + def seed(self, seed: int) -> None: + # SplitMix64 expansion, matching Rng::seeded + sm = seed & MASK + state = [] + for _ in range(4): + sm = (sm + 0x9E3779B97F4A7C15) & MASK + z = sm + z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & MASK + z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & MASK + state.append(z ^ (z >> 31)) + self.s = state + + def next_u64(self) -> int: + s = self.s + result = (self._rotl((s[0] + s[3]) & MASK, 23) + s[0]) & MASK + t = (s[1] << 17) & MASK + s[2] ^= s[0] + s[3] ^= s[1] + s[1] ^= s[2] + s[0] ^= s[3] + s[2] ^= t + s[3] = self._rotl(s[3], 45) + return result + + @staticmethod + def _rotl(x: int, k: int) -> int: + return ((x << k) | (x >> (64 - k))) & MASK + + def random(self) -> float: + return (self.next_u64() >> 11) * (1.0 / (1 << 53)) + + def randbelow(self, n: int) -> int: + assert n > 0 + bits = (n - 1).bit_length() + shift = 64 - max(bits, 1) + while True: + r = self.next_u64() >> shift + if r < n: + return r + + def randint(self, a: int, b: int) -> int: + return a + self.randbelow(b - a + 1) + + def randrange(self, a: int, b: int | None = None) -> int: + if b is None: + a, b = 0, a + return a + self.randbelow(b - a) + + def choice(self, seq): + return seq[self.randbelow(len(seq))] + + def uniform(self, a: float, b: float) -> float: + return a + (b - a) * self.random() + + def shuffle(self, x) -> None: + for i in reversed(range(1, len(x))): + j = self.randbelow(i + 1) + x[i], x[j] = x[j], x[i] + + +class VirtualClock: + def __init__(self) -> None: + self.now = 0.0 + self.dt = 1.0 / 60.0 + + def advance_frame(self) -> None: + self.now += self.dt + + +RNG = Xoshiro256pp(0) +CLOCK = VirtualClock() + + +def advance_frame() -> None: + CLOCK.advance_frame() + + +def set_frame_rate(frame_rate: int) -> None: + CLOCK.dt = 1.0 / frame_rate if frame_rate > 0 else 1.0 / 60.0 + + +def install(seed: int) -> None: + import random + import time + + RNG.seed(seed) + random.random = RNG.random + random.randint = RNG.randint + random.randrange = RNG.randrange + random.choice = RNG.choice + random.uniform = RNG.uniform + random.shuffle = RNG.shuffle + random.seed = RNG.seed + + time.time = lambda: CLOCK.now + time.monotonic = lambda: CLOCK.now + time.sleep = lambda _: None + + # determinism patches (canonical orders, docs/ordering-inventory.md) + from terminaltexteffects.engine.base_effect import BaseEffectIterator + from terminaltexteffects.engine.terminal import Terminal + from terminaltexteffects.utils.spanningtree.algo.breadthfirst import BreadthFirst + + def update(self) -> None: + for character in sorted(self.active_characters, key=lambda c: c.character_id): + character.tick() + self.active_characters -= {c for c in self.active_characters if not c.is_active} + + BaseEffectIterator.update = update + + def _update_terminal_state(self) -> None: + rows = [[" " for _ in range(self.visible_right)] for _ in range(self.visible_top)] + for character in sorted(self._visible_characters, key=lambda c: (c.layer, c.character_id)): + row = character.motion.current_coord.row + self.canvas_row_offset + column = character.motion.current_coord.column + self.canvas_column_offset + if self.visible_bottom <= row <= self.visible_top and self.visible_left <= column <= self.visible_right: + rows[row - 1][column - 1] = character.animation.current_character_visual.formatted_symbol + self.terminal_state = ["".join(row) for row in rows] + + Terminal._update_terminal_state = _update_terminal_state + + _orig_bfs_step = BreadthFirst.step + + def bfs_step(self) -> None: + self.explored_last_step.clear() + if not self._frontier: + self.complete = True + return + new_edges = [] + while self._frontier: + position = self._frontier.pop(0) + position_new_edges = [ + neighbor + for neighbor in sorted(position.links, key=lambda c: c.character_id) + if neighbor not in self._explored and neighbor not in self._frontier and neighbor not in new_edges + ] + new_edges.extend(position_new_edges) + for character in position_new_edges: + self._explored[character] = position + self.explored_last_step.append(character) + self.char_explore_order.append(character) + self._frontier = new_edges + + BreadthFirst.step = bfs_step + del _orig_bfs_step + + # middleout: __next__ iterates the rebuilt active_characters set to + # activate the "full" path/scene — canonical ascending character_id + # (docs/ordering-inventory.md, effect_middleout.py:229). + from terminaltexteffects.effects.effect_middleout import MiddleOutIterator + + def middleout_next(self) -> str: + if self.phase == "center" and not self.active_characters: + self.phase = "full" + self.active_characters = set(self.terminal.get_characters()) + for character in sorted(self.active_characters, key=lambda c: c.character_id): + character.motion.activate_path("full") + character.animation.activate_scene("full") + if self.active_characters: + self.update() + return self.frame + raise StopIteration + + MiddleOutIterator.__next__ = middleout_next + + # unstable: __next__ ticks the active_characters set directly in the + # explosion (effect_unstable.py:332) and reassembly (effect_unstable.py:354) + # phases — canonical ascending character_id (docs/ordering-inventory.md). + from terminaltexteffects import Coord + from terminaltexteffects.effects.effect_unstable import UnstableIterator + + def unstable_next(self) -> str: + next_frame = None + if self.phase == "rumble": + if self._current_rumble_steps < self._max_rumble_steps: + if self._current_rumble_steps > 30 and self._current_rumble_steps % self._rumble_mod_delay == 0: + row_offset = random.choice([-1, 0, 1]) + column_offset = random.choice([-1, 0, 1]) + for character in self.terminal.get_characters(): + character.motion.set_coordinate( + Coord( + character.motion.current_coord.column + column_offset, + character.motion.current_coord.row + row_offset, + ), + ) + character.animation.step_animation() + next_frame = self.frame + for character in self.terminal.get_characters(): + character.motion.set_coordinate(self.jumbled_coords[character]) + self._rumble_mod_delay -= 1 + self._rumble_mod_delay = max(self._rumble_mod_delay, 1) + else: + for character in self.terminal.get_characters(): + character.animation.step_animation() + next_frame = self.frame + + self._current_rumble_steps += 1 + else: + self.phase = "explosion" + for character in self.terminal.get_characters(): + character.motion.activate_path(character.motion.query_path("explosion")) + self.active_characters = set(self.terminal.get_characters()) + + if self.phase == "explosion": + if self.active_characters: + for character in sorted(self.active_characters, key=lambda c: c.character_id): + character.tick() + self.active_characters = { + character + for character in self.active_characters + if character.motion.current_coord != character.motion.query_path("explosion").waypoints[0].coord + } + next_frame = self.frame + + elif self._explosion_hold_time: + for character in sorted(self.active_characters, key=lambda c: c.character_id): + character.tick() + self._explosion_hold_time -= 1 + next_frame = self.frame + else: + self.phase = "reassembly" + for character in self.terminal.get_characters(): + character.animation.activate_scene(character.animation.query_scene("final")) + self.active_characters.add(character) + character.motion.activate_path(character.motion.query_path("reassembly")) + + if self.phase == "reassembly" and self.active_characters: + for character in sorted(self.active_characters, key=lambda c: c.character_id): + character.tick() + self.active_characters = { + character + for character in self.active_characters + if character.motion.current_coord != character.motion.query_path("reassembly").waypoints[0].coord + or not character.animation.active_scene_is_complete() + } + next_frame = self.frame + + if next_frame is not None: + return next_frame + raise StopIteration + + UnstableIterator.__next__ = unstable_next diff --git a/vendor/ttfx/tools/parity/tty_compare.sh b/vendor/ttfx/tools/parity/tty_compare.sh new file mode 100755 index 0000000..2f7c648 --- /dev/null +++ b/vendor/ttfx/tools/parity/tty_compare.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Full byte-stream parity: the tty escape dance (prep, per-frame cursor moves, +# teardown) byte-compared between the shimmed reference and a real ttfx run. +# Runs a representative subset of cases with --frame-rate 0 plus the +# reuse-canvas / no-eol / no-restore-cursor teardown variants. +set -u +cd "$(dirname "$0")/../.." + +RUST=./target/release/ttfx +PY="python3 tools/parity/tty_run.py" +export COLUMNS=80 LINES=24 +tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT +inputs=tools/parity/inputs + +pass=0; fail=0; failed_cases=() +run_case() { + local name="$1"; shift + local input="$1"; shift + $PY --seed 42 --frame-rate 0 "$@" < "$inputs/$input" > "$tmp/py.bytes" 2>"$tmp/py.err" + local py_rc=$? + # --virtual-clock matches the shim's virtual clock so clock-dependent + # effects (matrix, thunderstorm) are comparable; with a real clock their + # phase transitions legitimately track wall time, not frame count. + $RUST --seed 42 --frame-rate 0 --virtual-clock "$@" < "$inputs/$input" > "$tmp/rs.bytes" 2>"$tmp/rs.err" + local rs_rc=$? + if [ $py_rc -ne $rs_rc ]; then + fail=$((fail+1)); failed_cases+=("$name (exit py=$py_rc rs=$rs_rc)"); return + fi + if cmp -s "$tmp/py.bytes" "$tmp/rs.bytes"; then + pass=$((pass+1)) + else + fail=$((fail+1)); failed_cases+=("$name ($(cmp "$tmp/py.bytes" "$tmp/rs.bytes" 2>&1 | head -1))") + fi +} + +# effect subcommands present in both implementations +mapfile -t effects < <($RUST --help 2>/dev/null | sed -n '/Commands:/,/Options:/p' | awk '/^ [a-z]/ {print $1}' | grep -v help) +for effect in "${effects[@]}"; do + run_case "tty-$effect" basic.txt "$effect" +done +run_case "tty-no-eol" basic.txt --no-eol randomsequence +run_case "tty-no-restore-cursor" basic.txt --no-restore-cursor randomsequence +run_case "tty-reuse-canvas" basic.txt --reuse-canvas randomsequence +run_case "tty-anchored" paragraph.txt --canvas-width 60 --canvas-height 20 --anchor-canvas c randomsequence + +echo "tty byte-stream parity: $pass passed, $fail failed" +if [ $fail -gt 0 ]; then printf 'FAILED: %s\n' "${failed_cases[@]}"; exit 1; fi diff --git a/vendor/ttfx/tools/parity/tty_run.py b/vendor/ttfx/tools/parity/tty_run.py new file mode 100644 index 0000000..1c8c96e --- /dev/null +++ b/vendor/ttfx/tools/parity/tty_run.py @@ -0,0 +1,65 @@ +"""Full tty-path runner for the reference TTE: replicates __main__.py's +`with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame)` +with the shim installed, so the COMPLETE output byte stream (canvas prep, DEC +save/restore, per-frame cursor moves, teardown) can be compared against a real +`ttfx --seed N ` run. Use --frame-rate 0 so pacing is disabled on both +sides. + +Usage: tty_run.py --seed N [terminal args] [effect args] < input > bytes +""" + +from __future__ import annotations + +import argparse +import importlib +import pkgutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[0])) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "reference" / "tte")) + +import shim # noqa: E402 + +argv = sys.argv[1:] +seed = 0 +if argv and argv[0] == "--seed": + argv.pop(0) + seed = int(argv.pop(0)) + +shim.install(seed) + +import terminaltexteffects.effects # noqa: E402 +from terminaltexteffects.engine.terminal import TerminalConfig # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(prog="tte-tty-run") + TerminalConfig._populate_parser(parser) + subparsers = parser.add_subparsers(title="effect", dest="effect") + effects = {} + for module_info in pkgutil.iter_modules(terminaltexteffects.effects.__path__): + module = importlib.import_module(f"terminaltexteffects.effects.{module_info.name}") + if hasattr(module, "get_effect_resources"): + name, effect_cls, config_cls = module.get_effect_resources() + effects[name] = (effect_cls, config_cls) + config_cls._populate_parser(subparsers) + + args = parser.parse_args(argv) + terminal_config = TerminalConfig._build_config(args) + shim.set_frame_rate(terminal_config.frame_rate) + effect_cls, config_cls = effects[args.effect] + effect_config = config_cls._build_config(args) + + input_data = sys.stdin.read() + effect = effect_cls(input_data, effect_config, terminal_config) + + with effect.terminal_output() as terminal: + for frame in effect: + terminal.print(frame) + shim.advance_frame() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ttfx/tools/tests/bench_full.py b/vendor/ttfx/tools/tests/bench_full.py new file mode 100644 index 0000000..471931f --- /dev/null +++ b/vendor/ttfx/tools/tests/bench_full.py @@ -0,0 +1,121 @@ +"""Fair ttfx vs upstream-TTE benchmark. + +Both sides run their real user-facing command (no parity shim — the shim's +pure-Python RNG would unfairly slow CPython, whose own random module is C). +Frame pacing is disabled on both sides so this measures render throughput, +not sleep(). + +matrix and thunderstorm are reported separately: they gate on wall-clock time, +so a faster implementation renders MORE frames in the same seconds rather than +finishing sooner — a speed ratio would be meaningless. + +Usage: bench_full.py [repeats] +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +RUST = ROOT / "target/release/ttfx" +REF = ROOT / "reference/tte" +REPEATS = int(sys.argv[1]) if len(sys.argv) > 1 else 3 +CLOCK_BOUND = {"matrix", "thunderstorm"} + +# Canvas geometry is overridable so the same harness covers a modest terminal and +# a fullscreen one — the heavier effects only diverge once the canvas gets big. +COLS = os.environ.get("TTFX_BENCH_COLS", "100") +LINES = os.environ.get("TTFX_BENCH_LINES", "30") + +ENV = {**os.environ, "COLUMNS": COLS, "LINES": LINES, "PYTHONPATH": str(REF)} + + +def effects() -> list[str]: + out = subprocess.run([str(RUST), "--help"], capture_output=True, text=True).stdout + names, grab = [], False + for line in out.splitlines(): + if line.startswith("Commands:"): + grab = True + continue + if line.startswith("Options:"): + break + if grab and line.startswith(" ") and line.strip(): + n = line.split()[0] + if n != "help": + names.append(n) + return names + + +def best_of(cmd: list[str], data: bytes) -> float: + """Best wall-clock of REPEATS runs, in ms (best = least noise).""" + times = [] + for _ in range(REPEATS): + t = time.monotonic() + subprocess.run(cmd, input=data, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=ENV) + times.append((time.monotonic() - t) * 1000) + return min(times) + + +def frames_of(effect: str, data: bytes) -> int: + r = subprocess.run( + [str(RUST), "--seed", "1", "--frame-rate", "0", "--virtual-clock", "--parity-dump", effect], + input=data, capture_output=True, env=ENV, + ) + return int(r.stderr.decode().strip().rsplit("=", 1)[-1] or 0) + + +def main() -> int: + # Default input is a fixed small block; TTFX_BENCH_FILL=1 grows it to cover the + # canvas instead, which is what the character-driven effects actually scale on. + filler = "the quick brown fox jumps over the lazy dog" + if os.environ.get("TTFX_BENCH_FILL") == "1": + rows, width = max(1, int(LINES) - 4), max(20, int(COLS) - 10) + lines = [f"benchmark line {i:03d} — {filler}" for i in range(rows)] + lines = [(l * (width // len(l) + 1))[:width] for l in lines] + else: + lines = [f"benchmark line {i:03d} — {filler}" for i in range(20)] + text = "\n".join(lines) + data = text.encode() + + print(f"canvas: {COLS}x{LINES} · input: {len(text.splitlines())} lines x" + f" {max(len(l) for l in text.splitlines())} cols" + f" · best of {REPEATS} · frame pacing off\n") + + # startup: smallest possible unit of work + tiny = b"x" + rs_start = best_of([str(RUST), "--seed", "1", "--frame-rate", "0", "wipe"], tiny) + py_start = best_of([sys.executable, "-m", "terminaltexteffects", "--frame-rate", "0", "wipe"], tiny) + print(f"{'startup (1 char, wipe)':<22} rust {rs_start:7.1f} ms python {py_start:8.1f} ms {py_start/rs_start:5.1f}x\n") + + rows, ratios = [], [] + for e in effects(): + rs = best_of([str(RUST), "--seed", "1", "--frame-rate", "0", e], data) + py = best_of([sys.executable, "-m", "terminaltexteffects", "--frame-rate", "0", e], data) + n = frames_of(e, data) + rows.append((e, rs, py, py / rs if rs else 0, n)) + if e not in CLOCK_BOUND: + ratios.append(py / rs if rs else 0) + + rows.sort(key=lambda r: -r[3]) + print(f"{'effect':<17}{'rust ms':>9}{'python ms':>11}{'speedup':>9}{'frames':>8}{'rust fps':>10}") + print("-" * 64) + for e, rs, py, ratio, n in rows: + mark = " *" if e in CLOCK_BOUND else "" + fps = n / (rs / 1000) if rs else 0 + print(f"{e+mark:<17}{rs:9.1f}{py:11.1f}{ratio:8.1f}x{n:8d}{fps:10.0f}") + + ratios.sort() + mid = ratios[len(ratios) // 2] + print("-" * 64) + print(f"median speedup (35 non-clock-bound effects): {mid:.1f}x" + f" range {min(ratios):.1f}x–{max(ratios):.1f}x") + print("* clock-bound: gated on wall time, so the ratio reflects frames rendered, not time saved") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ttfx/tools/tests/benchmark.sh b/vendor/ttfx/tools/tests/benchmark.sh new file mode 100755 index 0000000..57a1213 --- /dev/null +++ b/vendor/ttfx/tools/tests/benchmark.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Startup + throughput benchmark: ttfx vs the Python reference. +set -u +cd "$(dirname "$0")/../.." +RUST=./target/release/ttfx +export COLUMNS=120 LINES=40 +input=$(mktemp); trap 'rm -f "$input"' EXIT +python3 -c "print('\n'.join('benchmark line %03d with some text to render' % i for i in range(30)))" > "$input" + +time_ms() { python3 -c "import subprocess,sys,time; t=time.monotonic(); subprocess.run(sys.argv[1:], stdin=open('$input'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); print(round((time.monotonic()-t)*1000))" "$@"; } + +echo "== startup (--help) ==" +echo "ttfx: $(time_ms $RUST --help) ms" +echo "python: $(time_ms python3 -c 'import sys; sys.path.insert(0, "reference/tte"); from terminaltexteffects.__main__ import main' ) ms (import only)" + +echo "== full effect run, frame-rate 0, 30x46 input ==" +for effect in randomsequence beams decrypt; do + r=$(time_ms $RUST --seed 1 --frame-rate 0 --parity-dump $effect) + p=$(time_ms python3 tools/parity/dump.py --seed 1 --frame-rate 0 $effect) + echo "$effect: ttfx ${r}ms vs python ${p}ms" +done diff --git a/vendor/ttfx/tools/tests/cli_corpus.sh b/vendor/ttfx/tools/tests/cli_corpus.sh new file mode 100755 index 0000000..c8a6ff6 --- /dev/null +++ b/vendor/ttfx/tools/tests/cli_corpus.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# CLI contract corpus: exit codes and stream routing per plan.md §8 +# (0 success; 1 runtime errors — no-input/file errors on STDOUT, +# unsupported-ANSI on STDERR; 2 usage errors). +set -u +cd "$(dirname "$0")/../.." +RUST=./target/release/ttfx +export COLUMNS=80 LINES=24 +pass=0; fail=0; failed=() + +check() { + local name="$1" expected_rc="$2"; shift 2 + "$@" > /tmp/claude-cli-out 2>/tmp/claude-cli-err + local rc=$? + if [ "$rc" -eq "$expected_rc" ]; then pass=$((pass+1)); else fail=$((fail+1)); failed+=("$name rc=$rc want=$expected_rc"); fi +} + +# usage errors -> 2 +check unknown-subcommand 2 bash -c "printf x | $RUST nosucheffect" +check unknown-option 2 bash -c "printf x | $RUST --no-such-option wipe" +check bad-value 2 bash -c "printf x | $RUST --frame-rate -- -1 wipe" +check bad-tab-width 2 bash -c "printf x | $RUST --tab-width 0 wipe" +check root-opt-after-subcommand 2 bash -c "printf x | $RUST wipe --no-color" +check include-exclude-conflict 2 bash -c "printf x | $RUST -R --include-effects a --exclude-effects b" +check bad-easing 2 bash -c "printf x | $RUST wipe --wipe-ease not_an_ease" + +# runtime errors -> 1 +check no-input 1 bash -c "printf '' | $RUST --parity-dump --seed 1 wipe" +check whitespace-input 1 bash -c "printf ' \n ' | $RUST --parity-dump --seed 1 wipe" +check missing-file 1 bash -c "$RUST -i /nonexistent/file wipe" +check no-effect 1 bash -c "printf x | $RUST" +check bad-ansi-input 1 bash -c "printf 'a\x1b[2Jb' | $RUST --parity-dump --seed 1 wipe" +check bad-utf8-file 1 bash -c "printf '\xff\xfe' > /tmp/claude-bad-utf8; $RUST -i /tmp/claude-bad-utf8 wipe" + +# stream routing +printf '' | $RUST --parity-dump --seed 1 wipe > /tmp/claude-cli-out 2>/tmp/claude-cli-err +grep -q "NO INPUT." /tmp/claude-cli-out && pass=$((pass+1)) || { fail=$((fail+1)); failed+=("no-input-on-stdout"); } +$RUST -i /nonexistent/file wipe > /tmp/claude-cli-out 2>/tmp/claude-cli-err +[ -s /tmp/claude-cli-out ] && pass=$((pass+1)) || { fail=$((fail+1)); failed+=("file-error-on-stdout"); } +printf 'a\x1b[2Jb' | $RUST --parity-dump --seed 1 wipe > /tmp/claude-cli-out 2>/tmp/claude-cli-err +grep -qi "unsupported ansi" /tmp/claude-cli-err && pass=$((pass+1)) || { fail=$((fail+1)); failed+=("ansi-error-on-stderr"); } + +# success -> 0 +check success 0 bash -c "printf 'hi' | $RUST --parity-dump --seed 1 --max-frames 5 wipe" +check success-negative-canvas 0 bash -c "printf 'hi' | $RUST --canvas-width -1 --parity-dump --seed 1 --max-frames 2 wipe" +check success-multi-stops 0 bash -c "printf 'hi' | $RUST --parity-dump --seed 1 --max-frames 2 wipe --final-gradient-stops ff0000 00ff00 0000ff" + +echo "cli corpus: $pass passed, $fail failed" +if [ $fail -gt 0 ]; then printf 'FAILED: %s\n' "${failed[@]}"; exit 1; fi diff --git a/vendor/ttfx/tools/tests/resize_behavior.py b/vendor/ttfx/tools/tests/resize_behavior.py new file mode 100644 index 0000000..485da82 --- /dev/null +++ b/vendor/ttfx/tools/tests/resize_behavior.py @@ -0,0 +1,150 @@ +"""Resize-restart behavior, driven on a real pty. + +The restart-on-resize path is signal-driven and tty-dependent, so none of the +other suites can see it: parity runs go through --parity-dump, and the CLI +corpus never allocates a terminal. This spawns the built binary on a pty, +drives TIOCSWINSZ, and asserts on the emitted byte stream. + +Each prep_canvas emits exactly one hide-cursor, so counting those counts runs. + +Usage: resize_behavior.py [path-to-ttfx] +""" + +from __future__ import annotations + +import fcntl +import os +import pty +import select +import struct +import sys +import termios +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BIN = sys.argv[1] if len(sys.argv) > 1 else str(ROOT / "target/release/ttfx") +HIDE, SHOW = b"\x1b[?25l", b"\x1b[?25h" +MAX_BYTES = 8 << 20 +TEXT = b"hello world\nsecond line" + + +def set_size(fd: int, cols: int, rows: int) -> None: + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + + +def child_env() -> dict[str, str]: + # COLUMNS/LINES would win over the tty and make every resize invisible. + return {k: v for k, v in os.environ.items() if k not in ("COLUMNS", "LINES")} + + +def spawn(args, stdout_pipe: bool): + """Fork onto a pty; stdin is always a pipe, stdout optionally one too.""" + sin_r, sin_w = os.pipe() + out_r, out_w = (os.pipe() if stdout_pipe else (None, None)) + pid, fd = pty.fork() + if pid == 0: + os.close(sin_w) + os.dup2(sin_r, 0) + os.close(sin_r) + if stdout_pipe: + os.close(out_r) + os.dup2(out_w, 1) + os.close(out_w) + os.execve(BIN, [BIN] + args, child_env()) + os._exit(127) + os.close(sin_r) + if stdout_pipe: + os.close(out_w) + return pid, fd, sin_w, out_r + + +def drive(args, resizes=(), cols=80, rows=24, first_delay=0.30, gap=0.05, + budget=8.0, stdout_pipe=False, slow=False): + """Run to completion (or budget), applying `resizes`; return the stream.""" + pid, fd, sin_w, out_r = spawn(args, stdout_pipe) + set_size(fd, cols, rows) + os.write(sin_w, TEXT) + os.close(sin_w) + + source = out_r if stdout_pipe else fd + captured = bytearray() + start = time.time() + applied = False + while time.time() - start < budget and len(captured) < MAX_BYTES: + if not applied and time.time() - start >= first_delay: + for size in resizes: + set_size(fd, *size) + time.sleep(gap) + applied = True + ready, _, _ = select.select([source], [], [], 0.02) + if ready: + try: + chunk = os.read(source, 256 if slow else 65536) + except OSError: + break + if not chunk: + break + captured.extend(chunk) + elif not stdout_pipe and os.waitpid(pid, os.WNOHANG)[0] == pid: + break + if slow: + time.sleep(0.02) + for closer in (lambda: os.kill(pid, 9), lambda: os.waitpid(pid, 0)): + try: + closer() + except (ProcessLookupError, ChildProcessError): + pass + os.close(fd) + if stdout_pipe: + os.close(out_r) + return bytes(captured) + + +def runs_in(stream: bytes) -> int: + return stream.count(HIDE) + + +def main() -> int: + failures = 0 + + def check(label, got, want): + nonlocal failures + ok = got == want + failures += not ok + print(f" {'ok ' if ok else 'FAIL'} {label}: got {got}, want {want}") + + # A slow consumer keeps the process alive across the resize; a file sink + # drains instantly and the animation would finish before the signal lands. + print("stdout piped to a slow consumer, window resized") + piped = ["--seed", "1", "--canvas-width", "0", "pour"] + quiet = drive(piped, stdout_pipe=True, slow=True, first_delay=0.5, budget=6.0) + noisy = drive(piped, resizes=[(40, 24)], stdout_pipe=True, slow=True, first_delay=0.5, budget=6.0) + check("runs without a resize", runs_in(quiet), 1) + check("runs with a resize", runs_in(noisy), 1) + check("bytes match the undisturbed run", len(noisy), len(quiet)) + + print("tty, resize that cannot move a cell") + check("runs", runs_in(drive(["--seed", "1", "pour"], resizes=[(100, 30)])), 1) + + print("tty, resize that changes the canvas") + changed = drive(["--seed", "1", "pour"], resizes=[(8, 24)]) + check("runs", runs_in(changed), 2) + + print("tty, burst of resizes during a drag") + burst = drive(["--seed", "1", "pour"], + resizes=[(9, 24), (8, 24), (7, 24), (6, 24), (7, 24), (8, 24)], gap=0.02) + rebuilds = runs_in(burst) + ok = rebuilds <= 3 + failures += not ok + print(f" {'ok ' if ok else 'FAIL'} rebuilds for a 6-step drag: {rebuilds}, want <= 3") + + print("tty, cursor is not shown between runs") + check("show-cursor before the rebuild", changed[: changed.rfind(HIDE)].count(SHOW), 0) + + print(f"\nresize behavior: {'all checks passed' if not failures else f'{failures} failed'}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ttfx/tools/tests/sigterm_behavior.py b/vendor/ttfx/tools/tests/sigterm_behavior.py new file mode 100644 index 0000000..02912a8 --- /dev/null +++ b/vendor/ttfx/tools/tests/sigterm_behavior.py @@ -0,0 +1,122 @@ +"""SIGTERM teardown behavior, driven on a real pty. + +A supervisor killing an animation must not leave the cursor hidden. On a tty +ttfx restores it and then dies from the signal; a redirected stream keeps the +default action and gains no teardown bytes. Either way the parent sees a child +terminated by SIGTERM. Only a real tty shows any of this, so this spawns the +built binary on a pty and asserts on the emitted byte stream. + +Usage: sigterm_behavior.py [path-to-ttfx] +""" + +from __future__ import annotations + +import fcntl +import os +import pty +import select +import signal +import struct +import sys +import termios +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BIN = sys.argv[1] if len(sys.argv) > 1 else str(ROOT / "target/release/ttfx") +HIDE, SHOW = b"\x1b[?25l", b"\x1b[?25h" +# Long enough that the signal always lands mid-animation. +ARGS = ["--frame-rate", "30", "colorshift", "--cycles", "100"] + + +def spawn(stdout_pipe: bool): + """Fork onto a pty; stdin is always a pipe, stdout optionally one too.""" + sin_r, sin_w = os.pipe() + out_r, out_w = os.pipe() if stdout_pipe else (None, None) + pid, fd = pty.fork() + if pid == 0: + os.close(sin_w) + os.dup2(sin_r, 0) + os.close(sin_r) + if stdout_pipe: + os.close(out_r) + os.dup2(out_w, 1) + os.close(out_w) + # COLUMNS/LINES would win over the tty we just sized. + env = {k: v for k, v in os.environ.items() if k not in ("COLUMNS", "LINES")} + os.execve(BIN, [BIN] + ARGS, env) + os._exit(127) + os.close(sin_r) + if stdout_pipe: + os.close(out_w) + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + os.write(sin_w, b"hello\n") + os.close(sin_w) + return pid, fd, out_r + + +def drain(source: int, captured: bytearray, until: bytes | None = None) -> None: + """Read until `until` shows up, or with no needle until the source ends.""" + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + if until is not None and until in captured: + return + if not select.select([source], [], [], 0.02)[0]: + continue + try: + chunk = os.read(source, 65536) + except OSError: # the pty master reports EIO once the child is gone + return + if not chunk: + return + captured.extend(chunk) + + +def reap(pid: int) -> int: + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + done, status = os.waitpid(pid, os.WNOHANG) + if done: + return status + time.sleep(0.01) + os.kill(pid, signal.SIGKILL) + return os.waitpid(pid, 0)[1] + + +def run(stdout_pipe: bool): + pid, fd, out_r = spawn(stdout_pipe) + source = out_r if stdout_pipe else fd + captured = bytearray() + drain(source, captured, until=HIDE) + os.kill(pid, signal.SIGTERM) + # The stream ends when the child does, so this also waits for the exit. + drain(source, captured) + status = reap(pid) + os.close(source) + if stdout_pipe: + os.close(fd) + return status, bytes(captured) + + +def killed_by_sigterm(status: int) -> bool: + return os.WIFSIGNALED(status) and os.WTERMSIG(status) == signal.SIGTERM + + +def main() -> int: + tty_status, tty_output = run(False) + pipe_status, pipe_output = run(True) + checks = [ + ("tty dies from SIGTERM", killed_by_sigterm(tty_status)), + ("tty restores the cursor", tty_output.count(HIDE) == 1 and tty_output.count(SHOW) == 1), + ("pipe dies from SIGTERM", killed_by_sigterm(pipe_status)), + ("pipe gets no teardown", SHOW not in pipe_output), + ] + for label, passed in checks: + print(f" {'ok ' if passed else 'FAIL'} {label}") + failures = sum(not passed for _, passed in checks) + print(f"\nsigterm behavior: {'all checks passed' if not failures else f'{failures} failed'}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ttfx/tools/tests/terminal_close_behavior.py b/vendor/ttfx/tools/tests/terminal_close_behavior.py new file mode 100644 index 0000000..560b340 --- /dev/null +++ b/vendor/ttfx/tools/tests/terminal_close_behavior.py @@ -0,0 +1,143 @@ +"""Losing the terminal mid-animation, driven on a real pty. + +When the terminal an animation is drawing on goes away — the screensaver's +window killed at lock, an emulator exiting — every write from here on fails +with EIO. That is the display ending, not a failure of the run: ttfx must stop +quietly, say nothing about it, and above all not abort. Reporting it was the +whole bug (basecamp/omarchy#6762): `eprintln!` panics when stderr is the dead +terminal too, and a release build aborts on panic, so every idle lock left a +core dump behind. + +Only a real pty shows this, so this spawns the built binary with its stdout on +one and closes the master out from under it. + +Usage: terminal_close_behavior.py [path-to-ttfx] +""" + +from __future__ import annotations + +import fcntl +import os +import select +import signal +import struct +import sys +import termios +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BIN = sys.argv[1] if len(sys.argv) > 1 else str(ROOT / "target/release/ttfx") +HIDE = b"\x1b[?25l" +# Long enough that the terminal always closes mid-animation. +ARGS = ["--frame-rate", "30", "colorshift", "--cycles", "100"] + + +def spawn(stderr_pipe: bool): + """Fork with stdout on a pty; stderr optionally on a pipe we can read. + + Deliberately no setsid: with the pty as a controlling terminal the kernel + also sends SIGHUP when the master closes, and the race between that and + the failing write would decide which path the child took. Without one, + only the write error is left — the path under test. + """ + sin_r, sin_w = os.pipe() + err_r, err_w = os.pipe() if stderr_pipe else (None, None) + master, slave = os.openpty() + pid = os.fork() + if pid == 0: + os.close(master) + os.close(sin_w) + os.dup2(sin_r, 0) + os.dup2(slave, 1) + os.dup2(err_w if stderr_pipe else slave, 2) + os.close(slave) + # COLUMNS/LINES would win over the tty we just sized. + env = {k: v for k, v in os.environ.items() if k not in ("COLUMNS", "LINES")} + os.execve(BIN, [BIN] + ARGS, env) + os._exit(127) + os.close(slave) + os.close(sin_r) + if stderr_pipe: + os.close(err_w) + fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + os.write(sin_w, b"hello\n") + os.close(sin_w) + return pid, master, err_r + + +def drain(source: int, until: bytes | None = None) -> bytes: + """Read until `until` shows up, or with no needle until the source ends.""" + captured = bytearray() + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + if until is not None and until in captured: + break + if not select.select([source], [], [], 0.02)[0]: + continue + try: + chunk = os.read(source, 65536) + except OSError: # the pty master reports EIO once the child is gone + break + if not chunk: + break + captured.extend(chunk) + return bytes(captured) + + +def reap(pid: int) -> int | None: + """Wait out the exit; None means it is still running and had to be killed.""" + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + done, status = os.waitpid(pid, os.WNOHANG) + if done: + return status + time.sleep(0.01) + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + return None + + +def run(stderr_pipe: bool): + pid, master, err_r = spawn(stderr_pipe) + drain(master, until=HIDE) + os.close(master) # the terminal is gone; the next frame hits EIO + status = reap(pid) + stderr = drain(err_r) if stderr_pipe else b"" + if stderr_pipe: + os.close(err_r) + return status, stderr + + +def exited_quietly(status: int | None) -> bool: + return status is not None and os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 + + +def aborted(status: int | None) -> bool: + return status is not None and os.WIFSIGNALED(status) and os.WTERMSIG(status) == signal.SIGABRT + + +def main() -> int: + if sys.platform != "linux": + # A pty slave whose master is closed only reliably reports EIO here. + print("terminal close behavior: skipped (linux only)") + return 0 + pipe_status, pipe_stderr = run(True) + tty_status, _ = run(False) + checks = [ + ("a closed terminal ends the run", exited_quietly(pipe_status)), + ("nothing is reported about it", pipe_stderr == b""), + ("no abort with stderr on the dead terminal", not aborted(tty_status)), + ("that run ends too", tty_status is not None), + ] + for label, passed in checks: + print(f" {'ok ' if passed else 'FAIL'} {label}") + failures = sum(not passed for _, passed in checks) + if failures and pipe_stderr: + print(f"\nstderr: {pipe_stderr.decode(errors='replace').strip()}") + print(f"\nterminal close behavior: {'all checks passed' if not failures else f'{failures} failed'}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main())