commit b7fca0146dcdcf42386b8dddb39f194e65f47bab Author: 雷电芽衣 Date: Thu Oct 8 21:20:08 2026 -0400 Ely Office: six offline-first desktop apps in Rust and GPUI ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow. diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..40cb15b --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +[target.'cfg(all(windows, target_env = "msvc"))'] +rustflags = ["-C", "target-feature=+crt-static"] + +[env] +# cef-dll-sys builds against the CEF that scripts/fetch-cef.sh fetched. +CEF_PATH = { value = "engines/cef", relative = true } diff --git a/.github/workflows/engines.yml b/.github/workflows/engines.yml new file mode 100644 index 0000000..e1248ed --- /dev/null +++ b/.github/workflows/engines.yml @@ -0,0 +1,77 @@ +# Builds the patched engines once; release.yml downloads them. +name: engines + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + macos: + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + arch: arm64 + - runner: macos-15-intel + arch: x86_64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 355 + env: + GH_TOKEN: ${{ github.token }} + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 6G + steps: + - uses: actions/checkout@v4 + - name: Key + id: key + run: echo "key=$(scripts/engines-key.sh)" >> "$GITHUB_OUTPUT" + - name: Already built? + id: done + run: | + if gh release view "engines-${{ steps.key.outputs.key }}" --json assets \ + --jq '.assets[].name' 2>/dev/null | grep -qx "engines-macos-${{ matrix.arch }}.tar.zst"; then + echo "built=true" >> "$GITHUB_OUTPUT" + fi + - name: Tools + if: steps.done.outputs.built != 'true' + run: brew install make gnu-sed bison flex gettext autoconf automake libtool ccache nasm zstd + - name: Compiler cache + if: steps.done.outputs.built != 'true' + uses: actions/cache/restore@v4 + with: + path: .ccache + key: ccache-macos-${{ matrix.arch }}-${{ steps.key.outputs.key }}-${{ github.run_id }} + restore-keys: ccache-macos-${{ matrix.arch }}- + - name: Upstream sources + if: steps.done.outputs.built != 'true' + run: scripts/fetch-references.sh + - name: LibreOffice (patched) + if: steps.done.outputs.built != 'true' + run: scripts/build-libreoffice.sh + - name: MuPDF (patched), Git, git-lfs, OCR data, CEF + if: steps.done.outputs.built != 'true' + run: | + scripts/build-mupdf.sh + scripts/build-git.sh + scripts/fetch-git-lfs.sh + scripts/fetch-tessdata.sh + scripts/fetch-cef.sh + - name: Keep the compiler cache, even after a timeout + if: always() && steps.done.outputs.built != 'true' + uses: actions/cache/save@v4 + with: + path: .ccache + key: ccache-macos-${{ matrix.arch }}-${{ steps.key.outputs.key }}-${{ github.run_id }} + - name: Publish + if: steps.done.outputs.built != 'true' + run: | + archive=engines-macos-${{ matrix.arch }}.tar.zst + tar -C engines -cf - lo-src/instdir mupdf-build mupdf-src git git-src/COPYING git-src/LGPL-2.1 cef tessdata | + zstd -19 -T0 -o "$archive" + tag=engines-${{ steps.key.outputs.key }} + gh release view "$tag" >/dev/null 2>&1 || + gh release create "$tag" --prerelease --title "$tag" --notes "Pinned, patched engines; built by engines.yml." + gh release upload "$tag" "$archive" --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5912068 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +# On a version tag: macOS apps attached to its release. +name: release + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write + +jobs: + macos: + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + arch: arm64 + - runner: macos-15-intel + arch: x86_64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + - name: Engines + run: | + brew install zstd + tag=engines-$(scripts/engines-key.sh) + gh release download "$tag" --pattern "engines-macos-${{ matrix.arch }}.tar.zst" || + { echo "::error::no $tag for ${{ matrix.arch }}; run the engines workflow first"; exit 1; } + mkdir -p engines + zstd -dc "engines-macos-${{ matrix.arch }}.tar.zst" | tar -C engines -xf - + - name: Upstream sources and ElyMark's page + run: | + scripts/fetch-references.sh + scripts/build-mark-content.sh + - name: Tests that need no window + run: | + tests/scripts/types.sh + tests/scripts/icons.sh + python3 tests/scripts/comments.py + - name: Packages + run: packaging/macos/release.sh 1 + - name: Attach to the release + if: startsWith(github.ref, 'refs/tags/') + run: | + version=${GITHUB_REF_NAME} + gh release view "$version" >/dev/null 2>&1 || gh release create "$version" --title "Ely Office $version" --notes "Built by release.yml." + gh release upload "$version" dist/*.dmg --clobber + if [ "${{ matrix.arch }}" = arm64 ]; then + gh release upload "$version" dist/ElyOffice-*-source.tar.gz dist/ElyOffice-*-upstream-source.tar.gz --clobber + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..433c540 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Build output and downloads +/target/ +/dist/ +/engines/ +/reference/ +node_modules/ +.DS_Store +.ruff_cache/ + +# Internal working files, kept local +/agents.md +/CLAUDE.md +/PRD.md +/tasks/ +/.pi/ +/markdown-sample.md +/docs/engineering-risks.md +/docs/qa.md +/docs/acceptance.md +/docs/test-map.md +/scripts/review.sh +/scripts/test-map.py +/tests/scripts/agents_md.py +/assets/papirus_icons-master.zip diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..db14fa2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,9348 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "enumn", + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf 0.13.1", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead", + "aes 0.9.3", + "cipher 0.5.2", + "ctr", + "ctutils", + "ghash", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alacritty_terminal" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" +dependencies = [ + "base64 0.22.1", + "bitflags 2.13.2", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix", + "rustix-openpty", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[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 0.61.2", +] + +[[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 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object 0.39.1", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "ashpd" +version = "0.13.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.3", + "serde", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.21", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object 0.37.3", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "barcoders" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3826fb6e98ec72c0c0db8c9a40af4932d16793021027469857e84c1b50a1e8f" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec 0.9.1", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix", + "slab", + "tracing", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cbindgen" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" +dependencies = [ + "heck 0.4.1", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.119", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cef" +version = "154.3.0+154.0.32" +source = "git+https://github.com/tauri-apps/cef-rs?rev=a712ad9bacd234c050631a95b9745b27d346b7cc#a712ad9bacd234c050631a95b9745b27d346b7cc" +dependencies = [ + "cef-dll-sys", + "libloading 0.9.0", + "objc2 0.6.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "cef-dll-sys" +version = "154.3.0+154.0.32" +source = "git+https://github.com/tauri-apps/cef-rs?rev=a712ad9bacd234c050631a95b9745b27d346b7cc#a712ad9bacd234c050631a95b9745b27d346b7cc" +dependencies = [ + "anyhow", + "cc", + "cmake", + "download-cef", + "serde_json", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation 0.1.2", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" +dependencies = [ + "bitflags 2.13.2", + "block", + "cocoa-foundation 0.2.0", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" +dependencies = [ + "bitflags 2.13.2", + "block", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "libc", + "objc", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[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 = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" +dependencies = [ + "bzip2", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-helmer-fork" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "core-graphics2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4416167a69126e617f8d0a214af0e3c1dbdeffcb100ddf72dcd1a1ac9893c146" +dependencies = [ + "bitflags 2.13.2", + "block", + "cfg-if", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "core-text" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" +dependencies = [ + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-video" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139679cc63eb9504bdbe37e37874b0247136177655f0008588781e90863afa62" +dependencies = [ + "block", + "core-foundation 0.10.0", + "core-graphics2", + "io-surface", + "libc", + "metal", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" +dependencies = [ + "bitflags 2.13.2", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 2.1.3", + "self_cell", + "skrifa 0.40.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.6", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set 0.8.0", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "download-cef" +version = "3.0.0" +source = "git+https://github.com/tauri-apps/cef-rs?rev=a712ad9bacd234c050631a95b9745b27d346b7cc#a712ad9bacd234c050631a95b9745b27d346b7cc" +dependencies = [ + "bzip2", + "clap", + "fs-err", + "indicatif", + "regex", + "semver", + "serde", + "serde_json", + "sha1_smol", + "tar", + "thiserror 2.0.21", + "ureq", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "ely-agent" +version = "0.1.0" +dependencies = [ + "ely-platform", + "ely-ui", +] + +[[package]] +name = "ely-doc" +version = "0.1.0" +dependencies = [ + "anyhow", + "csv", + "directories", + "ely-files", + "ely-gpui-component", + "ely-lok", + "ely-office", + "ely-platform", + "ely-ui", + "env_logger", + "gpui-pre", + "image", + "jiff", + "log", + "tempfile", +] + +[[package]] +name = "ely-files" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "ely-platform", + "getrandom 0.4.3", + "keyring", + "libc", + "log", + "serde_json", + "tempfile", + "windows-sys 0.60.2", +] + +[[package]] +name = "ely-gpui-component" +version = "0.1.1" +source = "git+https://github.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=69265b4fd4da888e2d8e1268021af4d358e67afb#69265b4fd4da888e2d8e1268021af4d358e67afb" +dependencies = [ + "alacritty_terminal", + "anyhow", + "barcoders", + "block", + "cocoa 0.26.0", + "emojis", + "futures", + "gpui-pre", + "gpui-pre-platform", + "image", + "iso_currency", + "isocountry", + "isolang", + "jiff", + "log", + "objc", + "pulldown-cmark", + "qrcode", + "raw-window-handle", + "regex", + "rqrr", + "rust-embed", + "rustix", + "serde", + "serde_json", + "similar", + "smallvec", + "unicode-segmentation", + "vte", + "web-time", + "wry", +] + +[[package]] +name = "ely-ipc" +version = "0.1.0" +dependencies = [ + "log", +] + +[[package]] +name = "ely-lok" +version = "0.1.0" +dependencies = [ + "base64 0.23.1", + "ely-gpui-component", + "ely-ipc", + "ely-platform", + "ely-ui", + "env_logger", + "futures", + "gpui-pre", + "image", + "libc", + "log", + "serde_json", + "tempfile", + "windows-sys 0.60.2", +] + +[[package]] +name = "ely-mail" +version = "0.1.0" +dependencies = [ + "ely-platform", + "ely-ui", +] + +[[package]] +name = "ely-mark" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.23.1", + "ely-files", + "ely-gpui-component", + "ely-office", + "ely-platform", + "ely-ui", + "ely-web-content", + "futures", + "gpui-pre", + "jiff", + "log", + "notify", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "ely-office" +version = "0.1.0" +dependencies = [ + "anyhow", + "directories", + "ely-files", + "ely-gpui-component", + "ely-lok", + "ely-platform", + "ely-ui", + "gpui-pre", + "jiff", + "log", + "quick-xml", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "ely-pdf" +version = "0.1.0" +dependencies = [ + "ely-files", + "ely-gpui-component", + "ely-office", + "ely-pdf-engine", + "ely-platform", + "ely-ui", + "gpui-pre", + "image", + "log", + "tempfile", +] + +[[package]] +name = "ely-pdf-engine" +version = "0.1.0" +dependencies = [ + "cc", + "ely-ipc", + "ely-pdf-engine", + "env_logger", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-pdf-kit", + "serde_json", + "tempfile", +] + +[[package]] +name = "ely-platform" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "core-foundation 0.10.0", + "core-video", + "directories", + "env_logger", + "image", + "libc", + "log", + "metal", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-av-foundation", + "objc2-core-foundation", + "objc2-core-media", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-pdf-kit", + "rfd", + "security-framework 3.7.0", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "ureq", + "windows-sys 0.60.2", +] + +[[package]] +name = "ely-show" +version = "0.1.0" +dependencies = [ + "ely-files", + "ely-gpui-component", + "ely-lok", + "ely-office", + "ely-platform", + "ely-ui", + "gpui-pre", + "gpui-pre-platform", + "image", + "jiff", + "log", + "serde_json", + "tempfile", +] + +[[package]] +name = "ely-table" +version = "0.1.0" +dependencies = [ + "ely-gpui-component", + "ely-lok", + "ely-office", + "ely-platform", + "ely-ui", + "encoding_rs", + "gpui-pre", + "log", + "serde_json", + "tempfile", +] + +[[package]] +name = "ely-tree" +version = "0.1.0" +dependencies = [ + "ely-files", + "ely-gpui-component", + "ely-platform", + "ely-ui", + "futures", + "gpui-pre", + "jiff", + "libc", + "log", + "notify", + "serde_json", + "tempfile", +] + +[[package]] +name = "ely-ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "directories", + "ely-gpui-component", + "ely-platform", + "futures", + "gpui-pre", + "gpui-pre-platform", + "log", + "notify", + "serde_json", + "tempfile", +] + +[[package]] +name = "ely-web-content" +version = "0.1.0" +dependencies = [ + "anyhow", + "cef", + "ely-gpui-component", + "ely-platform", + "ely-ui", + "futures", + "gpui-pre", + "image", + "libc", + "log", + "objc2 0.6.4", + "serde_json", + "tempfile", +] + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.6+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "emojis" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1514ced566c94991ed9563258ecf61e311fd773bf0a3f20606830386bf66e3" +dependencies = [ + "phf 0.13.1", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +dependencies = [ + "getrandom 0.4.3", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin 0.9.9", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[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 = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "fs-err" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29" +dependencies = [ + "autocfg", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "g2p" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" +dependencies = [ + "g2gen", + "g2poly", +] + +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows 0.62.2", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.2", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "gpui-pre" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f0bc3ebba070f8981b3f8e905a12f47d27794a30d029e59cdb9edf7a4b3360" +dependencies = [ + "accesskit", + "anyhow", + "async-channel", + "async-task", + "backtrace", + "bindgen", + "bitflags 2.13.2", + "chrono", + "core-video", + "ctor", + "derive_more", + "embed-resource", + "etagere", + "futures", + "futures-concurrency", + "getrandom 0.3.4", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-macros", + "gpui-pre-refineable", + "gpui-pre-scheduler", + "gpui-pre-shared-string", + "gpui-pre-sum-tree", + "gpui-pre-util", + "gpui-pre-util-macros", + "heapless", + "image", + "inventory", + "itertools 0.14.0", + "libc", + "log", + "lyon", + "num_cpus", + "objc2-core-foundation", + "objc2-core-video", + "parking", + "parking_lot", + "pin-project", + "pollster 0.4.0", + "postage", + "profiling", + "proptest", + "rand 0.9.5", + "raw-window-handle", + "regex", + "resvg", + "schemars", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "spin 0.10.1", + "stacksafe", + "strum 0.28.0", + "taffy", + "thiserror 2.0.21", + "ttf-parser", + "url", + "usvg", + "uuid", + "waker-fn", + "web-time", + "windows 0.62.2", + "zed-scap", +] + +[[package]] +name = "gpui-pre-apple" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfdd914311cd12894367c8b9908947bb0c27fd42f8b9a5ff7a0730551a83fa4c" +dependencies = [ + "anyhow", + "async-task", + "block2 0.6.2", + "cbindgen", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "core-video", + "derive_more", + "dispatch2", + "etagere", + "foreign-types", + "gpui-pre", + "gpui-pre-util", + "image", + "libc", + "log", + "mach2", + "metal", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "parking_lot", +] + +[[package]] +name = "gpui-pre-collections" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c060863025dc3b8e90febad2d1f4ffd23a5d0dcedba39d3f6b7820ea2fdd3a0" +dependencies = [ + "gpui-pre-util", + "indexmap", + "rustc-hash 2.1.3", +] + +[[package]] +name = "gpui-pre-derive-refineable" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc834f30f5f49791e214f1fde01a009c7de91c9cd0a9f31dabfed130ba39e6cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-http-client" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2935cf82333da3f41774e5757837756408f01356197b41c817ad2666034453" +dependencies = [ + "anyhow", + "async-compression", + "bytes", + "derive_more", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + +[[package]] +name = "gpui-pre-linux" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1131746fad5c87b5de74dc9c73105655b0c820a2dd4bb247586573e7cf5720e" +dependencies = [ + "accesskit", + "accesskit_unix", + "anyhow", + "bytemuck", + "calloop", + "futures", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-util", + "libc", + "log", + "notify-rust", + "oo7", + "parking_lot", + "raw-window-handle", + "smallvec", + "smol", + "strum 0.28.0", + "url", + "uuid", +] + +[[package]] +name = "gpui-pre-macos" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d756813ff1cd741a502af60fd18869e0677795936d7349642b41c49f43120c0" +dependencies = [ + "accesskit", + "accesskit_macos", + "anyhow", + "block2 0.6.2", + "cocoa 0.26.0", + "core-foundation 0.10.0", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", + "core-video", + "ctor", + "dispatch2", + "foreign-types", + "futures", + "gpui-pre", + "gpui-pre-apple", + "gpui-pre-collections", + "gpui-pre-scheduler", + "gpui-pre-util", + "image", + "itertools 0.14.0", + "log", + "objc", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-graphics", + "objc2-core-media", + "objc2-foundation 0.3.2", + "objc2-screen-capture-kit", + "objc2-user-notifications", + "parking_lot", + "pathfinder_geometry", + "raw-window-handle", + "semver", + "smallvec", + "strum 0.28.0", + "uuid", + "zed-font-kit", +] + +[[package]] +name = "gpui-pre-macros" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ad26ca759364694cb55487bebff4c1b7dd6accab49990ff3cda6f7a818a9f4" +dependencies = [ + "heck 0.5.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-perf" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcb288a506a476b6cbf97e6ec1cc0118db43f0ca91087588afa1c2d8cd4f842" +dependencies = [ + "gpui-pre-collections", + "serde", + "serde_json", +] + +[[package]] +name = "gpui-pre-platform" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e200b7b78836f1a0fc70767d971768e88f29d8b91b697285c15fb8e373635a3e" +dependencies = [ + "anyhow", + "console_error_panic_hook", + "gpui-pre", + "gpui-pre-linux", + "gpui-pre-macos", + "gpui-pre-web", + "gpui-pre-wgpu", + "gpui-pre-windows", +] + +[[package]] +name = "gpui-pre-refineable" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4042345bb5bd3ecbc5d15b21ed128154f462a992058d7038cd397631f5462338" +dependencies = [ + "gpui-pre-derive-refineable", +] + +[[package]] +name = "gpui-pre-scheduler" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4acd73345bc3c09f1b98c1b21eb4459ad8336f89bb2edcef619e11c5a08456e" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume", + "futures", + "parking_lot", + "rand 0.9.5", + "wasm_thread", + "web-time", +] + +[[package]] +name = "gpui-pre-shared-string" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "477529fd5aa90bff969785ee9a3938484d94aa5101cdf7b84719629c0345f98a" +dependencies = [ + "schemars", + "serde", + "smol_str", +] + +[[package]] +name = "gpui-pre-sum-tree" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ec0ec176d576b82aed716cf47185856b54ea9528caaf9d01787024689bfd824" +dependencies = [ + "gpui-pre-ztracing", + "heapless", + "log", + "rayon", + "tracing", +] + +[[package]] +name = "gpui-pre-util" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1635ae7d0b05400bfa211ddef6c67dc8d9f223c463022c2b3bf2c1c14c6044f" +dependencies = [ + "anyhow", + "log", + "which", +] + +[[package]] +name = "gpui-pre-util-macros" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01486fce7722e5d7d7e7bb71c719dfdcc6ed917112bdc7c56d0556fdb60d10ab" +dependencies = [ + "gpui-pre-perf", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-web" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "184776d0763e6889dd2842dcb1204c15055945f2bdfe926b5ef57bdbd1952a65" +dependencies = [ + "anyhow", + "console_error_panic_hook", + "futures", + "gpui-pre", + "gpui-pre-http-client", + "gpui-pre-scheduler", + "gpui-pre-wgpu", + "js-sys", + "log", + "parking_lot", + "raw-window-handle", + "smallvec", + "unicode-properties", + "unicode-script", + "unicode-segmentation", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_thread", + "web-sys", + "web-time", +] + +[[package]] +name = "gpui-pre-wgpu" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439c5421cafe1a7cf68d0c0ae5cc82b26fad6b0e6345ac98d20acaf1f2463914" +dependencies = [ + "anyhow", + "bytemuck", + "cosmic-text", + "etagere", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-util", + "image", + "itertools 0.14.0", + "log", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "swash", + "unicode-bidi", + "unicode-segmentation", + "web-sys", + "wgpu", +] + +[[package]] +name = "gpui-pre-windows" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "371f9bb13f97e3bea2f8b8e5e81232bb74d735153c244ae67f7400e743925b1c" +dependencies = [ + "accesskit", + "accesskit_windows", + "anyhow", + "dunce", + "etagere", + "futures", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-scheduler", + "gpui-pre-util", + "image", + "itertools 0.14.0", + "log", + "parking_lot", + "rand 0.9.5", + "raw-window-handle", + "smallvec", + "uuid", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-numerics 0.3.1", + "windows-registry", +] + +[[package]] +name = "gpui-pre-zlog" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bbe5b98630ef4bfed5a7105ec1062e78d571912bc491388f07ea22f2457c18b" +dependencies = [ + "anyhow", + "chrono", + "gpui-pre-collections", + "log", +] + +[[package]] +name = "gpui-pre-ztracing" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f806400f6bda1fdd9fc627e158c0e44fb0c4b7767582a3621efcf92f4a850db7" +dependencies = [ + "gpui-pre-zlog", + "gpui-pre-ztracing-macro", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "gpui-pre-ztracing-macro" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d337c9acf058238e45ce0570d8a5958439242889492f91ae2d8e324211a61ccd" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "core_maths", + "read-fonts 0.37.0", + "smallvec", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[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 2.0.1", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-surface" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" +dependencies = [ + "cgl", + "core-foundation 0.10.0", + "core-foundation-sys", + "leaky-cow", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "iso_country" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20633e788d3948ea7336861fdb09ec247f5dae4267e8f0743fa97de26c28624d" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "iso_currency" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d721074ff687a7ac523217a2bedb8277ac81d83dc10216ffc2a19128ca3dd5" +dependencies = [ + "iso_country", + "proc-macro2", + "quote", + "strum 0.27.2", +] + +[[package]] +name = "isocountry" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ea1dc4bf0fb4904ba83ffdb98af3d9c325274e92e6e295e4151e86c96363e04" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "isolang" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe50d48c77760c55188549098b9a7f6e37ae980c586a24693d6b01c3b2010c3c" +dependencies = [ + "phf 0.11.3", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "js-sys", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd100e01f1154f2908dfa7d02219aeab25d0b9c7fa955164192e3245255a0c73" + +[[package]] +name = "leaky-cow" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a8225d44241fd324a8af2806ba635fc7c8a7e9a7de4d5cf3ef54e71f5926fc" +dependencies = [ + "leak", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "link-section" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +dependencies = [ + "serde_core", + "value-bag", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "time", + "uuid", +] + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[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 = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" +dependencies = [ + "bitflags 2.13.2", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[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 = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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 = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "naga" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +dependencies = [ + "arrayvec", + "bit-set 0.9.1", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.2", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-rust" +version = "4.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe430c879247371c47fdedd328ad4e301e6b30b533c5fd5db1c06d75253d0e46" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099" +dependencies = [ + "libm", + "num-integer", + "num-iter", + "num-traits", + "once_cell", + "rand 0.9.5", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-av-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-media", + "objc2-core-video", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-media" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-video", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-pdf-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c14ed801ae810c6ba487cedd7616bb48f9a8e37940042f57e862538e2c7db117" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-screen-capture-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-core-media", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-location", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oo7" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" +dependencies = [ + "aes 0.8.4", + "ashpd", + "async-fs", + "async-io", + "async-lock", + "blocking", + "cbc", + "cipher 0.4.4", + "digest 0.10.7", + "endi", + "futures-lite", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hmac", + "md-5", + "num", + "num-bigint-dig", + "pbkdf2", + "serde", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "zbus", + "zbus_macros", + "zeroize", + "zvariant", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[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 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic", + "crossbeam-queue", + "futures", + "log", + "parking_lot", + "pin-project", + "pollster 0.2.5", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.15+spec-1.1.0", +] + +[[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 = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.2", + "num-traits", + "proptest-macro", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-macro" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efaa288b896cb2b345da7b7f2110ab19e51565b83495b56fcec98a62f8b1f33e" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "unicase", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.21", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error 2.0.1", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.5", + "once_cell", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster 0.4.0", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rqrr" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d084c50c5cfa397a081850da329566c8667b0b6c4e81da16abff1fef27de93b" +dependencies = [ + "g2p", + "lru", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "indexmap", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.6", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "screencapturekit" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" +dependencies = [ + "screencapturekit-sys", +] + +[[package]] +name = "screencapturekit-sys" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" +dependencies = [ + "block", + "dispatch", + "objc", + "objc-foundation", + "objc_id", + "once_cell", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.0", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen", + "precomputed-hash", + "rustc-hash 2.1.3", + "servo_arc", + "smallvec", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[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_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_fmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" +dependencies = [ + "bstr", + "unicode-segmentation", + "web-time", +] + +[[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 = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[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.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "serde_core", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "stacksafe" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95f9c34983ac74195c710c473db6fdf1085f64a47dbaa0090d1bea03be70da66" +dependencies = [ + "stacker", + "stacksafe-macro", +] + +[[package]] +name = "stacksafe-macro" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6feeae42a2d6b0dcb8aeb2f08d9e48cdac600239cf8a20fc59f9e252e86bdfe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[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 = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sval" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b81b254da21fe1fcc4e3a74fe39b46e25e3a863078f8b71c954d47f84889dbc6" + +[[package]] +name = "sval_buffer" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50be352d2822ffafb59e3e2ddac9d5ee60f2eeadbb7b5a2a951b9f3651e87a6f" +dependencies = [ + "sval", + "sval_ref", + "zerocopy", +] + +[[package]] +name = "sval_dynamic" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048ca293b998d9a45659159f94a64063791e74cdc670164943dbb434405573d" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b5888e40f80568733217f27b7317b845f463400ced36c424b1a804730e53b2" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17664d6bb6b74947afaab9d7c991caa9bf5638d4dee16fcbef637f440796049" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c059969ca5ca163ea7fef6c9661758973d17691aba92abdcf5c428f4ec122c" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d6b29ff568c85c87561807f51d2adfff4b6016c6363133f7cd1652a12548f3" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f33ec9edc42b12764d5c90ca0a1d84189c6bde81ed27507f1e661c6e4e05853" +dependencies = [ + "serde_core", + "sval", + "sval_nested", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + +[[package]] +name = "taffy" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c034e05f6ee85a12daa63863c2245797715075c70649947aa0da54f3f2ab1d0f" +dependencies = [ + "arrayvec", + "serde", + "slotmap", + "smallvec", +] + +[[package]] +name = "tao-core-video-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "objc", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" +dependencies = [ + "thiserror 2.0.21", + "windows 0.62.2", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[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 = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[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 = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0941feceafbe7a8f59ea1096d45b97002884a41306315ad797b3684b63a81d8c" +dependencies = [ + "erased-serde", + "serde_core", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839752af8179287d27eb2b94164641b1ede9e60ab7424163388dc21ebd0508cd" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "cursor-icon", + "log", + "memchr", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm_thread" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" +dependencies = [ + "futures", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf 0.13.1", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.21", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +dependencies = [ + "arrayvec", + "bit-set 0.9.1", + "bit-vec 0.9.1", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.9.1", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "naga", + "ndk-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-result 0.4.1", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-capture" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" +dependencies = [ + "parking_lot", + "rayon", + "thiserror 2.0.21", + "windows 0.61.3", + "windows-future 0.2.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" +dependencies = [ + "base64 0.22.1", + "block2 0.6.2", + "cookie", + "crossbeam-channel", + "dirs 7.0.0", + "dom_query", + "dpi", + "dunce", + "http", + "jni", + "libc", + "ndk", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "tao-macros", + "thiserror 2.0.21", + "url", + "webview2-com", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-version", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xcb" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" +dependencies = [ + "bitflags 2.13.2", + "libc", + "quick-xml", + "x11", +] + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow 1.0.4", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zed-font-kit" +version = "0.14.1-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3898e450f36f852edda72e3f985c34426042c4951790b23b107f93394f9bff5" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "core-text", + "dirs 5.0.1", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "zed-scap" +version = "0.0.8-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b338d705ae33a43ca00287c11129303a7a0aa57b101b72a1c08c863f698ac8" +dependencies = [ + "anyhow", + "cocoa 0.25.0", + "core-graphics-helmer-fork", + "log", + "objc", + "rand 0.8.8", + "screencapturekit", + "screencapturekit-sys", + "sysinfo", + "tao-core-video-sys", + "windows 0.61.3", + "windows-capture", + "x11", + "xcb", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[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.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "serde_bytes", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..11f513c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,66 @@ +[workspace] +resolver = "3" +members = ["apps/*", "crates/*"] +# Upstream sources and their builds keep their own manifests. +exclude = ["engines", "reference"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.95" +license = "AGPL-3.0-or-later" +publish = false + +[workspace.dependencies] +csv = "1" +encoding_rs = "0.8" +jiff = "0.2" +anyhow = "1" +directories = "6" +ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "69265b4fd4da888e2d8e1268021af4d358e67afb" } +env_logger = "0.11" +gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] } +gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit"] } +futures = "0.3" +libc = "0.2" +log = "0.4" +metal = "0.33" +notify = "8" +quick-xml = "0.41" +objc2-app-kit = { version = "0.3", default-features = false, features = ["NSWorkspace", "NSAccessibility", "NSPrintInfo", "NSPrintOperation", "objc2-core-foundation"] } +rfd = { version = "0.17", default-features = false } +aes-gcm = "0.11" +base64 = "0.23" +arboard = { version = "3.6", default-features = false, features = ["image-data"] } +getrandom = "0.4" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] } +semver = { version = "1", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tempfile = "3" +ureq = "3" +windows-sys = { version = "0.60", features = ["Win32_UI_WindowsAndMessaging", "Win32_Globalization", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Threading", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Com", "Win32_UI_Shell", "Win32_Security_Authentication_Identity"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["NSLocale", "NSPathUtilities", "NSString", "NSURL", "NSDictionary", "NSValue", "NSError", "NSRunLoop", "NSDate", "NSUserDefaults"] } +objc2-av-foundation = { version = "0.3", default-features = false, features = ["std", "AVPlayer", "AVPlayerItem", "AVPlayerItemOutput", "AVAsset", "objc2-core-media", "objc2-core-video", "objc2-core-foundation"] } +objc2-core-media = { version = "0.3", default-features = false, features = ["std", "CMTime"] } +objc2-core-video = { version = "0.3", default-features = false, features = ["std", "CVBuffer", "CVImageBuffer", "CVPixelBuffer"] } +objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFString", "CFCGTypes"] } +core-video = "0.5" +cef = { git = "https://github.com/tauri-apps/cef-rs", rev = "a712ad9bacd234c050631a95b9745b27d346b7cc", default-features = false } +core-foundation = "0.10" +objc2 = "0.6" +security-framework = "3.7" +objc2-pdf-kit = { version = "0.3", default-features = false, features = ["PDFDocument", "PDFPage", "objc2-app-kit"] } + +ely-files = { path = "crates/ely-files" } +ely-ipc = { path = "crates/ely-ipc" } +ely-lok = { path = "crates/ely-lok" } +ely-office = { path = "crates/ely-office" } +ely-pdf-engine = { path = "crates/ely-pdf-engine" } +ely-platform = { path = "crates/ely-platform" } +ely-web-content = { path = "crates/ely-web-content" } +ely-ui = { path = "crates/ely-ui" } + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dba13ed --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d5cbb2 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Ely Office + +Offline-first desktop apps in Rust and GPUI. Files stay in your folders; no account, no cloud service. + +| App | Does | Engine | +| --- | --- | --- | +| ElyDoc | Word processing (DOCX, ODT) | LibreOfficeKit | +| ElyTable | Spreadsheets (XLSX, ODS, CSV) | LibreOfficeKit | +| ElyShow | Presentations (PPTX, ODP) | LibreOfficeKit, system media | +| ElyPDF | PDF reading and editing, OCR, signatures | MuPDF, Tesseract | +| ElyTree | Git client | Git | +| ElyMark | Markdown | Milkdown, CodeMirror in CEF | +| ElyMail, ElyAgent | Second release | — | + +## Build (macOS) + +```sh +scripts/doctor.sh # toolchain versions +scripts/fetch-references.sh # upstream sources at pinned commits +scripts/build-libreoffice.sh # patched LibreOffice 26.2.6.3 (about an hour) +scripts/build-mupdf.sh # patched MuPDF 1.28.5 +scripts/build-git.sh && scripts/fetch-git-lfs.sh +scripts/fetch-tessdata.sh && scripts/fetch-cef.sh +scripts/build-mark-content.sh # ElyMark's page +cargo build -p ely-lok --bins -p ely-pdf-engine --bins +cargo run -p ely-doc # or ely-table, ely-show, ely-pdf, ely-tree, ely-mark +``` + +Packages: `packaging/macos/release.sh` writes each app's `.app` and `.dmg`, the source archive and the upstream source archive (patched MuPDF, Git) to `dist/`. Linux and Windows recipes are in `packaging/linux` and `packaging/windows`; they have not been run on those systems. + +## Tests + +`cargo test --workspace`, the window scripts in `tests/scripts` (macOS, Accessibility permission), and the benchmark `tests/perf/bench.py`. + +## Documents + +| | | +| --- | --- | +| `docs/help.md` | Help: opening and saving, recovery, offline limits, repositories | +| `docs/compatibility.md` | Versioned compatibility list | +| `docs/accessibility.md` | Accessibility and language review | +| `docs/perf/` | Benchmark records | +| `docs/licenses.md` | Licences and sources | + +## Licence + +AGPL-3.0-or-later (`LICENSE`). Third-party components keep their own licences; see `docs/licenses.md`. diff --git a/apps/ely-agent/Cargo.toml b/apps/ely-agent/Cargo.toml new file mode 100644 index 0000000..d857749 --- /dev/null +++ b/apps/ely-agent/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ely-agent" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +ely-platform.workspace = true +ely-ui.workspace = true + +[lints] +workspace = true diff --git a/apps/ely-agent/src/main.rs b/apps/ely-agent/src/main.rs new file mode 100644 index 0000000..b94546d --- /dev/null +++ b/apps/ely-agent/src/main.rs @@ -0,0 +1,5 @@ +use ely_platform::AppId; + +fn main() { + ely_ui::run(AppId::Agent, ely_ui::blank); +} diff --git a/apps/ely-doc/Cargo.toml b/apps/ely-doc/Cargo.toml new file mode 100644 index 0000000..58390c2 --- /dev/null +++ b/apps/ely-doc/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "ely-doc" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +csv.workspace = true +anyhow.workspace = true +directories.workspace = true +ely-files.workspace = true +ely-gpui-component.workspace = true +ely-lok = { workspace = true, features = ["view"] } +ely-office.workspace = true +gpui.workspace = true +jiff.workspace = true +log.workspace = true +ely-platform.workspace = true +ely-ui.workspace = true + +[dev-dependencies] +ely-ui = { workspace = true, features = ["test-support"] } +env_logger.workspace = true +image.workspace = true +tempfile.workspace = true +gpui = { workspace = true, features = ["test-support"] } + +[lints] +workspace = true diff --git a/apps/ely-doc/src/actions.rs b/apps/ely-doc/src/actions.rs new file mode 100644 index 0000000..257edf6 --- /dev/null +++ b/apps/ely-doc/src/actions.rs @@ -0,0 +1,136 @@ +//! New, and ElyDoc's own dialogs (DOC-01). + +use std::rc::Rc; + +use ely_gpui_component::{ + buttons::Button, + documents::{Template as Pick, TemplatePicker}, + forms::TextInput, + overlays::Dialog as Card, +}; +use ely_office::prelude::*; +use ely_ui::commands::New; +use gpui::{AnyElement, App, Context, Entity, IntoElement, ParentElement as _, Window}; + +use crate::docs::Docs; +use crate::files::TEMPLATES; +use crate::refs::Ask; + +pub enum Dialog { + Templates, + Table { + columns: f64, + rows: f64, + }, + Section, + Ask { + ask: Ask, + field: Entity, + }, + Link { + text: Entity, + url: Entity, + }, + Reference { + marks: Vec, + mark: String, + show: &'static str, + }, + Output { + tab: u64, + print: bool, + all: bool, + pdf: ely_lok::Pdf, + }, +} + +impl Docs { + pub fn on_new(&mut self, _: &New, _: &mut Window, cx: &mut Context) { + self.dialog = Some(Dialog::Templates); + cx.notify(); + } + + /// The open dialog, if any. + pub fn dialog_element(&mut self, _: &mut Window, cx: &mut Context) -> Option { + let this = cx.entity(); + let dismiss = { + let this = this.clone(); + move |_: &mut Window, cx: &mut App| { + this.update(cx, |docs, cx| { + docs.dialog = None; + cx.notify(); + }) + } + }; + let card = match self.dialog.as_ref()? { + Dialog::Templates => templates(this, dismiss, cx), + Dialog::Section => { + return Some(crate::insert::section_dialog(this, dismiss, cx).into_any_element()); + } + Dialog::Ask { ask, field } => { + let prompt = crate::refs::ask_dialog(this, ask, field, dismiss, cx); + return Some(prompt.into_any_element()); + } + Dialog::Link { text, url } => crate::refs::link_dialog(this, (text, url), dismiss, cx), + Dialog::Reference { marks, mark, show } => { + crate::refs::reference_dialog(this, (marks, mark, show), dismiss, cx) + } + Dialog::Output { + tab, + print, + all, + pdf, + } => { + let last = self + .tab(*tab) + .expect("a modal keeps its tab") + .view + .read(cx) + .pages() + .len(); + let last = u32::try_from(last).expect("a page count"); + crate::output::output_dialog(this, (*print, *all, *pdf, last), dismiss, cx) + } + Dialog::Table { columns, rows } => { + crate::insert::table_dialog(this, (*columns, *rows), dismiss, cx) + } + }; + Some(card.into_any_element()) + } +} + +/// The New dialog: bundled templates and a local file (DOC-01). +fn templates( + this: Entity, + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &mut Context, +) -> Card { + let picks: Vec = TEMPLATES + .iter() + .map(|template| Pick { + name: Docs::text(cx, &format!("doc.template.{}", template.key()), &[]), + icon: "▤".into(), + category: Docs::text(cx, "doc.template.category", &[]), + description: Docs::text(cx, &format!("doc.template.{}.about", template.key()), &[]), + markdown: template.preview(cx).into(), + }) + .collect(); + let used = this.clone(); + let picker = TemplatePicker::new("doc-templates", Rc::new(picks)).on_use(move |ix, _, cx| { + used.update(cx, |docs, cx| { + docs.dialog = None; + docs.new_from(TEMPLATES[ix], cx); + }) + }); + let label = Docs::text(cx, "doc.new.from_file", &[]); + Card::new("doc-new", Docs::text(cx, "doc.new.title", &[]), dismiss) + .child(picker) + .action(move |close| { + Button::new("doc-new-file", label).on_click(move |_, window, cx| { + close(window, cx); + this.update(cx, |docs, cx| { + docs.pick(false, cx, |docs, path, cx| docs.new_from_file(&path, cx)) + }); + }) + }) +} diff --git a/apps/ely-doc/src/chrome.rs b/apps/ely-doc/src/chrome.rs new file mode 100644 index 0000000..a6c3113 --- /dev/null +++ b/apps/ely-doc/src/chrome.rs @@ -0,0 +1,149 @@ +//! Tabs, page, status line and side panel. + +use ely_gpui_component::{ + feedback::{Banner, EmptyState}, + primitives::{IconName, Severity}, + shell::{StatusBar, StatusBarItem}, +}; +use ely_lok::LokView; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, AnyView, Context, Entity, IntoElement, ParentElement as _, Styled as _, div, +}; + +use ely_office::prelude::*; + +use crate::docs::{Docs, Side}; + +impl Docs { + /// The save state, and the kernel's word count. + pub(crate) fn status(&self, cx: &mut Context) -> StatusBar { + let state = self.save_state(cx); + let status = StatusBar::new().left(StatusBarItem::new("doc-state").label(state)); + let zoom = self.zoom_control(cx); + let Some(counts) = self.active_tab().and_then(|tab| tab.extra.counts) else { + return status.when_some(zoom, |status, zoom| status.right(zoom)); + }; + let (words, characters) = (counts.words.to_string(), counts.characters.to_string()); + let label = match counts.selected_words { + 0 => Self::text( + cx, + "doc.count.words", + &[("words", &words), ("characters", &characters)], + ), + selected => Self::text( + cx, + "doc.count.selected", + &[("selected", &selected.to_string()), ("words", &words)], + ), + }; + status.right(StatusBarItem::new("doc-count").label(label)).when_some(zoom, |status, zoom| status.right(zoom)) + } + + pub(crate) fn body(&self, cx: &mut Context) -> AnyElement { + if let Some(tab) = self.active_tab() { + let view = div() + .relative() + .size_full() + .child(tab.view.clone()) + .children(self.find_box(cx)) + .children(self.spelling_menu(cx)) + .into_any_element(); + let risk = self.stopped_banner(cx).or_else(|| self.risk_banner(cx)); + let fonts = tab.view.read(cx).missing_fonts().join(", "); + let fonts = (!fonts.is_empty()).then(|| { + let warning = Self::text(cx, "doc.fonts.missing", &[("fonts", &fonts)]); + Banner::new("doc-fonts", Severity::Warning, warning) + }); + if risk.is_none() && fonts.is_none() { + return view; + } + return div() + .size_full() + .flex() + .flex_col() + .children(risk) + .children(fonts) + .child(div().flex_1().min_h_0().child(view)) + .into_any_element(); + } + let title = match self.core.loading.first() { + Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]), + None => Self::text(cx, "command.blocked.no_document", &[]), + }; + EmptyState::new("doc-empty", IconName::FileText, title).into_any_element() + } + + /// The navigator column, while shown. + pub(crate) fn left_view(&mut self, cx: &mut Context) -> Option { + let shown = self + .active_tab() + .map(|tab| (tab.view.clone(), tab.doc.clone())); + let tab = shown.as_ref().map(|(view, doc)| (view, doc)); + self.navigator + .update(cx, |navigator, cx| navigator.show(tab, cx)); + shown.filter(|_| self.navigating)?; + Some(self.navigator.clone().into()) + } + + /// The right column for the active tab. + pub(crate) fn side_view(&mut self, cx: &mut Context) -> Option { + let shown = self + .active_tab() + .map(|tab| (tab.view.clone(), tab.doc.clone())); + let tab = shown.as_ref().map(|(view, doc)| (view, doc)); + self.props.update(cx, |props, cx| props.show(tab, cx)); + self.review.update(cx, |review, cx| review.show(tab, cx)); + shown.as_ref()?; + Some(match self.side { + Side::Props => self.props.clone().into(), + Side::Review => self.review.clone().into(), + Side::Merge => self.merge.clone().into(), + }) + } + + /// Reads the counts, again when the kernel's count moves. + pub(crate) fn count(&mut self, id: u64, view: &Entity, cx: &mut Context) { + let state = view + .read(cx) + .state(".uno:StateWordCount") + .map(str::to_string); + let Some(tab) = self.tab(id) else { + return; + }; + // The kernel reports its count only once the status moves. + let asked = tab.extra.counted.as_ref() == Some(&state); + if tab.extra.counting || asked { + return; + } + let doc = tab.doc.clone(); + // Asked now: a failure waits for the next change. + let extra = &mut self.tab_mut(id).expect("the tab is open").extra; + (extra.counting, extra.counted) = (true, Some(state)); + ely_lok::read_off( + &self.core.worker, + &doc, + |worker, doc| { + let json = worker.values(doc, ".uno:WordCount")?; + ely_lok::review::counts(&json) + }, + move |docs: &mut Self, read, cx| { + let Some(tab) = docs.tab_mut(id) else { + return; + }; + tab.extra.counting = false; + match read { + Ok(counts) => tab.extra.counts = Some(counts), + Err(error) => docs.engine_failed(&error.to_string(), cx), + } + cx.notify(); + // The status may have moved meanwhile. + let view = docs.tab(id).map(|tab| tab.view.clone()); + if let Some(view) = view { + docs.count(id, &view, cx); + } + }, + cx, + ); + } +} diff --git a/apps/ely-doc/src/compare.rs b/apps/ely-doc/src/compare.rs new file mode 100644 index 0000000..1627ee9 --- /dev/null +++ b/apps/ely-doc/src/compare.rs @@ -0,0 +1,88 @@ +//! Compares the active document with an older file (DOC-13). + +use std::path::PathBuf; + +use ely_gpui_component::primitives::Severity; +use gpui::{Context, Window, actions}; + +use ely_office::Source; +use ely_office::files::{self, file_name}; +use ely_office::prelude::*; + +use crate::docs::{Docs, Side}; + +actions!(doc, [CompareWith]); + +impl Docs { + pub fn on_compare(&mut self, _: &CompareWith, _: &mut Window, cx: &mut Context) { + let Some(id) = self.core.active else { + let text = Docs::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + self.pick(false, cx, move |docs, older, cx| { + docs.compare(id, older, cx) + }); + } + + /// A new tab holds this version, marked against `older`. + pub(crate) fn compare(&mut self, id: u64, older: PathBuf, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |docs, _, cx| docs.compare(id, older, cx)); + } + let Some(tab) = self.tab(id) else { + return; + }; + let name = tab.name.clone(); + // The copy's tab and folder, reserved now. + let into = self.core.next; + self.core.next += 1; + let dir = match files::work_dir(&self.core.work, into) { + Ok(dir) => dir, + Err(error) => return self.failed_open(&name, &error, cx), + }; + let copy = dir.join("compared.odt"); + let url = ely_lok::file_url(©); + self.read_in( + id, + move |worker, doc| doc.save_as(worker, &url, "odt"), + move |docs, saved, _, cx| match saved { + Ok(()) => docs.compare_copy((id, into), name, copy, older, cx), + Err(error) => docs.failed_open(&name, &error, cx), + }, + cx, + ); + } + + /// Opens the saved copy and marks it against `older`. + fn compare_copy( + &mut self, + (id, into): (u64, u64), + name: gpui::SharedString, + copy: PathBuf, + older: PathBuf, + cx: &mut Context, + ) { + log::info!("doc: comparing tab {id} with {}", file_name(&older)); + self.load_into(into, Source::Copy(copy), cx); + let url = ely_lok::file_url(&older); + self.core.waiting.push(( + into, + Box::new(move |docs, _, cx| { + let Some(tab) = docs.tab_mut(into) else { + return; + }; + tab.name = Docs::text(cx, "doc.compare.name", &[("name", &name)]); + let sent = tab + .view + .update(cx, |view, _| view.run("doc.compare", &[&url, "true"])); + if let Err(error) = sent { + let text = + Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); + return docs.toast(text, Severity::Danger, cx); + } + docs.side = Side::Review; + cx.notify(); + }), + )); + } +} diff --git a/apps/ely-doc/src/docs.rs b/apps/ely-doc/src/docs.rs new file mode 100644 index 0000000..7370d59 --- /dev/null +++ b/apps/ely-doc/src/docs.rs @@ -0,0 +1,221 @@ +//! ElyDoc's window: document tabs over one LOK worker (§8.1). + +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use ely_lok::{LokError, LokView, Worker, review::Counts}; +use ely_office::{Core, Kind, prelude::*}; +use ely_platform::AppId; +use ely_ui::{Root, Shell, commands}; +use gpui::{ + App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, Render, SharedString, Styled as _, Window, div, +}; + +use crate::actions::Dialog; +use crate::files::Template; +use crate::props::Props; +use crate::review::Review; + +/// What ElyDoc keeps per tab. +#[derive(Default)] +pub struct Extra { + /// The kernel's counts, read when its word count moves. + pub counts: Option, + /// The word count state last asked about. + pub counted: Option>, + /// A count is on its way. + pub counting: bool, +} + +pub struct Docs { + pub core: Core, + pub dialog: Option, + pub props: Entity, + pub review: Entity, + /// What the right column shows. + pub side: Side, + pub offer: Option, + pub merge: Entity, + pub navigator: Entity, + /// The left column shows the navigator. + pub navigating: bool, + /// Where jumps left from, latest last. + pub back: Vec<(u64, ely_lok::Rect)>, + /// Last jump: tab, input, prior cursor moves, target. + pub going: Option<(u64, u64, u64, ely_lok::Rect)>, +} + +/// The right column's panels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Side { + Props, + Review, + Merge, +} + +impl Host for Docs { + type Extra = Extra; + type Engine = ely_office::Lok; + const KIND: Kind = Kind { + app: AppId::Doc, + kept: &["docx", "odt", "doc", "rtf", "fodt", "txt"], + save_as: &["docx", "odt"], + macros: Some(&["docm", "dotm"]), + }; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Tab stops come with the events: the view's state. + fn read_at(&self, tab: u64, cx: &gpui::App) -> Option { + self.tab(tab).map(|tab| tab.view.read(cx).read_at()) + } + + fn observed(&mut self, id: u64, view: &Entity, cx: &mut Context) { + self.count(id, view, cx); + self.found(id, view, cx); + self.offer_spelling(id, view, cx); + } + + fn search_leaves(&mut self, cx: &mut Context) { + self.mark_place(cx); + } +} + +/// The window root with a blank document. +pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { + ely_lok::init(cx); + let (worker, work) = match ely_office::start(app, cx) { + Ok(started) => started, + Err(error) => ely_platform::startup::fail(app, &format!("{error}")), + }; + let docs = cx.new(|cx| { + let mut host = Docs::new(worker, work, cx); + // Recovery points, crash return, autosave (§6.2, §6.3). + ely_office::Recovering::start_recovery(&mut host, cx); + ::start_autosave(cx); + host + }); + cx.bind_keys([ + gpui::KeyBinding::new( + "secondary-f", + ely_office::FindReplace, + Some(commands::CONTEXT), + ), + gpui::KeyBinding::new( + "secondary-[", + crate::navigate::GoBack, + Some(commands::CONTEXT), + ), + ]); + ely_office::bind_zoom_keys(cx); + let menus = vec![ + gpui::Menu::new(ely_ui::i18n::text(cx, "menu.view", &[])).items(ely_office::zoom_items(false, cx)), + crate::insert::menu(cx), + crate::review::menu(cx), + crate::merge::menu(cx), + ]; + let root = ely_ui::document_root(app, docs.clone().into(), &ely_ui::commands::FILE, menus, window, cx); + docs.update(cx, |docs, cx| { + docs.core.root = root.downgrade(); + docs.core.window = Some(window.window_handle()); + window.focus(&docs.core.focus, cx); + }); + let launched = ely_ui::launch_files(cx); + docs.update(cx, |docs, cx| match launched.is_empty() { + true => docs.new_from(Template::Blank, cx), + false => launched.into_iter().for_each(|path| docs.open_file(path, cx)), + }); + let opening = docs.clone(); + ely_ui::on_open(cx, move |path, cx| { + opening.update(cx, |docs, cx| docs.open_file(path, cx)) + }); + let closing = docs.clone(); + window.on_window_should_close(cx, move |window, cx| { + closing.update(cx, |docs, cx| docs.should_close(window, cx)) + }); + root +} + +impl Docs { + pub fn new(worker: Arc>, work: PathBuf, cx: &mut Context) -> Self { + let props = cx.new(|_| Props::new(Arc::clone(&worker))); + let review = cx.new(|_| Review::new(Arc::clone(&worker))); + let weak = cx.weak_entity(); + let merge = cx.new(|_| crate::merge::Merge::new(weak.clone())); + let navigator = cx.new(|_| crate::navigate::Navigator::new(weak, Arc::clone(&worker))); + let failed = |docs: &mut Docs, error: &LokError, cx: &mut Context| { + docs.engine_failed(&error.0, cx) + }; + cx.subscribe(&props, move |docs, _, error, cx| failed(docs, error, cx)) + .detach(); + cx.subscribe(&review, move |docs, _, error, cx| failed(docs, error, cx)) + .detach(); + cx.subscribe(&navigator, move |docs, _, error, cx| { + failed(docs, error, cx) + }) + .detach(); + Docs { + core: Core::new(worker, work, cx), + dialog: None, + props, + review, + side: Side::Props, + offer: None, + merge, + navigator, + navigating: true, + back: Vec::new(), + going: None, + } + } + + pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString { + ely_ui::i18n::text(cx, key, args) + } + + /// A new untitled document from a bundled template (DOC-01). + pub fn new_from(&mut self, template: Template, cx: &mut Context) { + let name = format!("{}.fodt", template.key()); + self.new_from_text(&name, &template.fodt(cx), cx); + } +} + +impl Render for Docs { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let title = self + .active_tab() + .map_or_else(|| AppId::Doc.name().into(), |tab| tab.name.clone()); + let side = self.side_view(cx); + let shell = Shell::new(title, &self.core.focus) + .tabs(self.tab_strip(cx)) + .left(self.left_view(cx)) + .right(side) + .status(self.status(cx)) + .child(self.body(cx)); + let root = div() + .size_full() + .key_context(commands::CONTEXT) + .track_focus(&self.core.focus) + .on_action(cx.listener(Self::on_new)) + .on_action(cx.listener(Self::on_open)) + .on_action(cx.listener(Self::on_save)) + .on_action(cx.listener(Self::on_save_as)) + .on_action(cx.listener(Self::on_quit)) + .on_action(cx.listener(Self::on_close_tab)) + .child(shell) + .children(self.core_dialog(cx)) + .children(self.dialog_element(window, cx)); + Self::bind_zoom(crate::insert::bind(root, cx), cx) + } +} + +impl Zooming for Docs {} diff --git a/apps/ely-doc/src/files.rs b/apps/ely-doc/src/files.rs new file mode 100644 index 0000000..acea15c --- /dev/null +++ b/apps/ely-doc/src/files.rs @@ -0,0 +1,99 @@ +//! The bundled templates (DOC-01). + +use gpui::App; + +/// The three bundled templates (DOC-01). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Template { + Blank, + Report, + Letter, +} + +pub const TEMPLATES: [Template; 3] = [Template::Blank, Template::Report, Template::Letter]; + +impl Template { + pub fn key(self) -> &'static str { + match self { + Template::Blank => "blank", + Template::Report => "report", + Template::Letter => "letter", + } + } + + /// The paragraphs, as (heading level, text); 0 is body. + fn lines(self, cx: &App) -> Vec<(u8, String)> { + let text = |key: &str| ely_ui::i18n::text(cx, key, &[]).to_string(); + match self { + Template::Blank => vec![(0, String::new())], + Template::Report => vec![ + (9, text("doc.report.title")), + (1, text("doc.report.summary")), + (0, text("doc.body")), + (1, text("doc.report.details")), + (0, text("doc.body")), + ], + Template::Letter => vec![ + (0, text("doc.letter.sender")), + (0, text("doc.letter.date")), + (0, String::new()), + (0, text("doc.letter.recipient")), + (0, String::new()), + (0, text("doc.letter.greeting")), + (0, text("doc.body")), + (0, String::new()), + (0, text("doc.letter.closing")), + (0, text("doc.letter.sender")), + ], + } + } + + /// Flat ODF text; level 9 is the Title style. + pub fn fodt(self, cx: &App) -> String { + let body: String = self + .lines(cx) + .into_iter() + .map(|(level, text)| { + let text = escape(&text); + match level { + 0 => format!("{text}"), + 9 => format!("{text}"), + level => format!("{text}"), + } + }) + .collect(); + format!( + "\n{body}\n" + ) + } + + /// The template as the picker's markdown preview. + pub fn preview(self, cx: &App) -> String { + self.lines(cx) + .into_iter() + .map(|(level, text)| match level { + 0 => text, + 9 => format!("# {text}"), + level => format!("{} {text}", "#".repeat(usize::from(level) + 1)), + }) + .collect::>() + .join("\n\n") + } +} + +/// XML-escapes template text. +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn template_text_is_escaped() { + assert_eq!(escape("a"), "a<b & c>"); + } +} diff --git a/apps/ely-doc/src/insert.rs b/apps/ely-doc/src/insert.rs new file mode 100644 index 0000000..d947f34 --- /dev/null +++ b/apps/ely-doc/src/insert.rs @@ -0,0 +1,240 @@ +//! The Insert menu and its dialog (DOC-05 to 08). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::NumberInput, + overlays::{ConfirmDialog, Dialog as Card}, + primitives::Severity, +}; +use gpui::{ + App, Context, Div, Entity, InteractiveElement as _, Menu, MenuItem, ParentElement as _, + Styled as _, Window, actions, div, +}; + +use crate::actions::Dialog; +use crate::docs::Docs; +use ely_office::prelude::*; + +actions!( + doc, + [ + InsertTable, + InsertImage, + InsertPageBreak, + InsertSectionBreak, + InsertPageNumber, + InsertPageCount + ] +); + +/// ElyDoc's Insert menu, after File. +pub fn menu(cx: &App) -> Menu { + let label = |key: &str| Docs::text(cx, key, &[]); + let mut items = vec![ + MenuItem::action(label("doc.insert.table"), InsertTable), + MenuItem::action(label("doc.insert.image"), InsertImage), + MenuItem::separator(), + MenuItem::action(label("doc.insert.page_break"), InsertPageBreak), + MenuItem::action(label("doc.insert.section_break"), InsertSectionBreak), + MenuItem::separator(), + MenuItem::action(label("doc.insert.page_number"), InsertPageNumber), + MenuItem::action(label("doc.insert.page_count"), InsertPageCount), + ]; + items.extend(crate::refs::items(cx)); + Menu::new(label("menu.insert")).items(items) +} + +/// Binds the Insert and Review actions on the window root. +pub fn bind(root: Div, cx: &mut Context) -> Div { + root.on_action(cx.listener(Docs::on_insert_table)) + .on_action(cx.listener(Docs::on_insert_image)) + .on_action(cx.listener(Docs::on_page_break)) + .on_action(cx.listener(Docs::on_section_break)) + .on_action(cx.listener(Docs::on_page_number)) + .on_action(cx.listener(Docs::on_page_count)) + .on_action(cx.listener(Docs::on_contents)) + .on_action(cx.listener(Docs::on_update)) + .on_action(cx.listener(Docs::on_footnote)) + .on_action(cx.listener(Docs::on_endnote)) + .on_action(cx.listener(Docs::on_bookmark)) + .on_action(cx.listener(Docs::on_link)) + .on_action(cx.listener(Docs::on_reference)) + .on_action(cx.listener(Docs::on_caption)) + .on_action(cx.listener(Docs::on_formula)) + .on_action(cx.listener(Docs::on_comment)) + .on_action(cx.listener(Docs::on_track)) + .on_action(cx.listener(Docs::on_accept_all)) + .on_action(cx.listener(Docs::on_reject_all)) + .on_action(cx.listener(Docs::on_show_review)) + .on_action(cx.listener(Docs::on_find)) + .on_action(cx.listener(Docs::on_compare)) + .on_action(cx.listener(Docs::on_merge)) + .on_action(cx.listener(Docs::on_export)) + .on_action(cx.listener(Docs::on_navigator)) + .on_action(cx.listener(Docs::on_back)) + .on_action(cx.listener(Docs::on_print)) +} + +impl Docs { + /// Runs a command on the active document. + pub(crate) fn run_active(&mut self, id: &str, values: &[&str], cx: &mut Context) { + let Some(tab) = self.active_tab() else { + let text = Docs::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + log::info!("doc: runs {id}"); + let sent = tab.view.update(cx, |view, _| view.run(id, values)); + if let Err(error) = sent { + let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); + self.toast(text, Severity::Danger, cx); + } + } + + pub fn on_insert_table(&mut self, _: &InsertTable, _: &mut Window, cx: &mut Context) { + self.dialog = Some(Dialog::Table { + columns: 2.0, + rows: 2.0, + }); + cx.notify(); + } + + pub fn on_insert_image(&mut self, _: &InsertImage, _: &mut Window, cx: &mut Context) { + self.pick(false, cx, |docs, path, cx| { + let url = ely_lok::file_url(&path); + docs.run_active("image.insert", &[&url], cx) + }); + } + + pub fn on_page_break(&mut self, _: &InsertPageBreak, _: &mut Window, cx: &mut Context) { + self.run_active("page.break", &[], cx); + } + + /// Asks first: a new section clears the undo history. + pub fn on_section_break( + &mut self, + _: &InsertSectionBreak, + _: &mut Window, + cx: &mut Context, + ) { + self.dialog = Some(Dialog::Section); + cx.notify(); + } + + /// Copies this page's style, then breaks into it. + pub fn insert_section(&mut self, cx: &mut Context) { + self.dialog = None; + let Some(tab) = self.active_tab() else { + let text = Docs::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + let listed = self + .core + .worker + .lock() + .expect("the worker lock") + .values(&tab.doc, ".uno:StyleApply") + .and_then(|json| ely_lok::commands::styles(&json, "8")); + let names = match listed { + Ok(names) => names, + Err(error) => { + let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); + return self.toast(text, Severity::Danger, cx); + } + }; + let name = (2..) + .map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string()) + .find(|name| !names.contains(name)) + .expect("a free section name"); + log::info!("doc: a new section"); + self.run_active("style.new", &[&name, "8"], cx); + self.run_active("page.section", &["3", &name], cx); + self.props.update(cx, |props, _| props.refresh_lists()); + cx.notify(); + } + + pub fn on_page_number(&mut self, _: &InsertPageNumber, _: &mut Window, cx: &mut Context) { + self.run_active("field.page", &[], cx); + } + + pub fn on_page_count(&mut self, _: &InsertPageCount, _: &mut Window, cx: &mut Context) { + self.run_active("field.pages", &[], cx); + } + + /// Inserts the dialog's table and closes it. + pub fn insert_table(&mut self, columns: f64, rows: f64, cx: &mut Context) { + self.dialog = None; + let (columns, rows) = (columns.round().to_string(), rows.round().to_string()); + self.run_active("table.insert", &[&columns, &rows], cx); + cx.notify(); + } +} + +/// The section break's warning: undo clears. +pub fn section_dialog( + this: Entity, + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> ConfirmDialog { + ConfirmDialog::new( + "doc-insert-section", + Docs::text(cx, "doc.section.title", &[]), + Docs::text(cx, "doc.section.detail", &[]), + dismiss, + ) + .confirm(Docs::text(cx, "doc.insert.insert", &[])) + .on_confirm(move |_, cx| this.update(cx, |docs, cx| docs.insert_section(cx))) +} + +/// The Insert Table dialog: columns and rows. +pub fn table_dialog( + this: Entity, + (columns, rows): (f64, f64), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let field = |id: &'static str, value: f64, max: f64, tall: bool| { + let this = this.clone(); + let input = NumberInput::new(id, value) + .range(1.0, max) + .on_change(move |value, _, cx| { + this.update(cx, |docs, cx| { + if let Some(Dialog::Table { columns, rows }) = &mut docs.dialog { + *if tall { rows } else { columns } = value; + } + cx.notify(); + }) + }); + div().w_full().max_w_32().child(input) + }; + let fields = DescriptionList::new() + .item( + Docs::text(cx, "doc.insert.columns", &[]), + field("doc-table-columns", columns, 99.0, false), + ) + .item( + Docs::text(cx, "doc.insert.rows", &[]), + field("doc-table-rows", rows, 999.0, true), + ); + let (label, cancel) = ( + Docs::text(cx, "doc.insert.insert", &[]), + Docs::text(cx, "office.cancel", &[]), + ); + Card::new( + "doc-insert-table", + Docs::text(cx, "doc.insert.table_title", &[]), + dismiss, + ) + .child(fields) + .action(move |close| { + Button::new("doc-insert-table-cancel", cancel) + .on_click(move |_, window, cx| close(window, cx)) + }) + .action(move |_| { + Button::new("doc-insert-table-go", label) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| { + this.update(cx, |docs, cx| docs.insert_table(columns, rows, cx)) + }) + }) +} diff --git a/apps/ely-doc/src/main.rs b/apps/ely-doc/src/main.rs new file mode 100644 index 0000000..87d5b6c --- /dev/null +++ b/apps/ely-doc/src/main.rs @@ -0,0 +1,22 @@ +mod actions; +mod chrome; +mod compare; +mod docs; +mod files; +mod insert; +mod merge; +mod navigate; +mod output; +mod props; +mod refs; +mod review; +mod spell; + +use ely_platform::AppId; + +fn main() { + ely_ui::run(AppId::Doc, docs::open); +} + +#[cfg(test)] +mod tests; diff --git a/apps/ely-doc/src/merge.rs b/apps/ely-doc/src/merge.rs new file mode 100644 index 0000000..7f79407 --- /dev/null +++ b/apps/ely-doc/src/merge.rs @@ -0,0 +1,216 @@ +//! Mail merge from a CSV (DOC-14); never sends mail. + +mod run; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + data_display::{PropertyGrid, PropertyGroup}, + forms::NumberInput, + layout::ScrollArea, + primitives::Severity, + theme::ControlSize, +}; +use gpui::{ + Context, IntoElement, ParentElement as _, Render, Styled as _, WeakEntity, Window, actions, div, +}; + +use crate::docs::{Docs, Side}; +use ely_office::prelude::*; +pub use run::{Table, marker, read_csv}; + +actions!(doc, [MailMerge]); + +/// ElyDoc's Tools menu. +pub fn menu(cx: &gpui::App) -> gpui::Menu { + let label = |key: &str| Docs::text(cx, key, &[]); + gpui::Menu::new(label("menu.tools")).items([ + gpui::MenuItem::action(label("office.find.open"), ely_office::FindReplace), + gpui::MenuItem::action(label("doc.merge.open"), MailMerge), + gpui::MenuItem::separator(), + gpui::MenuItem::action(label("doc.nav.open"), crate::navigate::ToggleNavigator), + gpui::MenuItem::action(label("doc.nav.back"), crate::navigate::GoBack), + ]) +} + +/// The merge panel: the data, the record shown, the format. +pub struct Merge { + docs: WeakEntity, + pub table: Option, + /// The record to preview, from 1. + record: usize, + pdf: bool, +} + +impl Merge { + pub fn new(docs: WeakEntity) -> Self { + Merge { + docs, + table: None, + record: 1, + pdf: false, + } + } +} + +impl Docs { + pub fn on_merge(&mut self, _: &MailMerge, _: &mut Window, cx: &mut Context) { + self.side = Side::Merge; + cx.notify(); + if self.merge.read(cx).table.is_none() { + self.choose_csv(cx); + } + } + + pub(crate) fn choose_csv(&mut self, cx: &mut Context) { + self.pick(false, cx, |docs, path, cx| match read_csv(&path) { + Ok(table) => { + log::info!("doc: merge data has {} rows", table.rows.len()); + docs.merge.update(cx, |merge, cx| { + merge.table = Some(table); + merge.record = 1; + cx.notify(); + }); + } + Err(error) => { + let name = path.display().to_string(); + let error = error.to_string(); + let text = Docs::text( + cx, + "doc.merge.unreadable", + &[("name", &name), ("error", &error)], + ); + docs.toast(text, Severity::Danger, cx); + } + }); + } + + /// The tab a merge starts from: the active one. + fn source(&self, cx: &mut Context) -> Option { + if self.core.active.is_none() { + let text = Docs::text(cx, "command.blocked.no_document", &[]); + self.toast(text, Severity::Warning, cx); + } + self.core.active + } + + /// Types a field's marker at the cursor. + pub(crate) fn insert_field(&mut self, header: &str, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + let text = Docs::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + tab.view.update(cx, |view, _| view.insert(&marker(header))); + } +} + +impl Render for Merge { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (docs, this) = (self.docs.clone(), cx.entity()); + let text = |key: &str| Docs::text(cx, key, &[]); + let choose = { + let docs = docs.clone(); + Button::new("doc-merge-choose", text("doc.merge.choose")) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + docs.update(cx, |docs, cx| docs.choose_csv(cx)) + .expect("the documents live"); + }) + }; + let Some(table) = self.table.clone() else { + let group = + PropertyGroup::new(text("doc.merge.title")).row(text("doc.merge.data"), choose); + return PropertyGrid::new("doc-merge") + .group(group) + .into_any_element(); + }; + let fields = table + .headers + .iter() + .enumerate() + .map(|(at, header)| { + let (docs, header) = (docs.clone(), header.clone()); + Button::new(("doc-merge-field", at), header.clone()) + .size(ControlSize::Sm) + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| { + docs.update(cx, |docs, cx| docs.insert_field(&header, cx)) + .expect("the documents live"); + }) + }) + .collect::>(); + let record = { + let this = this.clone(); + NumberInput::new("doc-merge-record", self.record as f64) + .range(1.0, table.rows.len() as f64) + .on_commit(move |value, _, cx| { + this.update(cx, |merge, cx| { + merge.record = value.round() as usize; + cx.notify(); + }) + }) + }; + let preview = { + let (docs, table, at) = (docs.clone(), table.clone(), self.record - 1); + Button::new("doc-merge-preview", text("doc.merge.preview")) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + docs.update(cx, |docs, cx| { + if let Some(source) = docs.source(cx) { + docs.preview_record(source, &table, at, cx) + } + }) + .expect("the documents live"); + }) + }; + let format = { + let this = this.clone(); + SegmentedControl::new("doc-merge-format", if self.pdf { "pdf" } else { "docx" }) + .size(ControlSize::Sm) + .segment("docx", "DOCX", None) + .segment("pdf", "PDF", None) + .on_change(move |value, _, cx| { + this.update(cx, |merge, cx| { + merge.pdf = value.as_ref() == "pdf"; + cx.notify(); + }) + }) + }; + let generate = { + let (pdf, table) = (self.pdf, table.clone()); + Button::new("doc-merge-generate", text("doc.merge.generate")) + .size(ControlSize::Sm) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| { + let table = table.clone(); + docs.update(cx, |docs, cx| { + let Some(source) = docs.source(cx) else { + return; + }; + docs.pick(true, cx, move |docs, folder, cx| { + docs.generate(source, table, pdf, folder, cx) + }) + }) + .expect("the documents live"); + }) + }; + let row = || div().flex().flex_wrap().items_center().gap_1(); + let group = PropertyGroup::new(table.name.clone()) + .row(text("doc.merge.data"), choose) + .row(text("doc.merge.fields"), row().children(fields)) + .row( + text("doc.merge.record"), + row().child(div().w_full().max_w_24().child(record)).child(preview), + ) + .row( + text("doc.merge.output"), + row().child(format).child(generate), + ); + ScrollArea::new("doc-merge-scroll").vertical() + .size_full() + .child(PropertyGrid::new("doc-merge").group(group)) + .into_any_element() + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/ely-doc/src/merge/run.rs b/apps/ely-doc/src/merge/run.rs new file mode 100644 index 0000000..bb0f720 --- /dev/null +++ b/apps/ely-doc/src/merge/run.rs @@ -0,0 +1,308 @@ +//! Mail merge work: the CSV, filled copies, previews and files. + +use std::{ + collections::hash_map::RandomState, + hash::BuildHasher, + io, + path::{Path, PathBuf}, + sync::Arc, +}; + +use ely_gpui_component::primitives::Severity; +use ely_lok::{Doc, Worker}; +use gpui::{AppContext as _, Context, SharedString, TaskExt as _}; + +use ely_office::files::{self, file_name}; +use ely_office::prelude::*; +use ely_office::{Source, in_window}; + +use crate::docs::Docs; + +/// A CSV: its name, column names and rows. +#[derive(Clone, Debug)] +pub struct Table { + pub name: SharedString, + pub headers: Vec, + pub rows: Vec>, +} + +/// Reads a UTF-8 CSV whose first row names the columns. +pub fn read_csv(path: &Path) -> io::Result
{ + let mut reader = csv::Reader::from_path(path).map_err(io::Error::other)?; + let headers: Vec = reader + .headers() + .map_err(io::Error::other)? + .iter() + .map(|header| header.trim().to_string()) + .collect(); + if let Some(at) = headers.iter().position(String::is_empty) { + return Err(io::Error::other(format!("column {} has no name", at + 1))); + } + // A name with a guillemet would blur the marker bounds. + if let Some(header) = headers.iter().find(|header| header.contains(['«', '»'])) { + return Err(io::Error::other(format!("column {header} holds « or »"))); + } + for (at, header) in headers.iter().enumerate() { + if headers[..at].contains(header) { + return Err(io::Error::other(format!("two columns are named {header}"))); + } + } + let rows = reader + .records() + .map(|record| record.map(|record| record.iter().map(str::to_string).collect())) + .collect::>, _>>() + .map_err(io::Error::other)?; + if rows.is_empty() { + return Err(io::Error::other("the file has no rows")); + } + Ok(Table { + name: file_name(path), + headers, + rows, + }) +} + +/// The marker a field leaves in the text. +pub fn marker(header: &str) -> String { + format!("«{header}»") +} + +/// Replaces every field marker in `doc` with `row`'s values. +fn fill(worker: &mut Worker, doc: &Doc, headers: &[String], row: &[String]) -> io::Result<()> { + // Markers become tokens first, so values are never rescanned. + let nonce = BuildHasher::hash_one(&RandomState::new(), 0); + let token = |at: usize| format!("\u{e000}{nonce:x}-{at}\u{e001}"); + let mut replace = |from: &str, to: &str| { + worker.run( + doc, + "find.replace", + &[from, "false", "3", "0", "0", "1", to], + ) + }; + for (at, header) in headers.iter().enumerate() { + replace(&marker(header), &token(at))?; + } + for (at, value) in row.iter().enumerate() { + replace(&token(at), value)?; + } + Ok(()) +} + +/// Copies `from` to `to`, never over an existing file. +fn publish(from: &Path, to: &Path) -> io::Result<()> { + let mut target = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(to)?; + io::copy(&mut std::fs::File::open(from)?, &mut target)?; + target.sync_all() +} + +impl Docs { + /// Saves tab `source` as the template copy, in the background. + fn template( + &mut self, + source: u64, + into: u64, + cx: &mut Context, + then: impl FnOnce(&mut Self, SharedString, PathBuf, &mut Context) + 'static, + ) { + let Some(name) = self.tab(source).map(|tab| tab.name.clone()) else { + return self.merge_failed(&io::Error::other("the source document closed"), cx); + }; + let copy = match files::work_dir(&self.core.work, into) { + Ok(dir) => dir.join("merge.odt"), + Err(error) => return self.merge_failed(&error, cx), + }; + let url = ely_lok::file_url(©); + self.read_in( + source, + move |worker, doc| doc.save_as(worker, &url, "odt"), + move |docs, saved, _, cx| match saved { + Ok(()) => then(docs, name, copy, cx), + Err(error) => docs.merge_failed(&error, cx), + }, + cx, + ); + } + + fn merge_failed(&self, error: &io::Error, cx: &mut Context) { + let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); + self.toast(text, Severity::Danger, cx); + } + + /// Opens record `at` filled in, as a new tab. + pub(crate) fn preview_record( + &mut self, + source: u64, + table: &Table, + at: usize, + cx: &mut Context, + ) { + if !self.settled(source, cx) { + let table = table.clone(); + return self.when_settled(source, move |docs, _, cx| { + docs.preview_record(source, &table, at, cx) + }); + } + let (headers, row) = (table.headers.clone(), table.rows[at].clone()); + // The preview's tab and folder, reserved now. + let into = self.core.next; + self.core.next += 1; + self.template(source, into, cx, move |docs, name, copy, cx| { + docs.preview_copy((into, at), name, copy, (headers, row), cx) + }); + } + + /// Opens the template copy and fills it with one row. + fn preview_copy( + &mut self, + (into, at): (u64, usize), + name: SharedString, + copy: PathBuf, + (headers, row): (Vec, Vec), + cx: &mut Context, + ) { + log::info!("doc: previewing merge record {}", at + 1); + self.load_into(into, Source::Copy(copy), cx); + self.core.waiting.push(( + into, + Box::new(move |docs, _, cx| { + let Some(tab) = docs.tab_mut(into) else { + return; + }; + let n = (at + 1).to_string(); + tab.name = Docs::text(cx, "doc.merge.preview_name", &[("name", &name), ("n", &n)]); + cx.notify(); + docs.read_in( + into, + move |worker, doc| fill(worker, doc, &headers, &row), + |docs, filled, _, cx| { + if let Err(error) = filled { + docs.merge_failed(&error, cx); + } + }, + cx, + ); + }), + )); + } + + /// Writes one file per row into `folder`; refuses to overwrite. + pub(crate) fn generate( + &mut self, + source: u64, + table: Table, + pdf: bool, + folder: PathBuf, + cx: &mut Context, + ) { + if !self.settled(source, cx) { + return self.when_settled(source, move |docs, _, cx| { + docs.generate(source, table, pdf, folder, cx) + }); + } + let Some(name) = self.tab(source).map(|tab| tab.name.clone()) else { + return self.merge_failed(&io::Error::other("the source document closed"), cx); + }; + let stem = Path::new(name.as_ref()).file_stem().map_or_else( + || name.to_string(), + |stem| stem.to_string_lossy().into_owned(), + ); + let extension = if pdf { "pdf" } else { "docx" }; + let targets: Vec = (1..=table.rows.len()) + .map(|n| folder.join(format!("{stem} {n}.{extension}"))) + .collect(); + let taken = targets + .iter() + .find(|target| target.exists()) + .map(|target| file_name(target)); + if let Some(taken) = taken { + let text = Docs::text(cx, "doc.merge.exists", &[("name", &taken)]); + return self.toast(text, Severity::Danger, cx); + } + // Each run keeps its own template and staging folder. + let into = self.core.next; + self.core.next += 1; + self.template(source, into, cx, move |docs, _, template, cx| { + docs.write_records(template, (table, targets, extension, folder), cx) + }); + } + + /// Writes each row's file from the template copy. + fn write_records( + &mut self, + template: PathBuf, + (table, targets, extension, folder): (Table, Vec, &'static str, PathBuf), + cx: &mut Context, + ) { + log::info!("doc: merging {} records", targets.len()); + let worker = Arc::clone(&self.core.worker); + cx.spawn(async move |this, cx| { + let written = cx + .background_spawn(async move { + for (n, (row, target)) in table.rows.iter().zip(&targets).enumerate() { + // One record at a time: open documents keep their turns. + let mut worker = worker.lock().expect("the worker lock"); + let staged = template.with_file_name(format!("record.{extension}")); + let made = worker.load(&ely_lok::file_url(&template)).and_then(|doc| { + fill(&mut worker, &doc, &table.headers, row)?; + doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension)?; + worker.close_doc(doc)?; + publish(&staged, target) + }); + made.map_err(|error| (n + 1, error))?; + } + Ok(targets.len()) + }) + .await; + in_window(&this, cx, |docs, _, cx| { + let folder = folder.display().to_string(); + let text = match written { + Ok(count) => { + let count = count.to_string(); + let text = + Docs::text(cx, "doc.merge.done", &[("n", &count), ("folder", &folder)]); + return docs.toast(text, Severity::Success, cx); + } + Err((n, error)) => Docs::text( + cx, + "doc.merge.failed", + &[("n", &n.to_string()), ("error", &error.to_string())], + ), + }; + docs.toast(text, Severity::Danger, cx); + }) + }) + .detach_and_log_err(cx); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_csv_reads_or_says_why_not() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join("people.csv"); + std::fs::write(&path, "\u{feff}Name,City\nAda,\"London, UK\"\nLin,上海\n").expect("writes"); + let table = read_csv(&path).expect("reads"); + assert_eq!(table.headers, ["Name", "City"]); + assert_eq!(table.rows[0], ["Ada", "London, UK"]); + assert_eq!(table.rows[1][1], "上海"); + for (body, why) in [ + ("Name,\nAda,x\n", "no name"), + ("Name,Name\na,b\n", "two columns"), + ("Name,«Name»\na,b\n", "holds « or »"), + ("Name,City\n", "no rows"), + ("Name,City\nAda\n", "found record"), + ] { + std::fs::write(&path, body).expect("writes"); + let error = read_csv(&path).expect_err(body).to_string(); + assert!(error.contains(why), "{body}: {error}"); + } + std::fs::write(&path, b"Name\n\xff\xfe\n").expect("writes"); + assert!(read_csv(&path).is_err(), "bytes that are not UTF-8 fail"); + } +} diff --git a/apps/ely-doc/src/merge/tests.rs b/apps/ely-doc/src/merge/tests.rs new file mode 100644 index 0000000..9755017 --- /dev/null +++ b/apps/ely-doc/src/merge/tests.rs @@ -0,0 +1,267 @@ +use gpui::{Entity, TestAppContext}; + +use super::*; +use crate::tests::{ + blank, commit, docs, settle, settled, text_of, type_into, with_window, written, +}; + +fn tabs(app: &gpui::App, docs: &Entity) -> usize { + docs.read(app).core.tabs.len() +} + +#[test] +fn records_fill_previews_and_files() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let source_id = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "Dear "); + let csv = dir.path().join("people.csv"); + std::fs::write(&csv, "Name,City\nAda,London\n«City»,上海\n").expect("the csv writes"); + let table = read_csv(&csv).expect("the csv reads"); + cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx))); + type_into(&mut cx, &docs, " of "); + cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("City", cx))); + let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string()); + cx.update(|app| { + docs.update(app, |docs, cx| { + docs.preview_record(source_id, &table, 1, cx) + }) + }); + let name = cx.update(|app| { + Docs::text( + app, + "doc.merge.preview_name", + &[("name", &source), ("n", "2")], + ) + }); + settle(&cx, |app| { + tabs(app, &docs) == 2 + && docs.read(app).core.tabs[1].name == name + && docs.read(app).core.tabs[1].view.read(app).settled() + }); + let preview = dir.path().join("preview.odt"); + let id = cx.update(|app| docs.read(app).core.tabs[1].id); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, preview.clone(), None, window, cx) + }); + written(&cx, &preview, None); + assert!( + text_of(&mut cx, &docs, &preview).contains("Dear «City» of 上海"), + "the preview takes queued input and keeps values literal" + ); + cx.update(|app| docs.update(app, |docs, _| docs.core.active = Some(docs.core.tabs[0].id))); + let out = dir.path().join("out"); + std::fs::create_dir(&out).expect("the folder"); + let target = |n: usize, extension: &str| out.join(format!("{source} {n}.{extension}")); + std::fs::write(target(2, "docx"), "mine").expect("a taken name"); + cx.update(|app| { + docs.update(app, |docs, cx| { + docs.generate(source_id, table.clone(), false, out.clone(), cx) + }) + }); + assert_eq!( + std::fs::read_to_string(target(2, "docx")).expect("reads"), + "mine", + "a taken name stops the merge" + ); + assert!(!target(1, "docx").exists(), "nothing is written"); + std::fs::remove_file(target(2, "docx")).expect("frees the name"); + let staged = cx.update(|app| { + let docs = docs.read(app); + docs.core + .work + .join(format!("doc-{}", docs.core.next)) + .join("record.pdf") + }); + cx.update(|app| { + docs.update(app, |docs, cx| { + docs.generate(source_id, table.clone(), true, out.clone(), cx) + }) + }); + std::fs::write(target(1, "pdf"), "EXTERNAL").expect("a file arrives late"); + let worker = cx.update(|app| docs.read(app).core.worker.clone()); + settle(&cx, |_| staged.exists() && worker.try_lock().is_ok()); + assert_eq!( + std::fs::read_to_string(target(1, "pdf")).expect("reads"), + "EXTERNAL", + "a file that arrives late stays" + ); + assert!( + !target(2, "pdf").exists(), + "the merge stops at the taken name" + ); + std::fs::remove_file(target(1, "pdf")).expect("frees the name"); + cx.update(|app| { + docs.update(app, |docs, cx| { + docs.generate(source_id, table, true, out.clone(), cx) + }) + }); + settle(&cx, |_| target(2, "pdf").exists()); + let first = std::fs::read(target(1, "pdf")).expect("the pdf reads"); + assert!(first.starts_with(b"%PDF"), "a PDF is written"); +} + +#[test] +fn compare_opens_a_marked_copy_and_keeps_both_files() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "A new line"); + settled(&cx, &docs); + let older = dir.path().join("older.odt"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, older.clone(), None, window, cx) + }); + written(&cx, &older, None); + commit(&mut cx, &docs, " grows"); + let before = std::fs::read(&older).expect("the older file reads"); + let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string()); + cx.update(|app| docs.update(app, |docs, cx| docs.compare(id, older.clone(), cx))); + settle(&cx, |app| { + tabs(app, &docs) == 2 && docs.read(app).side == crate::docs::Side::Review + }); + let review = cx.update(|app| docs.read(app).review.clone()); + let tab = cx.update(|app| docs.read(app).core.tabs[1].view.clone()); + cx.update(|app| { + let doc = docs.read(app).core.tabs[1].doc.clone(); + review.update(app, |review, cx| review.show(Some((&tab, &doc)), cx)) + }); + settle(&cx, |app| !review.read(app).changes.is_empty()); + let name = cx.update(|app| docs.read(app).core.tabs[1].name.to_string()); + assert!(name.contains(&source), "the copy names its source: {name}"); + let copy = dir.path().join("copy.odt"); + let into = cx.update(|app| docs.read(app).core.tabs[1].id); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(into, copy.clone(), None, window, cx) + }); + written(&cx, ©, None); + assert!( + text_of(&mut cx, &docs, ©).contains(" grows"), + "the copy takes the queued input" + ); + assert_eq!( + std::fs::read(&older).expect("reads"), + before, + "the older file stays" + ); +} + +#[test] +fn a_merge_keeps_its_source_when_the_tab_changes() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let other = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "SourceB "); + let source = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "SourceA "); + let csv = dir.path().join("one.csv"); + std::fs::write(&csv, "Name\nAda\n").expect("the csv writes"); + let table = read_csv(&csv).expect("the csv reads"); + cx.update(|app| { + docs.update(app, |docs, cx| { + docs.insert_field("Name", cx); + docs.preview_record(source, &table, 0, cx); + docs.core.active = Some(other); + }) + }); + let name = cx.update(|app| { + let source = docs.read(app).tab(source).expect("a tab").name.clone(); + Docs::text( + app, + "doc.merge.preview_name", + &[("name", &source), ("n", "1")], + ) + }); + settle(&cx, |app| { + let docs = docs.read(app); + // A filled preview is a modified copy. + docs.core.tabs.len() == 3 && docs.core.tabs[2].name == name && docs.core.tabs[2].modified + }); + let preview = dir.path().join("preview.odt"); + let id = cx.update(|app| docs.read(app).core.tabs[2].id); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, preview.clone(), None, window, cx) + }); + written(&cx, &preview, None); + let text = text_of(&mut cx, &docs, &preview); + assert!(text.contains("SourceA Ada"), "the preview keeps its source"); + let stem = cx.update(|app| docs.read(app).tab(source).expect("a tab").name.to_string()); + let out = dir.path().join("out"); + std::fs::create_dir(&out).expect("the folder"); + cx.update(|app| { + docs.update(app, |docs, cx| { + docs.core.active = Some(other); + docs.generate(source, table, false, out.clone(), cx) + }) + }); + let target = out.join(format!("{stem} 1.docx")); + let worker = cx.update(|app| docs.read(app).core.worker.clone()); + settle(&cx, |_| target.exists() && worker.try_lock().is_ok()); + assert!( + text_of(&mut cx, &docs, &target).contains("SourceA Ada"), + "the file keeps its source" + ); +} + +#[test] +fn two_generations_keep_their_own_templates() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let mut sources = Vec::new(); + for text in ["SourceA ", "SourceB "] { + sources.push(blank(&mut cx, &docs)); + type_into(&mut cx, &docs, text); + cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx))); + settled(&cx, &docs); + } + let csv = dir.path().join("one.csv"); + std::fs::write(&csv, "Name\nAda\n").expect("the csv writes"); + let table = read_csv(&csv).expect("the csv reads"); + let outs: Vec<_> = (0..2).map(|n| dir.path().join(format!("out{n}"))).collect(); + for (source, out) in sources.iter().zip(&outs) { + std::fs::create_dir(out).expect("the folder"); + cx.update(|app| { + docs.update(app, |docs, cx| { + docs.generate(*source, table.clone(), false, out.clone(), cx) + }) + }); + } + let names: Vec = cx.update(|app| { + let docs = docs.read(app); + sources + .iter() + .map(|id| docs.tab(*id).expect("a tab").name.to_string()) + .collect() + }); + let targets: Vec<_> = outs + .iter() + .zip(&names) + .map(|(out, name)| out.join(format!("{name} 1.docx"))) + .collect(); + let worker = cx.update(|app| docs.read(app).core.worker.clone()); + settle(&cx, |_| { + targets.iter().all(|target| target.exists()) && worker.try_lock().is_ok() + }); + for (target, want) in targets.iter().zip(["SourceA Ada", "SourceB Ada"]) { + assert!( + text_of(&mut cx, &docs, target).contains(want), + "{want} lands" + ); + } +} diff --git a/apps/ely-doc/src/navigate.rs b/apps/ely-doc/src/navigate.rs new file mode 100644 index 0000000..30e5234 --- /dev/null +++ b/apps/ely-doc/src/navigate.rs @@ -0,0 +1,332 @@ +//! Outline, pages and bookmarks beside the page (DOC-16). + +use std::sync::{Arc, Mutex}; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + layout::ScrollArea, + lists::{List, ListItem, Outline}, + primitives::{IconName, Severity}, + theme::ControlSize, +}; +use ely_lok::{ + Doc, LokError, LokView, Rect, Reported, Worker, + review::{Bookmark, Heading}, +}; +use gpui::{ + App, Context, Entity, EventEmitter, IntoElement, ParentElement as _, Render, SharedString, + Styled as _, Subscription, WeakEntity, Window, actions, div, +}; + +use crate::docs::Docs; +use ely_office::prelude::*; + +actions!(doc, [ToggleNavigator, GoBack]); + +#[derive(Clone, Copy, PartialEq)] +enum Pane { + Outline, + Pages, + Marks, +} + +struct Shown { + view: Entity, + doc: Doc, + /// Edits and cursor at the last read. + read: Option<(u64, Option)>, + /// A read is on its way. + reading: bool, + _observe: Subscription, +} + +pub struct Navigator { + docs: WeakEntity, + worker: Arc>, + shown: Option, + outline: Vec, + marks: Vec, + pane: Pane, +} + +impl EventEmitter for Navigator {} + +impl Navigator { + pub fn new(docs: WeakEntity, worker: Arc>) -> Self { + Navigator { + docs, + worker, + shown: None, + outline: Vec::new(), + marks: Vec::new(), + pane: Pane::Outline, + } + } + + /// Follows the active tab; reads the lists after edits. + pub fn show(&mut self, tab: Option<(&Entity, &Doc)>, cx: &mut Context) { + let id = tab.map(|(view, _)| view.entity_id()); + if self.shown.as_ref().map(|shown| shown.view.entity_id()) == id { + return; + } + self.shown = None; + self.outline.clear(); + self.marks.clear(); + cx.notify(); + let Some((view, doc)) = tab else { + return; + }; + let observe = cx.subscribe(view, |navigator, _, _: &Reported, cx| navigator.refresh(cx)); + self.shown = Some(Shown { + view: view.clone(), + doc: doc.clone(), + read: None, + reading: false, + _observe: observe, + }); + self.refresh(cx); + } + + /// Rereads the lists after edits or moves. + fn refresh(&mut self, cx: &mut Context) { + let Some(shown) = self.shown.as_mut() else { + return; + }; + let view = shown.view.read(cx); + let seen = Some((view.edited(), view.cursor())); + if shown.reading || shown.read == seen { + return; + } + (shown.read, shown.reading) = (seen, true); + let reader = shown.view.entity_id(); + ely_lok::read_off( + &self.worker, + &shown.doc, + |worker, doc| { + let json = worker.values(doc, ".uno:Outline")?; + let outline = ely_lok::review::outline(&json)?; + let json = worker.values(doc, ".uno:Bookmarks")?; + Ok((outline, ely_lok::review::bookmarks(&json)?)) + }, + move |navigator: &mut Self, read, cx| { + let Some(shown) = navigator.shown.as_mut() else { + return; + }; + if shown.view.entity_id() != reader { + return; + } + shown.reading = false; + navigator.take_lists(read, cx); + navigator.refresh(cx); + }, + cx, + ); + } + + fn take_lists( + &mut self, + read: std::io::Result<(Vec, Vec)>, + cx: &mut Context, + ) { + match read { + Ok((outline, marks)) => { + let (placed, hidden): (Vec<_>, Vec<_>) = + marks.into_iter().partition(|mark| mark.rect.is_some()); + log::info!( + "doc: {} headings, {} bookmarks, {} not laid out", + outline.len(), + placed.len(), + hidden.len() + ); + self.outline = outline; + self.marks = placed; + } + Err(error) => cx.emit(LokError(format!( + "the navigator lists are unreadable: {error}" + ))), + } + cx.notify(); + } + + fn jump(docs: &WeakEntity, to: Rect, cx: &mut App) { + docs.update(cx, |docs, cx| docs.jump(to, cx)) + .expect("the documents live"); + } + + /// The page that holds the cursor. + fn current_page(&self, pages: &[Rect], cx: &App) -> Option { + let cursor = self.shown.as_ref()?.view.read(cx).cursor()?; + pages.iter().rposition(|page| page.1 <= cursor.1) + } +} + +impl Docs { + /// Goes to `to`; Back returns to where the cursor was. + pub(crate) fn jump(&mut self, to: Rect, cx: &mut Context) { + self.mark_place(cx); + self.go(to, cx); + } + + /// Remembers the place of the active tab for Back. + pub(crate) fn mark_place(&mut self, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return; + }; + let view = tab.view.read(cx); + let Some(cursor) = view.cursor() else { + return; + }; + // A jump the cursor has not caught up with. + let place = match self.going { + Some((id, seq, moves, to)) + if id == tab.id && (!view.reached(seq) || view.moves() == moves) => + { + to + } + _ => cursor, + }; + self.back.push((tab.id, place)); + cx.notify(); + } + + /// Puts the active tab's cursor at `to`. + fn go(&mut self, to: Rect, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return; + }; + let (id, view) = (tab.id, tab.view.clone()); + let moves = view.read(cx).moves(); + let seq = view.update(cx, |view, cx| { + view.reveal(to, cx); + view.sent() + }); + self.going = Some((id, seq, moves, to)); + } + + pub fn on_back(&mut self, _: &GoBack, _: &mut Window, cx: &mut Context) { + let Some((id, place)) = self.back.pop() else { + return; + }; + if self.tab(id).is_none() { + let text = Docs::text(cx, "doc.nav.gone", &[]); + return self.toast(text, Severity::Warning, cx); + } + log::info!("doc: back to tab {id}"); + self.core.active = Some(id); + self.go(place, cx); + cx.notify(); + } + + pub fn on_navigator(&mut self, _: &ToggleNavigator, _: &mut Window, cx: &mut Context) { + self.navigating = !self.navigating; + cx.notify(); + } +} + +impl Render for Navigator { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let this = cx.entity(); + let text = |key: &str| Docs::text(cx, key, &[]); + let docs = self.docs.clone(); + let back = docs + .upgrade() + .is_some_and(|docs| !docs.read(cx).back.is_empty()); + let head = Button::new("doc-nav-back", text("doc.nav.back")) + .size(ControlSize::Sm) + .variant(ButtonVariant::Ghost) + .icon(IconName::ArrowLeft) + .disabled(!back) + .on_click(move |_, window, cx| window.dispatch_action(Box::new(GoBack), cx)); + let pane = SegmentedControl::new( + "doc-nav-pane", + match self.pane { + Pane::Outline => "outline", + Pane::Pages => "pages", + Pane::Marks => "marks", + }, + ) + .size(ControlSize::Sm) + .segment("outline", text("doc.nav.outline"), Some(IconName::List)) + .segment("pages", text("doc.nav.pages"), Some(IconName::FileText)) + .segment("marks", text("doc.nav.marks"), Some(IconName::Bookmark)) + .on_change({ + let this = this.clone(); + move |value, _, cx| { + this.update(cx, |navigator, cx| { + navigator.pane = match value.as_ref() { + "pages" => Pane::Pages, + "marks" => Pane::Marks, + _ => Pane::Outline, + }; + cx.notify(); + }) + } + }); + let body = match self.pane { + Pane::Outline => { + let mut outline = Outline::new("doc-nav-outline"); + for (at, heading) in self.outline.iter().enumerate() { + let level = usize::try_from(heading.level).expect("a level") + 1; + outline = outline.item(at.to_string(), heading.text.clone(), level); + } + if let Some(at) = self.outline.iter().position(|heading| heading.current) { + outline = outline.current(at.to_string()); + } + let this = this.clone(); + outline + .on_select(move |key, _, cx| { + let at: usize = key.parse().expect("an outline index"); + let (to, docs) = { + let navigator = this.read(cx); + (navigator.outline[at].rect, navigator.docs.clone()) + }; + Navigator::jump(&docs, to, cx); + }) + .into_any_element() + } + Pane::Pages => { + let pages = self + .shown + .as_ref() + .map(|shown| shown.view.read(cx).pages().to_vec()) + .unwrap_or_default(); + let current = self.current_page(&pages, cx); + let rows = pages.iter().enumerate().map(|(at, &(x, y, _, _))| { + let docs = docs.clone(); + let label: SharedString = + Docs::text(cx, "doc.nav.page", &[("n", &(at + 1).to_string())]); + ListItem::new(("doc-nav-page", at), label) + .selected(current == Some(at)) + .on_click(move |_, _, cx| Navigator::jump(&docs, (x, y, 0, 0), cx)) + }); + List::new().children(rows).into_any_element() + } + Pane::Marks => { + let rows = self.marks.iter().enumerate().map(|(at, mark)| { + let docs = docs.clone(); + let to = mark.rect.expect("listed marks are placed"); + ListItem::new(("doc-nav-mark", at), mark.name.clone()) + .on_click(move |_, _, cx| Navigator::jump(&docs, to, cx)) + }); + List::new().children(rows).into_any_element() + } + }; + div() + .size_full() + .flex() + .flex_col() + .gap_2() + .p_2() + .child(head) + .child(pane) + .child( + ScrollArea::new("doc-nav-scroll").vertical() + .flex_1() + .min_h_0() + .child(body), + ) + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/ely-doc/src/navigate/tests.rs b/apps/ely-doc/src/navigate/tests.rs new file mode 100644 index 0000000..2e263c4 --- /dev/null +++ b/apps/ely-doc/src/navigate/tests.rs @@ -0,0 +1,254 @@ +use gpui::{Entity, TestAppContext}; + +use super::*; +use crate::tests::{docs, settle, settled, text_of, with_window, written}; +use ely_office::Dialog; + +/// Three pages, two headings, a font the system lacks. +const LONG: &str = r#" + + + + + + + + First partalpha odd + Second partbravo + charlie + +"#; + +/// Two headings side by side in two columns. +const COLUMNS: &str = r#" + + + + + + + + First partalpha + Second partbravo + +"#; + +fn open_long(cx: &mut TestAppContext, docs: &Entity, dir: &std::path::Path) -> u64 { + open_text(cx, docs, &dir.join("long.fodt"), LONG) +} + +fn open_text( + cx: &mut TestAppContext, + docs: &Entity, + path: &std::path::Path, + text: &str, +) -> u64 { + let path = path.to_path_buf(); + std::fs::write(&path, text).expect("the file writes"); + with_window(cx, docs, |docs, _, cx| docs.open_file(path.clone(), cx)); + settle(cx, |app| docs.read(app).core.tabs.len() == 1); + settled(cx, docs); + cx.update(|app| docs.read(app).core.tabs[0].id) +} + +fn cursor_y(app: &gpui::App, docs: &Entity) -> Option { + let tab = docs.read(app).active_tab()?; + tab.view.read(app).cursor().map(|cursor| cursor.1) +} + +#[test] +fn headings_and_pages_jump_and_back_returns() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + open_long(&mut cx, &docs, dir.path()); + settle(&cx, |app| { + let tab = docs.read(app).active_tab().expect("a tab"); + tab.view.read(app).missing_fonts() == ["Nowhere Sans"] + && tab.view.read(app).cursor().is_some() + }); + let navigator = cx.update(|app| docs.read(app).navigator.clone()); + cx.update(|app| { + let (view, doc) = { + let tab = docs.read(app).active_tab().expect("a tab"); + (tab.view.clone(), tab.doc.clone()) + }; + navigator.update(app, |navigator, cx| navigator.show(Some((&view, &doc)), cx)); + }); + settle(&cx, |app| navigator.read(app).outline.len() == 2); + let current = |app: &gpui::App| { + let navigator = navigator.read(app); + navigator.outline.iter().position(|heading| heading.current) + }; + settle(&cx, |app| current(app) == Some(0)); + let start = cx.update(|app| cursor_y(app, &docs)).expect("a cursor"); + let (first, second) = cx.update(|app| { + let outline = &navigator.read(app).outline; + (outline[0].rect, outline[1].rect) + }); + let third = cx.update(|app| { + let tab = docs.read(app).active_tab().expect("a tab"); + tab.view.read(app).pages()[2] + }); + cx.update(|app| { + docs.update(app, |docs, cx| { + docs.jump(second, cx); + docs.jump((third.0, third.1, 0, 0), cx); + }) + }); + settle(&cx, |app| { + cursor_y(app, &docs).is_some_and(|y| y >= third.1) + }); + assert_eq!( + cx.update(|app| docs.read(app).back.last().map(|place| place.1)), + Some(second), + "a jump in flight is the place" + ); + settle(&cx, |app| current(app) == Some(1)); + let between = + |app: &gpui::App| cursor_y(app, &docs).is_some_and(|y| y >= second.1 && y < third.1); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_back(&GoBack, window, cx) + }); + settle(&cx, between); + cx.update(|app| docs.update(app, |docs, cx| docs.jump(first, cx))); + settle(&cx, |app| current(app) == Some(0)); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_back(&GoBack, window, cx) + }); + settle(&cx, between); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_back(&GoBack, window, cx) + }); + settle(&cx, |app| { + cursor_y(app, &docs).is_some_and(|y| y <= start + 300) + }); + let home = cx.update(|app| { + let tab = docs.read(app).active_tab().expect("a tab"); + tab.view.read(app).cursor().expect("a cursor") + }); + cx.update(|app| docs.update(app, |docs, cx| docs.jump(second, cx))); + settle(&cx, between); + let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone()); + cx.update(|app| view.update(app, |view, cx| view.reveal(home, cx))); + settle(&cx, |app| { + cursor_y(app, &docs).is_some_and(|y| y <= start + 300) + }); + cx.update(|app| docs.update(app, |docs, cx| docs.jump((third.0, third.1, 0, 0), cx))); + settle(&cx, |app| { + cursor_y(app, &docs).is_some_and(|y| y >= third.1) + }); + assert_eq!( + cx.update(|app| docs.read(app).back.last().map(|place| place.1)), + Some(home), + "a landed jump is no longer the place" + ); +} + +#[test] +fn the_current_heading_follows_the_cursor_across_columns() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + open_text(&mut cx, &docs, &dir.path().join("columns.fodt"), COLUMNS); + let navigator = cx.update(|app| docs.read(app).navigator.clone()); + cx.update(|app| { + let (view, doc) = { + let tab = docs.read(app).active_tab().expect("a tab"); + (tab.view.clone(), tab.doc.clone()) + }; + navigator.update(app, |navigator, cx| navigator.show(Some((&view, &doc)), cx)); + }); + let current = |app: &gpui::App| { + let navigator = navigator.read(app); + navigator.outline.iter().position(|heading| heading.current) + }; + settle(&cx, |app| current(app) == Some(0)); + let (first, second) = cx.update(|app| { + let outline = &navigator.read(app).outline; + (outline[0].rect, outline[1].rect) + }); + assert_eq!(first.1, second.1, "the headings sit side by side"); + cx.update(|app| docs.update(app, |docs, cx| docs.jump(second, cx))); + settle(&cx, |app| current(app) == Some(1)); + cx.update(|app| docs.update(app, |docs, cx| docs.jump(first, cx))); + settle(&cx, |app| current(app) == Some(0)); +} + +#[cfg(unix)] +#[test] +fn pdfs_export_a_range_and_locked_files_ask() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = open_long(&mut cx, &docs, dir.path()); + let pdf = dir.path().join("two.pdf"); + let only_two = ely_lok::Pdf { + pages: Some((2, 2)), + comments: false, + changes: true, + handouts: 0, + }; + cx.update(|app| { + docs.update(app, |docs, cx| { + docs.export_to(id, pdf.clone(), only_two, cx) + }) + }); + written(&cx, &pdf, None); + let source = dir.path().join("long.fodt"); + let alias = dir.path().join("alias.pdf"); + std::os::unix::fs::symlink(&source, &alias).expect("the alias"); + let link = dir.path().join("link.pdf"); + std::fs::hard_link(&source, &link).expect("the hard link"); + for target in [&source, &alias, &link] { + cx.update(|app| { + docs.update(app, |docs, cx| { + docs.export_to(id, target.clone(), ely_lok::Pdf::default(), cx) + }) + }); + } + assert_eq!( + std::fs::read_to_string(&source).expect("the source reads"), + LONG, + "an open source is never replaced" + ); + let locked = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../crates/ely-lok/src/client/tests/locked.odt"); + let copy = dir.path().join("locked.odt"); + std::fs::copy(&locked, ©).expect("the fixture copies"); + let asked = |app: &gpui::App, wrong: bool| matches!(&docs.read(app).core.dialog, Some(Dialog::Password { wrong: was, .. }) if *was == wrong); + with_window(&mut cx, &docs, |docs, _, cx| { + docs.open_file(copy.clone(), cx) + }); + settle(&cx, |app| asked(app, false)); + // As submitting does: the prompt closes, then the load. + with_window(&mut cx, &docs, |docs, _, cx| { + docs.core.dialog = None; + docs.open_with(copy.clone(), "wrong".into(), cx) + }); + settle(&cx, |app| asked(app, true)); + with_window(&mut cx, &docs, |docs, _, cx| { + docs.core.dialog = None; + docs.open_with(copy.clone(), "secret".into(), cx) + }); + settle(&cx, |app| docs.read(app).core.tabs.len() == 2); + let out = dir.path().join("opened.odt"); + let opened = cx.update(|app| docs.read(app).core.tabs[1].id); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(opened, out.clone(), None, window, cx) + }); + written(&cx, &out, None); + assert!( + text_of(&mut cx, &docs, &out).contains("Teh cat"), + "the locked text opens" + ); +} diff --git a/apps/ely-doc/src/output.rs b/apps/ely-doc/src/output.rs new file mode 100644 index 0000000..b6d82e5 --- /dev/null +++ b/apps/ely-doc/src/output.rs @@ -0,0 +1,259 @@ +//! Export to PDF and print (DOC-15). + +use std::path::PathBuf; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + data_display::DescriptionList, + forms::{NumberInput, Switch}, + overlays::Dialog as Card, + primitives::Severity, + theme::ControlSize, +}; +use ely_lok::Pdf; +use ely_ui::commands::{Export, Print}; +use gpui::{App, Context, Entity, ParentElement as _, Styled as _, TaskExt as _, Window, div}; + +use crate::actions::Dialog; +use ely_office::in_window; +use ely_office::prelude::*; + +use crate::docs::Docs; + +impl Docs { + pub fn on_export(&mut self, _: &Export, _: &mut Window, cx: &mut Context) { + self.ask_output(false, cx); + } + + pub fn on_print(&mut self, _: &Print, _: &mut Window, cx: &mut Context) { + self.ask_output(true, cx); + } + + fn ask_output(&mut self, print: bool, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + let text = Docs::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + let last = u32::try_from(tab.view.read(cx).pages().len()).expect("a page count"); + self.dialog = Some(Dialog::Output { + tab: tab.id, + print, + all: true, + pdf: Pdf { + pages: Some((1, last)), + comments: false, + changes: true, + handouts: 0, + }, + }); + cx.notify(); + } + + /// Writes the asked PDF, then saves or prints it. + pub(crate) fn output(&mut self, cx: &mut Context) { + let Some(Dialog::Output { + tab, + print, + all, + pdf, + }) = self.dialog.take() + else { + return; + }; + cx.notify(); + let pdf = Pdf { + pages: pdf.pages.filter(|_| !all), + ..pdf + }; + if !self.settled(tab, cx) { + self.dialog = Some(Dialog::Output { + tab, + print, + all, + pdf, + }); + return self.when_settled(tab, |docs, _, cx| docs.output(cx)); + } + let Some(source) = self.tab(tab) else { + return; + }; + if print { + let path = source.work.join("print.pdf"); + let printing = path.clone(); + return self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written { + Ok(()) => ely_office::print_later(printing, cx, |docs, error, cx| { + docs.output_failed(&error, cx) + }), + Err(error) => docs.output_failed(&error.to_string(), cx), + }); + } + let Some(dir) = ely_office::folder(source) else { + return self.panel_failed("no documents folder", cx); + }; + let stem = source + .name + .rsplit_once('.') + .map_or(source.name.as_ref(), |(stem, _)| stem); + let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.pdf"))); + cx.spawn(async move |this, cx| { + let chosen = match chosen.await { + Ok(Ok(path)) => Ok(path), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + in_window(&this, cx, |docs, _, cx| match chosen { + Ok(Some(path)) => docs.export_to(tab, path, pdf, cx), + Ok(None) => log::info!("doc: the export panel was cancelled"), + Err(error) => docs.panel_failed(&error, cx), + }) + }) + .detach_and_log_err(cx); + } + + pub(crate) fn export_to(&mut self, tab: u64, path: PathBuf, pdf: Pdf, cx: &mut Context) { + match self.holder(&path) { + Ok(None) => {} + Ok(Some(_)) => { + log::info!("doc: the export target is an open document"); + let name = ely_office::files::file_name(&path); + let text = Docs::text(cx, "doc.output.open", &[("name", &name)]); + return self.toast(text, Severity::Danger, cx); + } + Err(error) => return self.output_failed(&error.to_string(), cx), + } + let (name, opened) = (ely_office::files::file_name(&path), path.clone()); + self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written { + Ok(()) => { + let text = Docs::text(cx, "doc.output.exported", &[("name", &name)]); + docs.toast_output(text, opened.clone(), cx); + } + Err(error) => docs.output_failed(&error.to_string(), cx), + }); + } + + /// Writes the PDF in the background; `then` takes the outcome. + fn write_pdf( + &mut self, + tab: u64, + path: PathBuf, + pdf: Pdf, + cx: &mut Context, + then: impl FnOnce(&mut Self, std::io::Result<()>, &mut Context) + 'static, + ) { + let Some(name) = self.tab(tab).map(|tab| tab.name.clone()) else { + return self.output_failed("the document closed", cx); + }; + log::info!("doc: writing a PDF of {name} ({pdf:?})"); + let url = ely_lok::file_url(&path); + self.read_in( + tab, + move |worker, doc| doc.export_pdf(worker, &url, &pdf), + move |docs, written, _, cx| then(docs, written, cx), + cx, + ); + } + + fn output_failed(&self, error: &str, cx: &mut Context) { + let text = Docs::text(cx, "doc.output.failed", &[("error", error)]); + self.toast(text, Severity::Danger, cx); + } +} + +/// Edits the open output dialog. +fn change(this: &Entity, cx: &mut App, edit: impl FnOnce(&mut bool, &mut Pdf)) { + this.update(cx, |docs, cx| { + if let Some(Dialog::Output { all, pdf, .. }) = &mut docs.dialog { + edit(all, pdf); + } + cx.notify(); + }) +} + +/// The dialog before a PDF goes out: pages, comments, changes. +pub fn output_dialog( + this: Entity, + (print, all, pdf, last): (bool, bool, Pdf, u32), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let text = |key: &str| Docs::text(cx, key, &[]); + let (from, to) = pdf.pages.unwrap_or((1, last)); + let pages = { + let this = this.clone(); + SegmentedControl::new("doc-output-pages", if all { "all" } else { "range" }) + .size(ControlSize::Sm) + .segment("all", text("doc.output.all"), None) + .segment("range", text("doc.output.range"), None) + .on_change(move |value, _, cx| { + change(&this, cx, |all, _| *all = value.as_ref() == "all") + }) + }; + let bound = |id: &'static str, value: u32, first: bool| { + let this = this.clone(); + NumberInput::new(id, f64::from(value)) + .range(1.0, f64::from(last)) + .on_commit(move |value, _, cx| { + let value = value.round() as u32; + change(&this, cx, |_, pdf| { + let (from, to) = pdf.pages.unwrap_or((1, value)); + pdf.pages = Some(if first { + (value, to.max(value)) + } else { + (from.min(value), value) + }); + }) + }) + }; + let range = (!all).then(|| { + div() + .flex() + .items_center() + .gap_2() + .child(div().w_full().max_w_24().child(bound("doc-output-from", from, true))) + .child("–") + .child(div().w_full().max_w_24().child(bound("doc-output-to", to, false))) + }); + let toggle = |id: &'static str, key: &str, on: bool, set: fn(&mut Pdf, bool)| { + let this = this.clone(); + Switch::new(id, on) + .label(text(key)) + .on_change(move |on, _, cx| change(&this, cx, |_, pdf| set(pdf, on))) + }; + let fields = DescriptionList::new() + .item( + text("doc.output.pages"), + div().flex().flex_col().gap_2().child(pages).children(range), + ) + .item( + text("doc.output.marks"), + div() + .flex() + .flex_col() + .gap_2() + .child(toggle( + "doc-output-comments", + "doc.output.comments", + pdf.comments, + |pdf, on| pdf.comments = on, + )) + .child(toggle( + "doc-output-changes", + "doc.output.changes", + pdf.changes, + |pdf, on| pdf.changes = on, + )), + ); + let (title, go) = match print { + true => ("doc.output.print_title", "doc.output.print"), + false => ("doc.output.export_title", "doc.output.export"), + }; + let label = text(go); + Card::new("doc-output", text(title), dismiss) + .child(fields) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("doc-output-go", label) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |docs, cx| docs.output(cx))) + }) +} diff --git a/apps/ely-doc/src/props.rs b/apps/ely-doc/src/props.rs new file mode 100644 index 0000000..b87c2ac --- /dev/null +++ b/apps/ely-doc/src/props.rs @@ -0,0 +1,256 @@ +//! The properties panel (DOC-02 to 08). + +mod groups; +#[cfg(test)] +mod layout_tests; +mod object; +mod page; +mod pairs; +mod paragraph; +mod sheet; +mod styles; +mod table; +#[cfg(test)] +mod tests; + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use ely_gpui_component::{ + data_display::PropertyGrid, forms::TextInput, layout::ScrollArea, overlays::PromptDialog, +}; +use ely_lok::{Doc, LokError, LokView, Reported, Worker}; +use ely_office::panel::Panel as _; +use gpui::{ + App, Context, Entity, EventEmitter, IntoElement, ParentElement as _, Render, SharedString, + Styled as _, Subscription, Window, div, +}; + +use crate::docs::Docs; + +/// The active document the panel edits. +struct Shown { + view: Entity, + doc: Doc, + paragraph: Vec, + character: Vec, + pages: Vec, + _observe: Subscription, + _errors: Subscription, +} + +pub struct Props { + worker: Arc>, + shown: Option, + fonts: Vec, + /// Re-read the styles once input lands. + reload: bool, + /// The family a new style goes to: "1" or "2". + naming: Option<&'static str>, + tab_at: f64, + /// Two-value edits awaiting the kernel's echo. + pending: HashMap<&'static str, pairs::Sent>, + /// Page edits awaiting the kernel's echo. + sheet_sent: Option, +} + +impl EventEmitter for Props {} + +impl Props { + pub fn new(worker: Arc>) -> Self { + Props { + worker, + shown: None, + fonts: Vec::new(), + reload: false, + naming: None, + tab_at: 1.0, + pending: HashMap::new(), + sheet_sent: None, + } + } + + /// Follows the active tab; reads the kernel lists on change. + pub fn show(&mut self, tab: Option<(&Entity, &Doc)>, cx: &mut Context) { + let id = tab.map(|(view, _)| view.entity_id()); + if self.shown.as_ref().map(|shown| shown.view.entity_id()) == id { + return; + } + self.shown = None; + self.naming = None; + self.pending.clear(); + self.sheet_sent = None; + let Some((view, doc)) = tab else { + return; + }; + let observe = cx.subscribe(view, |props, view, _: &Reported, cx| { + if props.reload && view.read(cx).settled() { + props.reload = false; + props.read_styles(cx); + } + props.track_pairs(cx); + props.track_sheet(cx); + cx.notify(); + }); + let errors = cx.subscribe(view, |props, _, _: &LokError, _| { + props.pending.clear(); + props.sheet_sent = None; + }); + self.shown = Some(Shown { + view: view.clone(), + doc: doc.clone(), + paragraph: Vec::new(), + character: Vec::new(), + pages: Vec::new(), + _observe: observe, + _errors: errors, + }); + if self.fonts.is_empty() { + self.read_fonts(doc, cx); + } + self.read_styles(cx); + } + + fn read_fonts(&mut self, doc: &Doc, cx: &mut Context) { + let read = self + .worker + .lock() + .expect("the worker lock") + .values(doc, ".uno:CharFontName") + .and_then(|json| ely_lok::commands::fonts(&json)); + match read { + Ok(fonts) => self.fonts = fonts.into_iter().map(Into::into).collect(), + Err(error) => cx.emit(LokError(format!("the font list is unreadable: {error}"))), + } + } + + fn read_styles(&mut self, cx: &mut Context) { + let Some(shown) = self.shown.as_mut() else { + return; + }; + let read = self + .worker + .lock() + .expect("the worker lock") + .values(&shown.doc, ".uno:StyleApply") + .and_then(|json| { + let list = |family| -> std::io::Result> { + let names = ely_lok::commands::styles(&json, family)?; + Ok(names.into_iter().map(Into::into).collect()) + }; + Ok((list("2")?, list("1")?, list("8")?)) + }); + match read { + Ok((paragraph, character, pages)) => { + shown.paragraph = paragraph; + shown.character = character; + shown.pages = pages; + } + Err(error) => cx.emit(LokError(format!("the style list is unreadable: {error}"))), + } + } + + /// Re-reads the style lists once queued input lands. + pub fn refresh_lists(&mut self) { + self.reload = true; + } + + /// Sends one table command to the shown document. + fn run(&mut self, id: &str, values: &[&str], cx: &mut Context) { + let Some(shown) = &self.shown else { + return; + }; + log::info!("doc: panel runs {id}"); + // Numbers here, like tab stops, are as the view shows. + let sent = shown.view.update(cx, |view, _| view.run_at(&view.read_at(), id, values)); + if let Err(error) = sent { + cx.emit(LokError(format!("{id}: {error}"))); + } + } + + /// A style from the selection; lists reload after. + fn create_style(&mut self, name: &str, family: &'static str, cx: &mut Context) { + self.naming = None; + self.reload = true; + self.run("style.new", &[name, family], cx); + cx.notify(); + } + + /// The new-style prompt, while open. + fn prompt(&self, window: &mut Window, cx: &mut Context) -> Option { + let (family, shown) = (self.naming?, self.shown.as_ref()?); + let field = window.use_keyed_state("doc-style-name", cx, |window, cx| { + TextInput::new(window, cx) + }); + let taken = match family { + "1" => shown.character.clone(), + _ => shown.paragraph.clone(), + }; + let title = match family { + "1" => "doc.style.new_character", + _ => "doc.style.new_paragraph", + }; + let this = cx.entity(); + let close = { + let this = this.clone(); + move |_: &mut Window, cx: &mut App| { + this.update(cx, |props, cx| { + props.naming = None; + cx.notify(); + }) + } + }; + let (empty, exists) = ( + Docs::text(cx, "doc.style.empty", &[]), + Docs::text(cx, "doc.style.taken", &[]), + ); + Some( + PromptDialog::new("doc-style-new", Docs::text(cx, title, &[]), &field, close) + .label(Docs::text(cx, "doc.style.name", &[])) + .submit(Docs::text(cx, "doc.style.create", &[])) + .check(move |text| { + let name = text.trim(); + if name.is_empty() { + return Err(empty.clone()); + } + if taken.iter().any(|style| style == name) { + return Err(exists.clone()); + } + Ok(()) + }) + .on_submit(move |text, _, cx| { + let name = text.trim().to_string(); + this.update(cx, |props, cx| props.create_style(&name, family, cx)) + }), + ) + } +} + +impl Render for Props { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let graphic = self + .shown + .as_ref() + .is_some_and(|shown| shown.view.read(cx).graphic().is_some()); + let grid = PropertyGrid::new("doc-props"); + let grid = if graphic { + grid.group(self.object_group(cx)) + } else { + let grid = grid + .group(self.text_group(window, cx)) + .group(self.paragraph_group(cx)) + .group(self.style_group(cx)) + .group(self.page_group(cx)); + match self.state(".uno:DeleteTable", cx).as_deref() { + Some("enabled") => grid.group(self.table_group(cx)), + _ => grid, + } + }; + div() + .size_full() + .child(ScrollArea::new("doc-props-scroll").vertical().size_full().child(grid)) + .children(self.prompt(window, cx)) + } +} diff --git a/apps/ely-doc/src/props/groups.rs b/apps/ely-doc/src/props/groups.rs new file mode 100644 index 0000000..046886d --- /dev/null +++ b/apps/ely-doc/src/props/groups.rs @@ -0,0 +1,96 @@ +//! The panel's text group and its kernel hookup. + +use ely_gpui_component::{data_display::PropertyGroup, primitives::IconName}; +use ely_office::panel::{Panel, color_state, font_box, palette, row, size_menu, toggle}; +use gpui::{App, Context, SharedString, Window}; + +use super::Props; +use crate::docs::Docs; + +/// The swatch's label without a color. +const AUTOMATIC: &str = "office.props.automatic"; + +impl Panel for Props { + fn state(&self, uno: &str, cx: &App) -> Option { + let view = self.shown.as_ref()?.view.read(cx); + view.state(uno) + .map(|state| SharedString::from(state.to_string())) + } + + fn run(&mut self, id: &str, values: &[&str], cx: &mut Context) { + Props::run(self, id, values, cx); + } +} + +impl Props { + pub(super) fn text_group(&self, window: &mut Window, cx: &mut Context) -> PropertyGroup { + let why = "doc.why.selection"; + let effects = row([ + toggle( + self, + "format.bold", + (Some(IconName::Bold), ""), + ("doc.props.bold", why), + cx, + ), + toggle( + self, + "format.italic", + (Some(IconName::Italic), ""), + ("doc.props.italic", why), + cx, + ), + toggle( + self, + "format.underline", + (Some(IconName::Underline), ""), + ("doc.props.underline", why), + cx, + ), + toggle( + self, + "format.strikeout", + (Some(IconName::Strikethrough), ""), + ("doc.props.strikeout", why), + cx, + ), + toggle( + self, + "format.superscript", + (None, "x²"), + ("doc.props.superscript", why), + cx, + ), + toggle( + self, + "format.subscript", + (None, "x₂"), + ("doc.props.subscript", why), + cx, + ), + ]); + let this = cx.entity(); + let color = color_state(self, ".uno:FontColor", cx); + let highlight = color_state(self, ".uno:CharBackColor", cx); + let font = font_box(self, "doc-props-font", &self.fonts, window, cx); + let tip = |key: &str| Docs::text(cx, key, &[]); + PropertyGroup::new(tip("doc.props.text")) + .row(tip("doc.props.font"), font) + .row(tip("doc.props.size"), size_menu(self, "doc-props-size", cx)) + .row(tip("doc.props.effects"), effects) + .row( + tip("doc.props.color"), + palette(&this, "format.color", (color, AUTOMATIC), None, cx), + ) + .row( + tip("doc.props.highlight"), + palette( + &this, + "format.highlight", + (highlight, AUTOMATIC), + Some(("doc.props.no_highlight", "format.highlight", "-1")), + cx, + ), + ) + } +} diff --git a/apps/ely-doc/src/props/layout_tests.rs b/apps/ely-doc/src/props/layout_tests.rs new file mode 100644 index 0000000..8dd6ba4 --- /dev/null +++ b/apps/ely-doc/src/props/layout_tests.rs @@ -0,0 +1,296 @@ +use gpui::{Entity, TestAppContext}; + +use super::Props; +use super::tests::panel; +use crate::docs::Docs; +use crate::insert::InsertPageNumber; +use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written}; +use ely_office::panel::Panel as _; +use ely_office::prelude::*; + +fn state(cx: &TestAppContext, props: &Entity, uno: &str) -> Option { + cx.update(|app| { + props + .read(app) + .state(uno, app) + .map(|state| state.to_string()) + }) +} + +#[test] +fn the_page_turns_resizes_and_keeps_quick_margins() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "Page"); + let props = panel(&mut cx, &docs); + settle(&cx, |app| props.read(app).sheet(app).is_some()); + cx.update(|app| props.update(app, |props, cx| props.set_orientation(true, cx))); + settle(&cx, |app| { + let props = props.read(app); + let size = props.state(".uno:AttributePageSize", app); + let size = size.as_deref().and_then(super::sheet::size_of); + props + .state(".uno:AttributePage", app) + .is_some_and(|page| page.starts_with("true,")) + && size.is_some_and(|(w, h)| w > h) + }); + cx.update(|app| props.update(app, |props, cx| props.set_paper("A5", cx))); + settle(&cx, |app| { + let size = props.read(app).state(".uno:AttributePageSize", app); + size.as_deref() + .and_then(super::sheet::size_of) + .is_some_and(|(w, h)| (w - 21000).abs() <= 2 && (h - 14800).abs() <= 2) + }); + let margins = (".uno:PageLRMargin", "page.margins_lr"); + let twips = |cm: f64| (cm * 1440.0 / 2.54).round() as i32; + cx.update(|app| { + props.update(app, |props, cx| { + props.set_pair(margins, false, twips(2.0), cx); + props.set_pair(margins, true, twips(3.0), cx); + }) + }); + settle(&cx, |app| { + props.read(app).state(".uno:PageLRMargin", app).as_deref() == Some("2000,3000") + }); + settled(&cx, &docs); + let path = dir.path().join("page.docx"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, path.clone(), None, window, cx) + }); + written(&cx, &path, None); + let text = text_of(&mut cx, &docs, &path); + assert!( + text.contains("style:print-orientation=\"landscape\""), + "landscape lands" + ); + assert!( + text.contains("fo:page-width=\"8.2681in\""), + "A5 across lands" + ); + assert!( + text.contains("fo:margin-left=\"0.7874in\" fo:margin-right=\"1.1811in\""), + "margins land" + ); +} + +#[test] +fn insert_actions_and_object_size_land() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "Insert"); + let props = panel(&mut cx, &docs); + settle(&cx, |app| { + props.read(app).state(".uno:PageStyleName", app).is_some() + }); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_page_number(&InsertPageNumber, window, cx); + docs.insert_section(cx); + docs.insert_table(3.0, 2.0, cx); + }); + settle(&cx, |app| { + props.read(app).state(".uno:DeleteTable", app).as_deref() == Some("enabled") + }); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(300, 200, image::Rgba([0, 0, 255, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("the png encodes"); + let image = dir.path().join("blue.png"); + std::fs::write(&image, png).expect("the png writes"); + let url = ely_lok::file_url(&image); + cx.update(|app| props.update(app, |props, cx| props.run("image.insert", &[&url], cx))); + settle(&cx, |app| { + props + .read(app) + .state(".uno:Size", app) + .is_some_and(|s| s.contains(" x ")) + }); + settle(&cx, |app| { + docs.read(app) + .active_tab() + .expect("a tab") + .view + .read(app) + .graphic() + .is_some() + }); + let before = state(&cx, &props, ".uno:Size").expect("a size"); + let (w0, h0) = super::sheet::size_of(&before).expect("parsed"); + cx.update(|app| props.update(app, |props, cx| props.set_extent(false, 2.0, cx))); + settle(&cx, |app| { + let size = props.read(app).state(".uno:Size", app); + size.as_deref() + .and_then(super::sheet::size_of) + .is_some_and(|(w, _)| (w - 2000).abs() <= 3) + }); + let (w, h) = + super::sheet::size_of(&state(&cx, &props, ".uno:Size").expect("a size")).expect("parsed"); + let ratio = (f64::from(w) / f64::from(h)) / (f64::from(w0) / f64::from(h0)); + assert!( + (ratio - 1.0).abs() < 0.01, + "the ratio holds: {w}x{h} from {w0}x{h0}" + ); + settled(&cx, &docs); + let path = dir.path().join("insert.docx"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, path.clone(), None, window, cx) + }); + written(&cx, &path, None); + let text = text_of(&mut cx, &docs, &path); + for mark in [ + ""), + "the footer differs on even pages" + ); +} + +#[test] +fn queued_page_edits_footers_and_new_sections_show_up() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &docs); + type_into(&mut cx, &docs, "Page"); + let props = panel(&mut cx, &docs); + settle(&cx, |app| props.read(app).sheet(app).is_some()); + cx.update(|app| { + props.update(app, |props, cx| { + props.set_paper("A5", cx); + props.set_orientation(true, cx); + }) + }); + settle(&cx, |app| { + let props = props.read(app); + let size = props.state(".uno:AttributePageSize", app); + let size = size.as_deref().and_then(super::sheet::size_of); + props + .state(".uno:AttributePage", app) + .is_some_and(|page| page.starts_with("true,")) + && size.is_some_and(|(w, h)| (w - 21000).abs() <= 2 && (h - 14800).abs() <= 2) + }); + cx.update(|app| props.update(app, |props, cx| props.run("footer.on", &["true"], cx))); + settle(&cx, |app| { + props + .read(app) + .state(".uno:SwPageFooterLayout", app) + .as_deref() + == Some("0") + }); + cx.update(|app| docs.update(app, |docs, cx| docs.insert_section(cx))); + let name = cx.update(|app| Docs::text(app, "doc.section.name", &[("n", "2")])); + settle(&cx, |app| { + let props = props.read(app); + props + .shown + .as_ref() + .is_some_and(|shown| shown.pages.contains(&name)) + }); +} diff --git a/apps/ely-doc/src/props/object.rs b/apps/ely-doc/src/props/object.rs new file mode 100644 index 0000000..4671f51 --- /dev/null +++ b/apps/ely-doc/src/props/object.rs @@ -0,0 +1,160 @@ +//! The object group: size, crop, replace, anchor, wrap, order (DOC-08). + +use ely_gpui_component::{ + buttons::Button, data_display::PropertyGroup, forms::NumberInput, theme::ControlSize, +}; +use ely_office::panel::{Panel as _, button, muted, row, toggle}; +use gpui::{AnyElement, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div}; + +use super::Props; +use super::sheet::size_of; +use crate::docs::Docs; + +/// 1/100 mm in whole twips, rounded as the kernel rounds. +fn twips_of(mm100: f64) -> i64 { + ((mm100 * 72.0 + 63.0) / 127.0).floor() as i64 +} + +impl Props { + pub(super) fn object_group(&self, cx: &mut Context) -> PropertyGroup { + let tip = |key: &str| Docs::text(cx, key, &[]); + let why = "doc.why.selection"; + let anchors = row([ + toggle( + self, + "image.anchor_page", + (None, &tip("doc.props.to_page")), + ("", "doc.why.anchor"), + cx, + ), + toggle( + self, + "image.anchor_paragraph", + (None, &tip("doc.props.to_paragraph")), + ("", "doc.why.anchor"), + cx, + ), + toggle( + self, + "image.anchor_char", + (None, &tip("doc.props.to_char")), + ("", "doc.why.anchor"), + cx, + ), + toggle( + self, + "image.anchor_as_char", + (None, &tip("doc.props.as_char")), + ("", "doc.why.anchor"), + cx, + ), + ]); + let wraps = row([ + toggle( + self, + "image.wrap_off", + (None, &tip("doc.props.wrap_none")), + ("", why), + cx, + ), + toggle( + self, + "image.wrap_on", + (None, &tip("doc.props.wrap_both")), + ("", why), + cx, + ), + toggle( + self, + "image.wrap_ideal", + (None, &tip("doc.props.wrap_ideal")), + ("", why), + cx, + ), + toggle( + self, + "image.wrap_left", + (None, &tip("doc.props.wrap_left")), + ("", why), + cx, + ), + toggle( + self, + "image.wrap_right", + (None, &tip("doc.props.wrap_right")), + ("", why), + cx, + ), + toggle( + self, + "image.wrap_through", + (None, &tip("doc.props.wrap_through")), + ("", why), + cx, + ), + ]); + let order = row([ + button(self, "image.front", "doc.props.front", why, cx), + button(self, "image.forward", "doc.props.forward", why, cx), + button(self, "image.backward", "doc.props.backward", why, cx), + button(self, "image.back", "doc.props.back", why, cx), + ]); + let replace = Button::new("doc-props-replace", tip("doc.props.replace")) + .size(ControlSize::Sm) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(crate::insert::InsertImage), cx) + }); + PropertyGroup::new(tip("doc.props.object")) + .row(tip("doc.props.width"), self.extent(false, cx)) + .row(tip("doc.props.height"), self.extent(true, cx)) + .row( + tip("doc.props.image"), + row([ + button(self, "image.crop", "doc.props.crop", why, cx), + replace.into_any_element(), + ]), + ) + .row(tip("doc.props.anchor"), anchors) + .row(tip("doc.props.wrap"), wraps) + .row(tip("doc.props.order"), order) + } + + /// Sizes the image from one side in cm. + pub(super) fn set_extent(&mut self, tall: bool, cm: f64, cx: &mut Context) { + let Some((width, height)) = self.state(".uno:Size", cx).as_deref().and_then(size_of) else { + return; + }; + let mine = if tall { height } else { width }; + let scale = cm * 1000.0 / f64::from(mine); + let (w, h) = (f64::from(width) * scale, f64::from(height) * scale); + let unchanged = + twips_of(w) == twips_of(f64::from(width)) && twips_of(h) == twips_of(f64::from(height)); + if unchanged { + return; + } + let twips = [twips_of(w).to_string(), twips_of(h).to_string()]; + self.run("image.size", &[&twips[0], &twips[1]], cx); + } + + /// Width or height in cm, ratio kept. + fn extent(&self, tall: bool, cx: &Context) -> AnyElement { + let Some((width, height)) = self.state(".uno:Size", cx).as_deref().and_then(size_of) else { + return muted("—".into(), cx); + }; + if width <= 0 || height <= 0 { + return muted("—".into(), cx); + } + let this = cx.entity(); + let mine = if tall { height } else { width }; + let id = SharedString::from(format!("doc-props-extent-{tall}")); + let field = NumberInput::new(id, f64::from(mine) / 1000.0) + .range(0.1, 600.0) + .step(0.1) + .precision(2) + .size(ControlSize::Sm) + .on_commit(move |cm, _, cx| { + this.update(cx, |props, cx| props.set_extent(tall, cm, cx)) + }); + div().w_full().max_w_32().child(field).into_any_element() + } +} diff --git a/apps/ely-doc/src/props/page.rs b/apps/ely-doc/src/props/page.rs new file mode 100644 index 0000000..2c72b4d --- /dev/null +++ b/apps/ely-doc/src/props/page.rs @@ -0,0 +1,291 @@ +//! The page group: paper, margins, columns, sections, headers (DOC-05, 06). + +use ely_gpui_component::{ + buttons::{ToggleButton, ToggleItem}, + data_display::PropertyGroup, + forms::{Choice, NumberInput, Select}, + theme::ControlSize, +}; +use ely_office::panel::{Panel as _, known, muted, row, send}; +use gpui::{AnyElement, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div}; + +use super::Props; +use super::sheet::Sheet; +#[cfg(test)] +use super::sheet::{page_of, size_of}; +use crate::docs::Docs; + +/// Paper sizes offered, portrait, in 1/100 mm. +const PAPERS: [(&str, i32, i32); 6] = [ + ("A3", 29_700, 42_000), + ("A4", 21_000, 29_700), + ("A5", 14_800, 21_000), + ("B5", 17_600, 25_000), + ("Letter", 21_590, 27_940), + ("Legal", 21_590, 35_560), +]; + +const LEFT_RIGHT: (&str, &str) = (".uno:PageLRMargin", "page.margins_lr"); + +/// A header or footer: switch and layout. +struct Part { + on: (&'static str, &'static str), + layout: (&'static str, &'static str), + why: &'static str, +} + +const HEADER: Part = Part { + on: ("header.on", ".uno:SwPageHeaderItem"), + layout: ("header.layout", ".uno:SwPageHeaderLayout"), + why: "doc.why.header", +}; + +const FOOTER: Part = Part { + on: ("footer.on", ".uno:SwPageFooterItem"), + layout: ("footer.layout", ".uno:SwPageFooterLayout"), + why: "doc.why.footer", +}; +const TOP_BOTTOM: (&str, &str) = (".uno:PageULMargin", "page.margins_ul"); + +/// The offered paper within the kernel's rounding of `size`. +fn paper_of((width, height): (i32, i32)) -> Option<&'static str> { + let (short, long) = (width.min(height), width.max(height)); + PAPERS + .iter() + .find(|(_, w, h)| (short - w).abs() <= 2 && (long - h).abs() <= 2) + .map(|(name, ..)| *name) +} + +impl Props { + pub(super) fn page_group(&self, cx: &mut Context) -> PropertyGroup { + let tip = |key: &str| Docs::text(cx, key, &[]); + PropertyGroup::new(tip("doc.props.page")) + .row(tip("doc.props.paper"), self.paper(cx)) + .row(tip("doc.props.orientation"), self.orientation(cx)) + .row(tip("doc.props.top"), self.margin(TOP_BOTTOM, false, cx)) + .row(tip("doc.props.bottom"), self.margin(TOP_BOTTOM, true, cx)) + .row( + tip("doc.props.margin_left"), + self.margin(LEFT_RIGHT, false, cx), + ) + .row( + tip("doc.props.margin_right"), + self.margin(LEFT_RIGHT, true, cx), + ) + .row(tip("doc.props.columns"), self.columns(cx)) + .row(tip("doc.props.section"), self.section(cx)) + .row(tip("doc.props.header"), self.part(HEADER, cx)) + .row(tip("doc.props.footer"), self.part(FOOTER, cx)) + } + + fn paper(&self, cx: &Context) -> AnyElement { + let Some(Sheet { size, .. }) = self.sheet(cx) else { + return muted("—".into(), cx); + }; + let this = cx.entity(); + let choices = PAPERS.iter().map(|(name, ..)| Choice::new(*name, *name)); + let cm = |mm100: i32| format!("{:.2}", f64::from(mm100) / 1000.0); + let custom = Docs::text( + cx, + "doc.props.size_cm", + &[("w", &cm(size.0)), ("h", &cm(size.1))], + ); + let mut menu = Select::new("doc-props-paper", choices) + .size(ControlSize::Sm) + .placeholder(custom) + .on_change(move |name, _, cx| this.update(cx, |props, cx| props.set_paper(name, cx))); + if let Some(name) = paper_of(size) { + menu = menu.selected(name); + } + menu.into_any_element() + } + + fn orientation(&self, cx: &Context) -> AnyElement { + let Some(Sheet { landscape, .. }) = self.sheet(cx) else { + return muted("—".into(), cx); + }; + let face = |wide: bool| { + let this = cx.entity(); + let key = if wide { + "doc.props.landscape" + } else { + "doc.props.portrait" + }; + let item = ToggleItem::new(key).label(Docs::text(cx, key, &[])); + ToggleButton::new(SharedString::from(key), item, landscape == wide) + .size(ControlSize::Sm) + .on_toggle(move |_, _, cx| { + this.update(cx, |props, cx| props.set_orientation(wide, cx)) + }) + .into_any_element() + }; + row([face(false), face(true)]) + } + + /// An offered paper, in the page's orientation. + pub(super) fn set_paper(&mut self, name: &str, cx: &mut Context) { + let Some(sheet) = self.sheet(cx) else { + return; + }; + let (_, w, h) = PAPERS + .iter() + .find(|(paper, ..)| *paper == name) + .expect("an offered paper"); + let size = if sheet.landscape { (*h, *w) } else { (*w, *h) }; + self.send_sheet(Sheet { size, ..sheet }, cx); + } + + /// Turns the page, size and flag together. + pub(super) fn set_orientation(&mut self, wide: bool, cx: &mut Context) { + let Some(sheet) = self.sheet(cx) else { + return; + }; + let (width, height) = sheet.size; + let (short, long) = (width.min(height), width.max(height)); + let size = if wide { (long, short) } else { (short, long) }; + self.send_sheet( + Sheet { + size, + landscape: wide, + ..sheet + }, + cx, + ); + } + + /// One page margin in cm. + fn margin( + &self, + pair: (&'static str, &'static str), + second: bool, + cx: &Context, + ) -> AnyElement { + if self.state(pair.0, cx).as_deref() == Some(ely_lok::MIXED) { + return muted(Docs::text(cx, "office.props.mixed", &[]), cx); + } + let Some((first, other)) = self.pair(pair.0, cx) else { + return muted("—".into(), cx); + }; + let this = cx.entity(); + let mine = if second { other } else { first }; + let id = SharedString::from(format!("doc-props-{}-{second}", pair.1)); + let field = NumberInput::new(id, f64::from(mine) / 1000.0) + .range(0.0, 99.99) + .step(0.1) + .precision(2) + .size(ControlSize::Sm) + .on_commit(move |cm, _, cx| { + let twips = (cm * 1440.0 / 2.54).round() as i32; + this.update(cx, |props, cx| props.set_pair(pair, second, twips, cx)) + }); + div().w_full().max_w_32().child(field).into_any_element() + } + + fn columns(&self, cx: &Context) -> AnyElement { + let current = self.state(".uno:PageColumnType", cx); + row((1..=3).map(|count: i32| { + let this = cx.entity(); + let label = count.to_string(); + let on = current.as_deref() == Some(label.as_str()); + ToggleButton::new( + SharedString::from(format!("doc-props-columns-{count}")), + ToggleItem::new(label.clone()).label(label), + on, + ) + .size(ControlSize::Sm) + .on_toggle(move |_, _, cx| send(&this, "page.columns", &[&count.to_string()], cx)) + .into_any_element() + })) + } + + /// The current section's page style. + fn section(&self, cx: &Context) -> AnyElement { + let names = self + .shown + .as_ref() + .map(|shown| shown.pages.clone()) + .unwrap_or_default(); + let current = self + .state(".uno:PageStyleName", cx) + .filter(|name| known(name)); + let this = cx.entity(); + let empty = names.is_empty(); + let mut menu = Select::new( + "doc-props-section", + names + .into_iter() + .map(|name| Choice::new(name.clone(), name)), + ) + .size(ControlSize::Sm) + .disabled(empty) + .placeholder(current.clone().unwrap_or_default()) + .on_change(move |name, _, cx| { + send(&this, "format.style", &[name.as_ref(), "PageStyles"], cx) + }); + if let Some(name) = current { + menu = menu.selected(name); + } + menu.into_any_element() + } + + /// On or off, then first and odd pages. + fn part(&self, part: Part, cx: &Context) -> AnyElement { + let (on_command, on_uno) = part.on; + let (layout_command, layout_uno) = part.layout; + let on = self.state(on_uno, cx).as_deref() == Some("true"); + let switch = { + let this = cx.entity(); + let key = if on { "doc.props.on" } else { "doc.props.off" }; + let item = ToggleItem::new(on_command).label(Docs::text(cx, key, &[])); + ToggleButton::new(SharedString::from(on_command), item, on) + .size(ControlSize::Sm) + .on_toggle(move |turn_on, _, cx| { + send(&this, on_command, &[&turn_on.to_string()], cx) + }) + .into_any_element() + }; + let current = self + .state(layout_uno, cx) + .and_then(|state| state.parse::().ok()); + let face = |bit: i32, key: &'static str| { + let this = cx.entity(); + let mut item = ToggleItem::new(key).label(Docs::text(cx, key, &[])); + if current.is_none() { + item = item.tooltip(Docs::text(cx, part.why, &[])); + } + let value = current.unwrap_or(0); + let id = SharedString::from(format!("{layout_command}-{bit}")); + ToggleButton::new(id, item, value & bit != 0) + .disabled(current.is_none()) + .size(ControlSize::Sm) + .on_toggle(move |_, _, cx| { + send(&this, layout_command, &[&(value ^ bit).to_string()], cx) + }) + .into_any_element() + }; + row([ + switch, + face(1, "doc.props.first_page"), + face(2, "doc.props.odd_even"), + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_states_read_and_papers_match_either_way() { + assert_eq!(size_of("21590 x 27940"), Some((21590, 27940))); + assert_eq!(page_of("true,0,4"), Some((true, 0, 4))); + assert_eq!( + paper_of((29700, 21001)), + Some("A4"), + "landscape A4 after rounding" + ); + assert_eq!(paper_of((21590, 27940)), Some("Letter")); + assert_eq!(paper_of((10000, 10000)), None); + assert_eq!(size_of("garbage"), None); + } +} diff --git a/apps/ely-doc/src/props/pairs.rs b/apps/ely-doc/src/props/pairs.rs new file mode 100644 index 0000000..a10dcfc --- /dev/null +++ b/apps/ely-doc/src/props/pairs.rs @@ -0,0 +1,95 @@ +//! Two-value kernel states edited one side at a time. + +use ely_office::panel::Panel as _; +use gpui::{App, Context}; + +use super::Props; + +/// Pairs sent ahead of the kernel's echo. +pub(super) struct Sent { + /// The state when the first pair left. + before: Option, + pairs: Vec<(i32, i32)>, +} + +/// Whole twips in 1/100 mm, rounded as the kernel does. +pub(super) fn mm100_of_twips(twips: i32) -> i32 { + (twips * 127 + 36) / 72 +} + +/// A "first,second" state. +fn parse(text: &str) -> Option<(i32, i32)> { + let (first, second) = text.split_once(',')?; + Some((first.parse().ok()?, second.parse().ok()?)) +} + +impl Props { + /// Edits build on the last sent pair, else the kernel's. + pub(super) fn pair(&self, uno: &'static str, cx: &App) -> Option<(i32, i32)> { + match self.pending.get(uno) { + Some(sent) => sent.pairs.last().copied(), + None => self.state(uno, cx).as_deref().and_then(parse), + } + } + + /// Sets one side in twips; the other stays. + pub(super) fn set_pair( + &mut self, + (uno, command): (&'static str, &'static str), + second: bool, + twips: i32, + cx: &mut Context, + ) { + let Some((first, other)) = self.pair(uno, cx) else { + return; + }; + let value = mm100_of_twips(twips); + let next = if second { + (first, value) + } else { + (value, other) + }; + if next == (first, other) { + return; + } + let before = self.state(uno, cx).map(|state| state.to_string()); + self.pending + .entry(uno) + .or_insert(Sent { + before, + pairs: Vec::new(), + }) + .pairs + .push(next); + let values = [next.0.to_string(), next.1.to_string()]; + self.run(command, &[&values[0], &values[1]], cx); + } + + /// Drops sent pairs the kernel has answered. + pub(super) fn track_pairs(&mut self, cx: &App) { + let reported: Vec<(&'static str, Option)> = self + .pending + .keys() + .map(|&uno| (uno, self.state(uno, cx).map(|state| state.to_string()))) + .collect(); + for (uno, reported) in reported { + let sent = self.pending.get_mut(uno).expect("listed above"); + if reported == sent.before { + continue; + } + let at = reported + .as_deref() + .and_then(parse) + .and_then(|seen| sent.pairs.iter().position(|pair| *pair == seen)); + match at { + Some(at) if at + 1 < sent.pairs.len() => { + sent.pairs.drain(..=at); + sent.before = reported; + } + _ => { + self.pending.remove(uno); + } + } + } + } +} diff --git a/apps/ely-doc/src/props/paragraph.rs b/apps/ely-doc/src/props/paragraph.rs new file mode 100644 index 0000000..846cd36 --- /dev/null +++ b/apps/ely-doc/src/props/paragraph.rs @@ -0,0 +1,206 @@ +//! The paragraph group: alignment, spacing, lists, tab stops (DOC-03). + +use ely_gpui_component::{ + buttons::Button, data_display::PropertyGroup, forms::NumberInput, primitives::IconName, + theme::ControlSize, +}; +use ely_office::panel::{Panel as _, button, muted, row, send, toggle}; +use gpui::{AnyElement, Context, IntoElement, ParentElement as _, Styled as _, div}; + +use super::Props; +use crate::docs::Docs; + +/// The kernel's spacing state: "upper,lower" in 1/100 mm. +pub(super) const SPACING: &str = ".uno:ULSpacing"; + +/// Twips in one centimeter. +const TWIPS_PER_CM: f64 = 567.0; + +impl Props { + pub(super) fn paragraph_group(&self, cx: &mut Context) -> PropertyGroup { + let tip = |key: &str| Docs::text(cx, key, &[]); + let align = row([ + toggle( + self, + "paragraph.left", + (Some(IconName::AlignLeft), ""), + ("doc.props.left", "doc.why.selection"), + cx, + ), + toggle( + self, + "paragraph.center", + (Some(IconName::AlignCenter), ""), + ("doc.props.center", "doc.why.selection"), + cx, + ), + toggle( + self, + "paragraph.right", + (Some(IconName::AlignRight), ""), + ("doc.props.right", "doc.why.selection"), + cx, + ), + toggle( + self, + "paragraph.justify", + (Some(IconName::AlignJustify), ""), + ("doc.props.justify", "doc.why.selection"), + cx, + ), + ]); + let line = row([ + toggle( + self, + "paragraph.spacing_1", + (None, "1"), + ("", "doc.why.selection"), + cx, + ), + toggle( + self, + "paragraph.spacing_15", + (None, "1.5"), + ("", "doc.why.selection"), + cx, + ), + toggle( + self, + "paragraph.spacing_2", + (None, "2"), + ("", "doc.why.selection"), + cx, + ), + ]); + let lists = row([ + toggle( + self, + "list.bullets", + (Some(IconName::List), ""), + ("doc.props.bullets", "doc.why.selection"), + cx, + ), + toggle( + self, + "list.numbering", + (Some(IconName::ListOrdered), ""), + ("doc.props.numbering", "doc.why.selection"), + cx, + ), + button( + self, + "list.higher", + "doc.props.promote", + "doc.why.selection", + cx, + ), + button( + self, + "list.deeper", + "doc.props.demote", + "doc.why.selection", + cx, + ), + ]); + PropertyGroup::new(tip("doc.props.paragraph")) + .row(tip("doc.props.align"), align) + .row( + tip("doc.props.indent"), + row([ + button( + self, + "paragraph.outdent", + "doc.props.less", + "doc.why.selection", + cx, + ), + button( + self, + "paragraph.indent", + "doc.props.more", + "doc.why.selection", + cx, + ), + ]), + ) + .row(tip("doc.props.line"), line) + .row(tip("doc.props.above"), self.spacing(false, cx)) + .row(tip("doc.props.below"), self.spacing(true, cx)) + .row(tip("doc.props.lists"), lists) + .row(tip("doc.props.tabs"), self.tabs(cx)) + } + + /// Space above or below, in points. + fn spacing(&self, below: bool, cx: &Context) -> AnyElement { + if self.state(SPACING, cx).as_deref() == Some(ely_lok::MIXED) { + return muted(Docs::text(cx, "office.props.mixed", &[]), cx); + } + let Some((upper, lower)) = self.pair(SPACING, cx) else { + return muted("—".into(), cx); + }; + let this = cx.entity(); + let (id, mine) = match below { + true => ("doc-props-below", lower), + false => ("doc-props-above", upper), + }; + let field = NumberInput::new(id, f64::from(mine) * 72.0 / 2540.0) + .range(0.0, 283.0) + .step(1.0) + .precision(1) + .size(ControlSize::Sm) + .on_commit(move |points, _, cx| { + let twips = (points * 20.0).round() as i32; + this.update(cx, |props, cx| { + props.set_pair((SPACING, "paragraph.spacing"), below, twips, cx) + }) + }); + div().w_full().max_w_32().child(field).into_any_element() + } + + /// The paragraph's stops, each removable, and an adder in cm. + fn tabs(&self, cx: &Context) -> AnyElement { + let this = cx.entity(); + let stops = self + .shown + .as_ref() + .map(|shown| shown.view.read(cx).tabs().to_vec()) + .unwrap_or_default(); + let chips: Vec = stops + .into_iter() + .enumerate() + .map(|(at, mm100)| { + let this = this.clone(); + let cm = format!("{:.2}", f64::from(mm100) / 1000.0); + let label = Docs::text(cx, "doc.props.cm", &[("n", &cm)]); + Button::new(("doc-props-tab", at), label) + .size(ControlSize::Sm) + .trailing_icon(IconName::X) + .on_click(move |_, _, cx| { + send(&this, "paragraph.tab", &[&at.to_string(), "0", "true"], cx) + }) + .into_any_element() + }) + .collect(); + let at = NumberInput::new("doc-props-tab-at", self.tab_at) + .range(0.0, 55.0) + .step(0.25) + .precision(2) + .size(ControlSize::Sm) + .on_change({ + let this = this.clone(); + move |value, _, cx| this.update(cx, |props, _| props.tab_at = value) + }); + let add = Button::new( + "doc-props-tab-add", + Docs::text(cx, "doc.props.add_tab", &[]), + ) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + let twips = this.read(cx).tab_at * TWIPS_PER_CM; + let twips = (twips.round() as i64).to_string(); + send(&this, "paragraph.tab", &["-1", &twips, "false"], cx) + }); + let at = div().w_full().max_w_32().child(at).into_any_element(); + row(chips.into_iter().chain([at, add.into_any_element()])) + } +} diff --git a/apps/ely-doc/src/props/sheet.rs b/apps/ely-doc/src/props/sheet.rs new file mode 100644 index 0000000..69ff2ff --- /dev/null +++ b/apps/ely-doc/src/props/sheet.rs @@ -0,0 +1,131 @@ +//! The page as reported, and page edits not yet echoed. + +use ely_office::panel::Panel as _; +use gpui::{App, Context}; + +use super::Props; + +const SIZE: &str = ".uno:AttributePageSize"; +const PAGE: &str = ".uno:AttributePage"; + +/// The page: size in 1/100 mm, orientation, layout, number type. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Sheet { + pub(super) size: (i32, i32), + pub(super) landscape: bool, + pub(super) layout: i32, + pub(super) numtype: i32, +} + +impl Sheet { + /// The same page, within the kernel's twips rounding. + fn matches(&self, other: &Sheet) -> bool { + let near = |a: i32, b: i32| (a - b).abs() <= 2; + (self.landscape, self.layout, self.numtype) + == (other.landscape, other.layout, other.numtype) + && near(self.size.0, other.size.0) + && near(self.size.1, other.size.1) + } +} + +/// Page edits sent ahead of the kernel's echo. +pub(super) struct Sent { + before: (Option, Option), + sheets: Vec, +} + +/// A "width x height" state in 1/100 mm. +pub(super) fn size_of(text: &str) -> Option<(i32, i32)> { + let (width, height) = text.split_once(" x ")?; + Some((width.trim().parse().ok()?, height.trim().parse().ok()?)) +} + +/// The page item: landscape, layout, number type. +pub(super) fn page_of(text: &str) -> Option<(bool, i32, i32)> { + let mut parts = text.split(','); + let landscape = parts.next()?.parse().ok()?; + Some(( + landscape, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + )) +} + +impl Props { + fn reported(&self, cx: &App) -> (Option, Option) { + let read = |uno| self.state(uno, cx).map(|state| state.to_string()); + (read(SIZE), read(PAGE)) + } + + fn reported_sheet(&self, cx: &App) -> Option { + let (size, page) = self.reported(cx); + let (landscape, layout, numtype) = page_of(&page?)?; + Some(Sheet { + size: size_of(&size?)?, + landscape, + layout, + numtype, + }) + } + + /// Edits build on the last sent page, else the kernel's. + pub(super) fn sheet(&self, cx: &App) -> Option { + match &self.sheet_sent { + Some(sent) => sent.sheets.last().copied(), + None => self.reported_sheet(cx), + } + } + + /// Sends `next`; one command, so one undo step. + pub(super) fn send_sheet(&mut self, next: Sheet, cx: &mut Context) { + let Some(now) = self.sheet(cx) else { + return; + }; + if next == now { + return; + } + let before = self.reported(cx); + self.sheet_sent + .get_or_insert(Sent { + before, + sheets: Vec::new(), + }) + .sheets + .push(next); + let (w, h) = (next.size.0.to_string(), next.size.1.to_string()); + let item = (next.landscape, next.layout, next.numtype); + if item == (now.landscape, now.layout, now.numtype) { + return self.run("page.size", &[&w, &h], cx); + } + let page = [ + next.landscape.to_string(), + next.layout.to_string(), + next.numtype.to_string(), + ]; + self.run( + "page.orientation", + &[&w, &h, &page[0], &page[1], &page[2]], + cx, + ); + } + + /// Drops sent pages the kernel has answered. + pub(super) fn track_sheet(&mut self, cx: &App) { + let reported = self.reported(cx); + let seen = self.reported_sheet(cx); + let Some(sent) = &mut self.sheet_sent else { + return; + }; + if reported == sent.before { + return; + } + let at = seen.and_then(|seen| sent.sheets.iter().position(|sheet| sheet.matches(&seen))); + match at { + Some(at) if at + 1 < sent.sheets.len() => { + sent.sheets.drain(..=at); + sent.before = reported; + } + _ => self.sheet_sent = None, + } + } +} diff --git a/apps/ely-doc/src/props/styles.rs b/apps/ely-doc/src/props/styles.rs new file mode 100644 index 0000000..0fa1d35 --- /dev/null +++ b/apps/ely-doc/src/props/styles.rs @@ -0,0 +1,146 @@ +//! The style group: apply, create, update (DOC-04). + +use ely_gpui_component::primitives::Tooltip; +use ely_gpui_component::{ + buttons::Button, + data_display::PropertyGroup, + forms::{Choice, Select}, + theme::ControlSize, +}; +use ely_office::panel::{Panel as _, known, row, send}; +use gpui::{ + AnyElement, Context, InteractiveElement as _, IntoElement, ParentElement as _, SharedString, + StatefulInteractiveElement as _, div, +}; + +use super::Props; +use crate::docs::Docs; + +impl Props { + pub(super) fn style_group(&self, cx: &mut Context) -> PropertyGroup { + let tip = |key: &str| Docs::text(cx, key, &[]); + let (paragraphs, characters) = self + .shown + .as_ref() + .map(|shown| (shown.paragraph.clone(), shown.character.clone())) + .unwrap_or_default(); + let current = self.state(".uno:StyleApply", cx); + let paragraph = self.style_menu( + "doc-props-paragraph", + paragraphs, + current.clone(), + "ParagraphStyles", + cx, + ); + let character_style = self.state(".uno:CharStyle", cx); + let character = self.style_menu( + "doc-props-character", + characters, + character_style.clone(), + "CharacterStyles", + cx, + ); + PropertyGroup::new(tip("doc.props.style")) + .row( + tip("doc.props.paragraph"), + row([ + paragraph, + self.new_style("2", cx), + self.update_style("2", current, cx), + ]), + ) + .row( + tip("doc.props.character"), + row([ + character, + self.new_style("1", cx), + self.update_style("1", character_style, cx), + ]), + ) + } + + /// A style list; unlisted or mixed names show as placeholder. + fn style_menu( + &self, + id: &'static str, + names: Vec, + current: Option, + family: &'static str, + cx: &Context, + ) -> AnyElement { + let this = cx.entity(); + let empty = names.is_empty(); + let placeholder = match current.as_deref() { + Some(ely_lok::MIXED) => Docs::text(cx, "office.props.mixed", &[]), + Some(name) if known(name) => name.to_string().into(), + _ => Docs::text(cx, "doc.props.choose", &[]), + }; + let mut menu = Select::new( + id, + names + .into_iter() + .map(|name| Choice::new(name.clone(), name)), + ) + .size(ControlSize::Sm) + .disabled(empty) + .placeholder(placeholder) + .on_change(move |name, _, cx| { + this.update(cx, |props, cx| { + props.run("format.style", &[name.as_ref(), family], cx) + }) + }); + if let Some(name) = current.filter(|name| known(name)) { + menu = menu.selected(name); + } + menu.into_any_element() + } + + fn new_style(&self, family: &'static str, cx: &Context) -> AnyElement { + let this = cx.entity(); + Button::new( + SharedString::from(format!("doc-props-new-style-{family}")), + Docs::text(cx, "doc.props.new_style", &[]), + ) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + this.update(cx, |props, cx| { + props.naming = Some(family); + cx.notify(); + }) + }) + .into_any_element() + } + + fn update_style( + &self, + family: &'static str, + name: Option, + cx: &Context, + ) -> AnyElement { + let this = cx.entity(); + let id = format!("doc-props-update-style-{family}"); + let name = name.filter(|name| known(name)); + let button = Button::new( + SharedString::from(id.clone()), + Docs::text(cx, "doc.props.update_style", &[]), + ) + .size(ControlSize::Sm) + .disabled(name.is_none()) + .on_click({ + let name = name.clone(); + move |_, _, cx| { + if let Some(name) = &name { + send(&this, "style.update", &[name.as_ref(), family], cx); + } + } + }); + if name.is_some() { + return button.into_any_element(); + } + div() + .id(SharedString::from(format!("{id}-why"))) + .child(button) + .tooltip(Tooltip::text(Docs::text(cx, "doc.props.no_style", &[]))) + .into_any_element() + } +} diff --git a/apps/ely-doc/src/props/table.rs b/apps/ely-doc/src/props/table.rs new file mode 100644 index 0000000..b6c49f6 --- /dev/null +++ b/apps/ely-doc/src/props/table.rs @@ -0,0 +1,158 @@ +//! The table group: rows, columns, cells, borders, flow (DOC-07). + +use ely_gpui_component::{buttons::Button, data_display::PropertyGroup, theme::ControlSize}; +use ely_office::panel::{button, color_state, palette, row, send, toggle}; +use gpui::{AnyElement, Context, IntoElement, SharedString}; + +use super::Props; +use crate::docs::Docs; + +impl Props { + pub(super) fn table_group(&self, cx: &mut Context) -> PropertyGroup { + let tip = |key: &str| Docs::text(cx, key, &[]); + let why = "doc.why.selection"; + PropertyGroup::new(tip("doc.props.table")) + .row( + tip("doc.props.rows"), + row([ + button(self, "table.row_above", "doc.props.above_row", why, cx), + button(self, "table.row_below", "doc.props.below_row", why, cx), + button(self, "table.delete_rows", "doc.props.delete", why, cx), + ]), + ) + .row( + tip("doc.props.columns"), + row([ + button(self, "table.column_left", "doc.props.left_column", why, cx), + button( + self, + "table.column_right", + "doc.props.right_column", + why, + cx, + ), + button(self, "table.delete_columns", "doc.props.delete", why, cx), + ]), + ) + .row( + tip("doc.props.cells"), + row([ + button(self, "table.merge", "doc.props.merge", "doc.why.merge", cx), + self.steps( + "doc-props-split-columns", + "doc.props.split_columns", + &[("table.split", &["2", "false"])], + cx, + ), + self.steps( + "doc-props-split-rows", + "doc.props.split_rows", + &[("table.split", &["2", "true"])], + cx, + ), + ]), + ) + .row( + tip("doc.props.cell_size"), + row([ + self.steps( + "doc-props-even-columns", + "doc.props.even_columns", + &[("table.select", &[]), ("table.even_columns", &[])], + cx, + ), + self.steps( + "doc-props-even-rows", + "doc.props.even_rows", + &[("table.select", &[]), ("table.even_rows", &[])], + cx, + ), + self.steps( + "doc-props-fit-rows", + "doc.props.fit_rows", + &[("table.select", &[]), ("table.optimal_height", &[])], + cx, + ), + ]), + ) + .row( + tip("doc.props.borders"), + row([ + button(self, "table.borders_all", "doc.props.borders_all", why, cx), + button( + self, + "table.borders_none", + "doc.props.borders_none", + why, + cx, + ), + ]), + ) + .row( + tip("doc.props.shading"), + palette( + &cx.entity(), + "table.shading", + (color_state(self, ".uno:TableCellBackgroundColor", cx), "office.props.automatic"), + None, + cx, + ), + ) + .row( + tip("doc.props.heading"), + row([toggle( + self, + "table.repeat_heading", + (None, &tip("doc.props.repeat")), + ("", "doc.why.heading"), + cx, + )]), + ) + .row( + tip("doc.props.row_break"), + row([ + self.steps( + "doc-props-row-breaks", + "doc.props.may_break", + &[("table.row_split", &["true"])], + cx, + ), + self.steps( + "doc-props-row-whole", + "doc.props.keep_whole", + &[("table.row_split", &["false"])], + cx, + ), + ]), + ) + .row( + tip("doc.props.table"), + row([button( + self, + "table.delete", + "doc.props.delete_table", + why, + cx, + )]), + ) + } + + /// A button that runs `commands` in order. + fn steps( + &self, + id: &'static str, + key: &str, + commands: &'static [(&'static str, &'static [&'static str])], + cx: &Context, + ) -> AnyElement { + let this = cx.entity(); + Button::new(SharedString::from(id), Docs::text(cx, key, &[])) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + for (command, values) in commands { + send(&this, command, values, cx); + } + }) + .into_any_element() + } +} diff --git a/apps/ely-doc/src/props/tests.rs b/apps/ely-doc/src/props/tests.rs new file mode 100644 index 0000000..1511d3e --- /dev/null +++ b/apps/ely-doc/src/props/tests.rs @@ -0,0 +1,292 @@ +use std::{cell::RefCell, rc::Rc}; + +use ely_lok::LokError; +use ely_office::panel::Panel as _; +use gpui::{Entity, TestAppContext}; + +use super::Props; +use crate::docs::Docs; +use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written}; +use ely_office::prelude::*; + +/// The panel, shown on the active tab. +pub(super) fn panel(cx: &mut TestAppContext, docs: &Entity) -> Entity { + cx.update(|app| { + let tab = docs.read(app).active_tab().expect("a tab"); + let (view, doc) = (tab.view.clone(), tab.doc.clone()); + let props = docs.read(app).props.clone(); + props.update(app, |props, cx| props.show(Some((&view, &doc)), cx)); + props + }) +} + +fn run(cx: &mut TestAppContext, props: &Entity, id: &str, values: &[&str]) { + cx.update(|app| props.update(app, |props, cx| props.run(id, values, cx))); +} + +#[test] +fn the_panel_formats_text_paragraphs_and_styles() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "Ely panel"); + let props = panel(&mut cx, &docs); + cx.update(|app| { + let props = props.read(app); + let shown = props.shown.as_ref().expect("shown"); + assert!(!props.fonts.is_empty(), "the kernel lists fonts"); + assert!(shown.paragraph.iter().any(|name| name == "Heading 2")); + assert!(shown.character.iter().any(|name| name == "Emphasis")); + }); + for (command, values) in [ + ("edit.select_all", vec![]), + ("format.style", vec!["Heading 2", "ParagraphStyles"]), + ("format.color", vec!["16711680"]), + ("format.highlight", vec!["65535"]), + ("format.superscript", vec![]), + ("paragraph.tab", vec!["-1", "1134", "false"]), + ("list.bullets", vec![]), + ] { + run(&mut cx, &props, command, &values); + } + cx.update(|app| props.update(app, |props, cx| props.create_style("Ely Note", "2", cx))); + settle(&cx, |app| { + let props = props.read(app); + let shown = props.shown.as_ref().expect("shown"); + !props.reload && shown.paragraph.iter().any(|name| name == "Ely Note") + }); + run(&mut cx, &props, "paragraph.right", &[]); + run(&mut cx, &props, "style.update", &["Ely Note", "2"]); + run( + &mut cx, + &props, + "format.style", + &["Emphasis", "CharacterStyles"], + ); + settle(&cx, |app| { + let tab = docs.read(app).active_tab().expect("a tab"); + let view = tab.view.read(app); + view.settled() && view.tabs().len() == 1 && view.state(".uno:FontColor") == Some("16711680") + }); + // The shown stop, removed by its number. + cx.update(|app| ely_office::panel::send(&props, "paragraph.tab", &["0", "0", "true"], app)); + settle(&cx, |app| { + let view = docs.read(app).active_tab().expect("a tab").view.read(app); + view.settled() && view.tabs().is_empty() + }); + run(&mut cx, &props, "paragraph.tab", &["-1", "1134", "false"]); + let target = dir.path().join("panel.docx"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, target.clone(), None, window, cx) + }); + written(&cx, &target, None); + let text = text_of(&mut cx, &docs, &target); + for mark in [ + "fo:color=\"#ff0000\"", + "fo:background-color=\"#00ffff\"", + "style:text-position=\"super", + "style:tab-stop", + "").next()) + .expect("the style is written"); + assert!( + note.contains("fo:text-align=\"right\""), + "the update reached the style" + ); +} + +#[test] +fn bad_values_report_and_the_panel_follows_the_tab() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &docs); + let props = panel(&mut cx, &docs); + let errors = Rc::new(RefCell::new(Vec::new())); + cx.update(|app| { + let errors = errors.clone(); + app.subscribe(&props, move |_, error: &LokError, _| { + errors.borrow_mut().push(error.0.clone()) + }) + .detach() + }); + run(&mut cx, &props, "format.size", &["0"]); + assert_eq!(errors.borrow().len(), 1, "an out-of-range size reports"); + assert!(errors.borrow()[0].starts_with("format.size")); + let first = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.entity_id()); + blank(&mut cx, &docs); + let props = panel(&mut cx, &docs); + let second = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.entity_id()); + assert_ne!(first, second); + cx.update(|app| { + let shown = props + .read(app) + .shown + .as_ref() + .map(|shown| shown.view.entity_id()); + assert_eq!(shown, Some(second), "the panel moves to the new tab"); + props.update(app, |props, cx| props.show(None, cx)); + assert!(props.read(app).shown.is_none(), "no tab, no panel"); + }); +} + +/// Posts `keys` (LOK key codes) straight to the active document. +fn keys(cx: &mut TestAppContext, docs: &Entity, keys: &[i32]) { + cx.update(|app| { + let docs = docs.read(app); + let doc = docs.active_tab().expect("a tab").doc.clone(); + let mut worker = docs.core.worker.lock().expect("the worker lock"); + for &code in keys { + worker.post_key(&doc, 0, code).expect("the key posts"); + } + }); +} + +/// A text style's definition in a flat ODF file. +fn text_style<'a>(text: &'a str, name: &str) -> &'a str { + let head = format!("style:name=\"{name}\""); + text.split(head.as_str()) + .nth(1) + .and_then(|rest| rest.split("").next()) + .unwrap_or_else(|| panic!("{name} is defined")) +} + +#[test] +fn update_follows_the_character_style_under_the_cursor() { + const HOME: i32 = 1028; + const END: i32 = 1029; + const LEFT: i32 = 1026; + const RIGHT: i32 = 1027; + const SHIFT: i32 = 0x1000; + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "Alpha Beta"); + let props = panel(&mut cx, &docs); + run(&mut cx, &props, "edit.select_all", &[]); + run( + &mut cx, + &props, + "format.style", + &["Strong Emphasis", "CharacterStyles"], + ); + settled(&cx, &docs); + keys( + &mut cx, + &docs, + &[ + HOME, + RIGHT | SHIFT, + RIGHT | SHIFT, + RIGHT | SHIFT, + RIGHT | SHIFT, + RIGHT | SHIFT, + ], + ); + run( + &mut cx, + &props, + "format.style", + &["Emphasis", "CharacterStyles"], + ); + settled(&cx, &docs); + keys( + &mut cx, + &docs, + &[END, LEFT | SHIFT, LEFT | SHIFT, LEFT | SHIFT, LEFT | SHIFT], + ); + let current = |app: &gpui::App| props.read(app).state(".uno:CharStyle", app); + settle(&cx, |app| { + current(app).as_deref() == Some("Strong Emphasis") + }); + cx.update(|app| { + props.update(app, |props, cx| props.show(None, cx)); + assert_eq!(current(app), None, "a hidden panel shows nothing"); + }); + let props = panel(&mut cx, &docs); + let target = cx.update(|app| props.read(app).state(".uno:CharStyle", app)); + assert_eq!( + target.as_deref(), + Some("Strong Emphasis"), + "shown again, still synced" + ); + run(&mut cx, &props, "format.color", &["16711680"]); + let target = target.expect("a reported style"); + run(&mut cx, &props, "style.update", &[&target, "1"]); + settled(&cx, &docs); + run(&mut cx, &props, "edit.select_all", &[]); + settle(&cx, |app| current(app).as_deref() == Some(ely_lok::MIXED)); + let mixed = cx.update(|app| current(app)).expect("reported"); + assert!( + !ely_office::panel::known(&mixed), + "a mixed selection has no update target" + ); + let path = dir.path().join("styles.docx"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, path.clone(), None, window, cx) + }); + written(&cx, &path, None); + let text = text_of(&mut cx, &docs, &path); + assert!(text_style(&text, "Strong_20_Emphasis").contains("fo:color=\"#ff0000\"")); + assert!( + !text_style(&text, "Emphasis").contains("#ff0000"), + "Emphasis stays" + ); +} + +#[test] +fn quick_spacing_edits_keep_each_other() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "Spacing"); + let props = panel(&mut cx, &docs); + let spacing = |app: &gpui::App| props.read(app).state(super::paragraph::SPACING, app); + settle(&cx, |app| spacing(app).is_some()); + let set = |cx: &mut TestAppContext, below: bool, points: f64| { + let twips = (points * 20.0).round() as i32; + let pair = (super::paragraph::SPACING, "paragraph.spacing"); + cx.update(|app| props.update(app, |props, cx| props.set_pair(pair, below, twips, cx))) + }; + set(&mut cx, false, 6.0); + set(&mut cx, true, 10.0); + settle(&cx, |app| spacing(app).as_deref() == Some("212,353")); + set(&mut cx, false, 8.0); + set(&mut cx, true, 12.0); + settle(&cx, |app| spacing(app).as_deref() == Some("282,423")); + settled(&cx, &docs); + let path = dir.path().join("spacing.docx"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, path.clone(), None, window, cx) + }); + written(&cx, &path, None); + let text = text_of(&mut cx, &docs, &path); + assert!( + text.contains("fo:margin-top=\"0.111in\" fo:margin-bottom=\"0.1665in\""), + "both quick edits land" + ); +} diff --git a/apps/ely-doc/src/refs.rs b/apps/ely-doc/src/refs.rs new file mode 100644 index 0000000..c5c44fc --- /dev/null +++ b/apps/ely-doc/src/refs.rs @@ -0,0 +1,402 @@ +//! References, formulas and comments (DOC-09, 10, 11). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Choice, Input, Select, TextInput}, + overlays::{Dialog as Card, PromptDialog}, + primitives::Severity, +}; +use gpui::{ + App, AppContext as _, Context, Entity, MenuItem, ParentElement as _, SharedString, Styled as _, + Window, actions, div, +}; + +use crate::actions::Dialog; +use crate::docs::Docs; +use ely_office::prelude::*; + +actions!( + doc, + [ + InsertContents, + UpdateContents, + InsertFootnote, + InsertEndnote, + InsertBookmark, + InsertLink, + InsertReference, + InsertCaption, + InsertFormula, + InsertComment + ] +); + +/// What a one-line prompt asks for. +#[derive(Clone)] +pub enum Ask { + Bookmark(Vec), + /// The caption's category, by program name. + Caption(&'static str), + /// True when it replaces the selected formula. + Formula(bool), + Comment, +} + +/// The reference formats offered, by kernel number. +const SHOWN: [(&str, &str); 4] = [ + ("2", "doc.reference.text"), + ("0", "doc.reference.page"), + ("1", "doc.reference.chapter"), + ("3", "doc.reference.direction"), +]; + +/// The Insert menu's reference entries. +pub fn items(cx: &App) -> Vec { + let label = |key: &str| Docs::text(cx, key, &[]); + vec![ + MenuItem::separator(), + MenuItem::action(label("doc.insert.contents"), InsertContents), + MenuItem::action(label("doc.insert.update_contents"), UpdateContents), + MenuItem::action(label("doc.insert.footnote"), InsertFootnote), + MenuItem::action(label("doc.insert.endnote"), InsertEndnote), + MenuItem::action(label("doc.insert.bookmark"), InsertBookmark), + MenuItem::action(label("doc.insert.link"), InsertLink), + MenuItem::action(label("doc.insert.reference"), InsertReference), + MenuItem::action(label("doc.insert.caption"), InsertCaption), + MenuItem::separator(), + MenuItem::action(label("doc.insert.formula"), InsertFormula), + MenuItem::action(label("doc.insert.comment"), InsertComment), + ] +} + +impl Docs { + /// A kernel list for the active document; failures toast. + fn read(&self, command: &str, cx: &mut Context) -> Option { + let Some(tab) = self.active_tab() else { + let text = Docs::text(cx, "command.blocked.no_document", &[]); + self.toast(text, Severity::Warning, cx); + return None; + }; + let read = self + .core + .worker + .lock() + .expect("the worker lock") + .values(&tab.doc, command); + read.map_err(|error| { + let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); + self.toast(text, Severity::Danger, cx); + }) + .ok() + } + + fn bookmarks(&self, cx: &mut Context) -> Option> { + let listed = ely_lok::review::bookmarks(&self.read(".uno:Bookmarks", cx)?); + listed + .map(|marks| marks.into_iter().map(|mark| mark.name).collect()) + .map_err(|error| { + let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); + self.toast(text, Severity::Danger, cx); + }) + .ok() + } + + fn ask(&mut self, ask: Ask, text: &str, window: &mut Window, cx: &mut Context) { + let field = cx.new(|cx| TextInput::new(window, cx)); + field.update(cx, |field, cx| field.set_text(text, cx)); + self.dialog = Some(Dialog::Ask { ask, field }); + cx.notify(); + } + + pub fn on_contents(&mut self, _: &InsertContents, _: &mut Window, cx: &mut Context) { + let title = Docs::text(cx, "doc.contents.title", &[]); + self.run_active("index.insert", &[&title], cx); + } + + pub fn on_update(&mut self, _: &UpdateContents, _: &mut Window, cx: &mut Context) { + self.run_active("index.update", &[], cx); + } + + pub fn on_footnote(&mut self, _: &InsertFootnote, _: &mut Window, cx: &mut Context) { + self.run_active("note.footnote", &[], cx); + } + + pub fn on_endnote(&mut self, _: &InsertEndnote, _: &mut Window, cx: &mut Context) { + self.run_active("note.endnote", &[], cx); + } + + pub fn on_bookmark(&mut self, _: &InsertBookmark, window: &mut Window, cx: &mut Context) { + if let Some(taken) = self.bookmarks(cx) { + self.ask(Ask::Bookmark(taken), "", window, cx); + } + } + + pub fn on_link(&mut self, _: &InsertLink, window: &mut Window, cx: &mut Context) { + let text = cx.new(|cx| TextInput::new(window, cx)); + let url = cx.new(|cx| TextInput::new(window, cx)); + cx.observe(&url, |_, _, cx| cx.notify()).detach(); + self.dialog = Some(Dialog::Link { text, url }); + cx.notify(); + } + + pub fn on_reference(&mut self, _: &InsertReference, _: &mut Window, cx: &mut Context) { + let Some(marks) = self.bookmarks(cx) else { + return; + }; + let Some(mark) = marks.first().cloned() else { + let text = Docs::text(cx, "doc.reference.none", &[]); + return self.toast(text, Severity::Warning, cx); + }; + self.dialog = Some(Dialog::Reference { + marks, + mark, + show: "2", + }); + cx.notify(); + } + + pub fn on_caption(&mut self, _: &InsertCaption, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + let text = Docs::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + let view = tab.view.read(cx); + let category = if view.graphic().is_some() { + "Figure" + } else if view.state(".uno:DeleteTable") == Some("enabled") { + "Table" + } else { + let text = Docs::text(cx, "doc.caption.none", &[]); + return self.toast(text, Severity::Warning, cx); + }; + self.ask(Ask::Caption(category), "", window, cx); + } + + pub fn on_formula(&mut self, _: &InsertFormula, window: &mut Window, cx: &mut Context) { + let Some(json) = self.read(".uno:Formula", cx) else { + return; + }; + match ely_lok::review::formula(&json) { + Ok(Some(formula)) => self.ask(Ask::Formula(true), &formula, window, cx), + Ok(None) => self.ask(Ask::Formula(false), "", window, cx), + Err(error) => { + let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); + self.toast(text, Severity::Danger, cx); + } + } + } + + pub fn on_comment(&mut self, _: &InsertComment, window: &mut Window, cx: &mut Context) { + self.ask(Ask::Comment, "", window, cx); + } + + /// Sends the hyperlink dialog. + pub(crate) fn insert_link(&mut self, cx: &mut Context) { + let Some(Dialog::Link { text, url }) = self.dialog.take() else { + return; + }; + let (words, address) = ( + text.read(cx).text().trim().to_string(), + url.read(cx).text().trim().to_string(), + ); + // No text shows the address. + let shown = if words.is_empty() { &address } else { &words }; + self.run_active("link.insert", &[shown, &address], cx); + cx.notify(); + } + + /// Sends the cross-reference dialog. + pub(crate) fn insert_reference(&mut self, cx: &mut Context) { + let Some(Dialog::Reference { mark, show, .. }) = self.dialog.take() else { + return; + }; + self.run_active("reference.insert", &["13", "2", &mark, show], cx); + cx.notify(); + } + + /// Sends a prompt's answer and closes it. + pub(crate) fn answer(&mut self, ask: &Ask, text: &str, cx: &mut Context) { + self.dialog = None; + match ask { + Ask::Bookmark(_) => self.run_active("bookmark.insert", &[text], cx), + Ask::Caption(category) => self.run_active("caption.insert", &[category, text], cx), + Ask::Formula(_) => self.run_active("formula.insert", &[text], cx), + Ask::Comment => { + self.side = crate::docs::Side::Review; + self.run_active("comment.insert", &[text], cx); + } + } + cx.notify(); + } +} + +/// The one-line prompt for `ask`. +pub fn ask_dialog( + this: Entity, + ask: &Ask, + field: &Entity, + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> PromptDialog { + let text = |key: &str| Docs::text(cx, key, &[]); + let (title, label) = match ask { + Ask::Bookmark(_) => ("doc.bookmark.title", "doc.bookmark.name"), + Ask::Caption(_) => ("doc.caption.title", "doc.caption.text"), + Ask::Formula(true) => ("doc.formula.edit", "doc.formula.text"), + Ask::Formula(false) => ("doc.formula.title", "doc.formula.text"), + Ask::Comment => ("doc.comment.title", "doc.comment.text"), + }; + let submit = match ask { + Ask::Formula(true) => text("doc.formula.apply"), + _ => text("doc.insert.insert"), + }; + let reasons: Vec<(&str, SharedString)> = [ + "doc.bookmark.empty", + "doc.bookmark.taken", + "doc.caption.empty", + "doc.formula.empty", + "doc.comment.empty", + ] + .into_iter() + .map(|key| (key, text(key))) + .collect(); + let (asked, ask) = (ask.clone(), ask.clone()); + PromptDialog::new("doc-ask", text(title), field, dismiss) + .label(text(label)) + .submit(submit) + .check(move |value| { + refuse(&asked, value.trim()).map_err(|key| { + let (_, why) = reasons + .iter() + .find(|(known, _)| *known == key) + .expect("a reason"); + why.clone() + }) + }) + .on_submit(move |value, _, cx| { + let value = value.trim().to_string(); + this.update(cx, |docs, cx| docs.answer(&ask, &value, cx)) + }) +} + +/// Why a prompt's answer cannot go, by message key. +fn refuse(ask: &Ask, value: &str) -> Result<(), &'static str> { + if let Ask::Bookmark(taken) = ask + && taken.iter().any(|name| name == value) + { + return Err("doc.bookmark.taken"); + } + if !value.is_empty() { + return Ok(()); + } + Err(match ask { + Ask::Bookmark(_) => "doc.bookmark.empty", + Ask::Caption(_) => "doc.caption.empty", + Ask::Formula(_) => "doc.formula.empty", + Ask::Comment => "doc.comment.empty", + }) +} + +/// The hyperlink dialog: text and address. +pub fn link_dialog( + this: Entity, + (text, url): (&Entity, &Entity), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let fields = DescriptionList::new() + .item( + Docs::text(cx, "doc.link.text", &[]), + div().w_full().max_w_64().child(Input::new(text)), + ) + .item( + Docs::text(cx, "doc.link.url", &[]), + div().w_full().max_w_64().child(Input::new(url)), + ); + let label = Docs::text(cx, "doc.insert.insert", &[]); + let empty = url.read(cx).text().trim().is_empty(); + Card::new( + "doc-insert-link", + Docs::text(cx, "doc.link.title", &[]), + dismiss, + ) + .child(fields) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("doc-insert-link-go", label) + .variant(ButtonVariant::Primary) + .disabled(empty) + .on_click(move |_, _, cx| this.update(cx, |docs, cx| docs.insert_link(cx))) + }) +} + +/// The cross-reference dialog: which bookmark, shown how. +pub fn reference_dialog( + this: Entity, + (marks, mark, show): (&[String], &str, &'static str), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let pick = { + let this = this.clone(); + let choices = marks + .iter() + .map(|name| Choice::new(name.clone(), name.clone())); + Select::new("doc-reference-mark", choices) + .selected(mark.to_string()) + .on_change(move |name, _, cx| { + this.update(cx, |docs, cx| { + if let Some(Dialog::Reference { mark, .. }) = &mut docs.dialog { + *mark = name.to_string(); + } + cx.notify(); + }) + }) + }; + let how = { + let this = this.clone(); + let choices = SHOWN + .iter() + .map(|(value, key)| Choice::new(*value, Docs::text(cx, key, &[]))); + Select::new("doc-reference-show", choices) + .selected(show) + .on_change(move |value, _, cx| { + let Some((value, _)) = SHOWN.iter().find(|(shown, _)| *shown == value.as_ref()) + else { + return; + }; + this.update(cx, |docs, cx| { + if let Some(Dialog::Reference { show, .. }) = &mut docs.dialog { + *show = value; + } + cx.notify(); + }) + }) + }; + let fields = DescriptionList::new() + .item( + Docs::text(cx, "doc.reference.target", &[]), + div().w_full().max_w_64().child(pick), + ) + .item( + Docs::text(cx, "doc.reference.show", &[]), + div().w_full().max_w_64().child(how), + ); + let label = Docs::text(cx, "doc.insert.insert", &[]); + Card::new( + "doc-insert-reference", + Docs::text(cx, "doc.reference.title", &[]), + dismiss, + ) + .child(fields) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("doc-insert-reference-go", label) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |docs, cx| docs.insert_reference(cx))) + }) +} + +#[cfg(test)] +mod tests; diff --git a/apps/ely-doc/src/refs/tests.rs b/apps/ely-doc/src/refs/tests.rs new file mode 100644 index 0000000..4755195 --- /dev/null +++ b/apps/ely-doc/src/refs/tests.rs @@ -0,0 +1,190 @@ +use gpui::{Entity, TestAppContext}; + +use super::*; +use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written}; + +/// The open prompt's request and field. +fn asked(cx: &TestAppContext, docs: &Entity) -> (Ask, Entity) { + cx.update(|app| match &docs.read(app).dialog { + Some(Dialog::Ask { ask, field }) => (ask.clone(), field.clone()), + _ => panic!("a prompt is open"), + }) +} + +fn answer(cx: &mut TestAppContext, docs: &Entity, text: &str) { + let (ask, _) = asked(cx, docs); + cx.update(|app| docs.update(app, |docs, cx| docs.answer(&ask, text, cx))); + settled(cx, docs); +} + +fn graphic(app: &gpui::App, docs: &Entity) -> bool { + let tab = docs.read(app).active_tab().expect("a tab"); + tab.view.read(app).graphic().is_some() +} + +#[test] +fn references_go_in_through_their_dialogs() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "Start"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_reference(&InsertReference, window, cx) + }); + assert!( + cx.update(|app| docs.read(app).dialog.is_none()), + "no bookmark, no cross-reference dialog" + ); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_bookmark(&InsertBookmark, window, cx) + }); + answer(&mut cx, &docs, "Here"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_bookmark(&InsertBookmark, window, cx) + }); + let (ask, _) = asked(&cx, &docs); + assert!( + matches!(&ask, Ask::Bookmark(taken) if taken == &["Here"]), + "the prompt knows the taken names" + ); + cx.update(|app| docs.update(app, |docs, _| docs.dialog = None)); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_reference(&InsertReference, window, cx) + }); + cx.update(|app| docs.update(app, |docs, cx| docs.insert_reference(cx))); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_link(&InsertLink, window, cx) + }); + cx.update(|app| { + docs.update(app, |docs, cx| { + let Some(Dialog::Link { url, .. }) = &docs.dialog else { + panic!("the link dialog is open"); + }; + url.update(cx, |url, cx| url.set_text("https://example.org/", cx)); + docs.insert_link(cx); + }) + }); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_footnote(&InsertFootnote, window, cx); + }); + type_into(&mut cx, &docs, "Foot"); + cx.update(|app| { + let view = docs.read(app).active_tab().expect("a tab").view.clone(); + view.update(app, |view, cx| view.reveal((1500, 1500, 0, 0), cx)); + }); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_contents(&InsertContents, window, cx); + docs.on_update(&UpdateContents, window, cx); + }); + settled(&cx, &docs); + let path = dir.path().join("refs.odt"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, path.clone(), None, window, cx) + }); + written(&cx, &path, None); + let text = text_of(&mut cx, &docs, &path); + for mark in [ + "https://example.org/<", + " Menu { + let label = |key: &str| Docs::text(cx, key, &[]); + Menu::new(label("menu.review")).items([ + MenuItem::action(label("doc.insert.comment"), crate::refs::InsertComment), + MenuItem::action(label("doc.review.panel"), ShowReview), + MenuItem::separator(), + MenuItem::action(label("doc.review.track"), TrackChanges), + MenuItem::action(label("doc.review.accept_all"), AcceptAll), + MenuItem::action(label("doc.review.reject_all"), RejectAll), + MenuItem::separator(), + MenuItem::action(label("doc.compare.open"), crate::compare::CompareWith), + ]) +} + +impl Docs { + pub fn on_track(&mut self, _: &TrackChanges, _: &mut Window, cx: &mut Context) { + self.run_active("track.toggle", &[], cx); + } + + pub fn on_accept_all(&mut self, _: &AcceptAll, _: &mut Window, cx: &mut Context) { + self.run_active("track.accept_all", &[], cx); + } + + pub fn on_reject_all(&mut self, _: &RejectAll, _: &mut Window, cx: &mut Context) { + self.run_active("track.reject_all", &[], cx); + } + + pub fn on_show_review(&mut self, _: &ShowReview, _: &mut Window, cx: &mut Context) { + self.side = match self.side { + Side::Review => Side::Props, + _ => Side::Review, + }; + cx.notify(); + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Pane { + Comments, + Changes, +} + +struct Shown { + view: Entity, + doc: Doc, + /// The view's review count at the last read. + read: Option, + /// A read is on its way. + reading: bool, + _observe: Subscription, +} + +pub struct Review { + worker: Arc>, + shown: Option, + notes: Vec, + threads: Vec, + pub(crate) changes: Vec, + active: Option, + pane: Pane, +} + +impl EventEmitter for Review {} + +impl Review { + pub fn new(worker: Arc>) -> Self { + Review { + worker, + shown: None, + notes: Vec::new(), + threads: Vec::new(), + changes: Vec::new(), + active: None, + pane: Pane::Comments, + } + } + + /// Follows the active tab; reads the lists when they change. + pub fn show(&mut self, tab: Option<(&Entity, &Doc)>, cx: &mut Context) { + let id = tab.map(|(view, _)| view.entity_id()); + if self.shown.as_ref().map(|shown| shown.view.entity_id()) == id { + return; + } + // Another document's lists must not stay actionable. + self.shown = None; + self.active = None; + self.notes.clear(); + self.threads.clear(); + self.changes.clear(); + cx.notify(); + let Some((view, doc)) = tab else { + return; + }; + let observe = cx.subscribe(view, |review, _, _: &Reported, cx| review.refresh(cx)); + self.shown = Some(Shown { + view: view.clone(), + doc: doc.clone(), + read: None, + reading: false, + _observe: observe, + }); + self.refresh(cx); + } + + /// Rereads the lists after review events. + fn refresh(&mut self, cx: &mut Context) { + let Some(shown) = self.shown.as_mut() else { + return; + }; + let count = shown.view.read(cx).review(); + if shown.reading || shown.read == Some(count) { + return; + } + (shown.read, shown.reading) = (Some(count), true); + let reader = shown.view.entity_id(); + ely_lok::read_off( + &self.worker, + &shown.doc, + |worker, doc| { + let json = worker.values(doc, ".uno:ViewAnnotations")?; + let notes = ely_lok::review::notes(&json)?; + let json = worker.values(doc, ".uno:AcceptTrackedChanges")?; + Ok((notes, ely_lok::review::changes(&json)?)) + }, + move |review: &mut Self, read, cx| { + let Some(shown) = review.shown.as_mut() else { + return; + }; + if shown.view.entity_id() != reader { + return; + } + shown.reading = false; + let read = read.and_then(|(notes, changes)| { + let threads = threads(¬es).map_err(std::io::Error::other)?; + Ok((notes, threads, changes)) + }); + match read { + Ok((notes, threads, changes)) => { + log::info!( + "doc: review lists {} notes, {} changes", + notes.len(), + changes.len() + ); + review.notes = notes; + review.threads = threads; + review.changes = changes; + } + Err(error) => cx.emit(LokError(format!( + "the review lists are unreadable: {error}" + ))), + } + cx.notify(); + review.refresh(cx); + }, + cx, + ); + } + + fn run(&mut self, id: &str, values: &[&str], cx: &mut Context) { + let Some(shown) = &self.shown else { + return; + }; + log::info!("doc: review runs {id}"); + if let Err(error) = shown.view.update(cx, |view, _| view.run(id, values)) { + cx.emit(LokError(format!("{id}: {error}"))); + } + } + + fn reveal(&mut self, anchor: ely_lok::Rect, cx: &mut Context) { + if let Some(shown) = &self.shown { + shown.view.update(cx, |view, cx| view.reveal(anchor, cx)); + } + } + + /// Picks a thread and goes to its words. + fn select(&mut self, key: &SharedString, cx: &mut Context) { + self.active = Some(key.clone()); + if let Some(note) = self.notes.iter().find(|note| note.id == key.as_ref()) { + self.reveal(note.anchor, cx); + } + cx.notify(); + } + + /// Answers the picked thread. + fn reply(&mut self, text: &str, cx: &mut Context) { + if let Some(key) = self.active.clone() { + self.run("comment.reply", &[&key, text], cx); + } + } + + fn changes_list(&self, cx: &mut Context) -> impl IntoElement { + if self.changes.is_empty() { + let none = Docs::text(cx, "doc.review.none", &[]); + return EmptyState::new("doc-review-none", IconName::Check, none).into_any_element(); + } + let this = cx.entity(); + let (accept, reject) = ( + Docs::text(cx, "doc.review.accept", &[]), + Docs::text(cx, "doc.review.reject", &[]), + ); + let rows = self.changes.iter().map(|change| { + let index = change.index.to_string(); + let decide = |id: &'static str, icon, tip: &SharedString| { + let (this, index) = (this.clone(), index.clone()); + IconButton::new((id, change.index as usize), icon) + .size(ControlSize::Sm) + .variant(ButtonVariant::Ghost) + .tooltip(tip.clone()) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + this.update(cx, |review, cx| review.run(id, &[&index], cx)) + }) + }; + let actions = div() + .flex() + .gap_1() + .child(decide("track.accept", IconName::Check, &accept)) + .child(decide("track.reject", IconName::X, &reject)); + let mut item = ListItem::new( + ("doc-review-change", change.index as usize), + change.description.clone(), + ) + .description(change.author.clone()) + .trailing(actions); + if let Some(anchor) = change.anchor { + let this = this.clone(); + item = item.on_click(move |_, _, cx| { + this.update(cx, |review, cx| review.reveal(anchor, cx)) + }); + } + item + }); + List::new().divided().children(rows).into_any_element() + } +} + +impl Render for Review { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let this = cx.entity(); + let tracking = self.shown.as_ref().and_then(|shown| { + shown + .view + .read(cx) + .state(".uno:TrackChanges") + .map(|on| on == "true") + }); + let all = |id: &'static str, key: &str| { + let this = this.clone(); + Button::new(id, Docs::text(cx, key, &[])) + .size(ControlSize::Sm) + .variant(ButtonVariant::Ghost) + .disabled(self.changes.is_empty()) + .on_click(move |_, _, cx| this.update(cx, |review, cx| review.run(id, &[], cx))) + }; + let head = div() + .flex() + .flex_wrap() + .items_center() + .gap_2() + .child( + Switch::new("doc-review-track", tracking == Some(true)) + .disabled(tracking.is_none()) + .label(Docs::text(cx, "doc.review.track", &[])) + .on_change({ + let this = this.clone(); + move |_, _, cx| { + this.update(cx, |review, cx| review.run("track.toggle", &[], cx)) + } + }), + ) + .child(all("track.accept_all", "doc.review.accept_all")) + .child(all("track.reject_all", "doc.review.reject_all")); + let roots = self.threads.len().to_string(); + let pane = SegmentedControl::new( + "doc-review-pane", + match self.pane { + Pane::Comments => "comments", + Pane::Changes => "changes", + }, + ) + .size(ControlSize::Sm) + .segment( + "comments", + Docs::text(cx, "doc.review.comments", &[("n", &roots)]), + None, + ) + .segment( + "changes", + Docs::text( + cx, + "doc.review.changes", + &[("n", &self.changes.len().to_string())], + ), + None, + ) + .on_change({ + let this = this.clone(); + move |value, _, cx| { + this.update(cx, |review, cx| { + review.pane = match value.as_ref() { + "changes" => Pane::Changes, + _ => Pane::Comments, + }; + cx.notify(); + }) + } + }); + let body = match self.pane { + Pane::Changes => ScrollArea::new("doc-review-scroll").vertical() + .size_full() + .child(self.changes_list(cx)) + .into_any_element(), + Pane::Comments => { + let field = window.use_keyed_state("doc-review-reply", cx, |window, cx| { + TextInput::new(window, cx) + }); + let (pick, send) = (this.clone(), this.clone()); + CommentSidebar::new("doc-review-comments", self.threads.clone()) + .active(self.active.clone(), move |key, _, cx| { + pick.update(cx, |review, cx| review.select(key, cx)) + }) + .reply(&field.clone(), move |_, cx| { + let text = field.read(cx).text().trim().to_string(); + send.update(cx, |review, cx| review.reply(&text, cx)); + field.update(cx, |field, cx| field.set_text("", cx)); + }) + .into_any_element() + } + }; + div() + .size_full() + .flex() + .flex_col() + .gap_2() + .p_2() + .child(head) + .child(pane) + .child(div().flex_1().min_h_0().child(body)) + } +} + +/// The notes as threads under their first comment. +fn threads(notes: &[Note]) -> Result, String> { + let parents: HashMap<&str, &str> = notes + .iter() + .map(|note| (note.id.as_str(), note.parent.as_str())) + .collect(); + let root = |id: &'_ str| -> String { + let mut id = id; + while let Some(parent) = parents.get(id).filter(|parent| **parent != "0") { + id = parent; + } + id.to_string() + }; + let authors: Vec<&str> = notes.iter().map(|note| note.author.as_str()).collect(); + let mut threads: Vec = Vec::new(); + for note in notes { + let at = note + .at + .parse::() + .and_then(|at| at.to_zoned(jiff::tz::TimeZone::system())) + .map_err(|error| format!("a bad comment time {}: {error}", note.at))?; + let hue = authors + .iter() + .position(|name| *name == note.author) + .expect("the author is listed"); + let comment = Comment { + author: Peer::new(note.author.clone(), note.author.clone(), hue), + at: at.timestamp(), + body: note.text.clone().into(), + reactions: Vec::new(), + }; + let key: SharedString = root(¬e.id).into(); + match threads.iter_mut().find(|thread| thread.key == key) { + Some(thread) => thread.comments.push(comment), + None => threads.push(Thread { + key, + quote: None, + comments: vec![comment], + resolved: note.resolved, + }), + } + } + Ok(threads) +} + +#[cfg(test)] +mod tests; diff --git a/apps/ely-doc/src/review/tests.rs b/apps/ely-doc/src/review/tests.rs new file mode 100644 index 0000000..48266e1 --- /dev/null +++ b/apps/ely-doc/src/review/tests.rs @@ -0,0 +1,84 @@ +use gpui::{Entity, TestAppContext}; + +use super::*; +use crate::actions::Dialog; +use crate::refs::InsertComment; +use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written}; +use ely_office::prelude::*; + +/// The review panel on the active tab. +fn panel(cx: &mut TestAppContext, docs: &Entity) -> Entity { + cx.update(|app| { + let tab = docs.read(app).active_tab().expect("a tab"); + let (view, doc) = (tab.view.clone(), tab.doc.clone()); + let review = docs.read(app).review.clone(); + review.update(app, |review, cx| review.show(Some((&view, &doc)), cx)); + review + }) +} + +#[test] +fn comments_thread_and_changes_accept_or_reject() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "Hello"); + let review = panel(&mut cx, &docs); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_comment(&InsertComment, window, cx); + let Some(Dialog::Ask { ask, .. }) = &docs.dialog else { + panic!("the comment prompt is open"); + }; + let ask = ask.clone(); + docs.answer(&ask, "First note", cx); + }); + assert_eq!( + cx.update(|app| docs.read(app).side), + Side::Review, + "the panel shows" + ); + settle(&cx, |app| review.read(app).threads.len() == 1); + let key = cx.update(|app| review.read(app).threads[0].key.clone()); + cx.update(|app| review.update(app, |review, cx| review.select(&key, cx))); + cx.update(|app| review.update(app, |review, cx| review.reply("An answer", cx))); + settle(&cx, |app| { + review + .read(app) + .threads + .first() + .is_some_and(|thread| thread.comments.len() == 2) + }); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_track(&TrackChanges, window, cx) + }); + settle(&cx, |app| { + let tab = docs.read(app).active_tab().expect("a tab"); + tab.view.read(app).state(".uno:TrackChanges") == Some("true") + }); + type_into(&mut cx, &docs, " kept"); + settle(&cx, |app| review.read(app).changes.len() == 1); + let index = cx.update(|app| review.read(app).changes[0].index.to_string()); + cx.update(|app| review.update(app, |review, cx| review.run("track.accept", &[&index], cx))); + settle(&cx, |app| review.read(app).changes.is_empty()); + type_into(&mut cx, &docs, " gone"); + settle(&cx, |app| review.read(app).changes.len() == 1); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_reject_all(&RejectAll, window, cx) + }); + settle(&cx, |app| review.read(app).changes.is_empty()); + settled(&cx, &docs); + let path = dir.path().join("review.odt"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, path.clone(), None, window, cx) + }); + written(&cx, &path, None); + let text = text_of(&mut cx, &docs, &path); + for mark in ["First note", "An answer", " kept"] { + assert!(text.contains(mark), "{mark} lands"); + } + assert!(!text.contains("gone"), "the rejected words go"); +} diff --git a/apps/ely-doc/src/spell.rs b/apps/ely-doc/src/spell.rs new file mode 100644 index 0000000..1d26c88 --- /dev/null +++ b/apps/ely-doc/src/spell.rs @@ -0,0 +1,85 @@ +//! Spelling suggestions from the kernel's context menu (DOC-12). + +use ely_gpui_component::{ + menus::{ContextMenu, Menu, MenuItem}, + primitives::Severity, +}; +use ely_lok::LokView; +use gpui::{Context, Entity, IntoElement, Pixels, Point}; + +use crate::docs::Docs; +use ely_office::prelude::*; + +/// A misspelled word's menu, asked for at a point. +pub struct Offer { + tab: u64, + pub(crate) suggestions: Vec<(String, String)>, + /// Bumps for each menu, so each opens once. + request: (u64, Point), +} + +impl Docs { + /// Turns the kernel's context menu into a spelling offer. + pub(crate) fn offer_spelling( + &mut self, + id: u64, + view: &Entity, + cx: &mut Context, + ) { + let Some(json) = view.update(cx, |view, _| view.take_menu()) else { + return; + }; + let spelling = match ely_lok::review::spelling(&json) { + Ok(spelling) => spelling, + Err(error) => { + let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); + return self.toast(text, Severity::Danger, cx); + } + }; + let Some(spelling) = spelling else { + log::info!("doc: the kernel menu has no spelling"); + return; + }; + let Some(caret) = view.read(cx).cursor_bounds() else { + log::warn!("doc: a spelling menu without a caret"); + return; + }; + let number = self.offer.as_ref().map_or(1, |offer| offer.request.0 + 1); + log::info!("doc: {} spelling suggestions", spelling.suggestions.len()); + self.offer = Some(Offer { + tab: id, + suggestions: spelling.suggestions, + request: (number, caret.bottom_left()), + }); + cx.notify(); + } + + /// The spelling menu's host, for the active tab. + pub(crate) fn spelling_menu(&self, cx: &mut Context) -> Option { + let offer = self + .offer + .as_ref() + .filter(|offer| Some(offer.tab) == self.core.active)?; + let this = cx.entity(); + let mut menu = Menu::new(); + for (label, rule) in &offer.suggestions { + let (this, rule) = (this.clone(), rule.clone()); + menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| { + this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx)) + })); + } + if offer.suggestions.is_empty() { + let none = Docs::text(cx, "doc.spell.none", &[]); + menu = menu.item(MenuItem::new(none).disabled(true)); + } + let ignore = Docs::text(cx, "doc.spell.ignore_all", &[]); + let menu = menu + .separator() + .item(MenuItem::new(ignore).on_click(move |_, cx| { + this.update(cx, |docs, cx| { + docs.run_active("spell.ignore_all", &["Spelling"], cx) + }) + })); + Some(ContextMenu::new("doc-spelling", menu).manual(Some(offer.request))) + } +} diff --git a/apps/ely-doc/src/tests.rs b/apps/ely-doc/src/tests.rs new file mode 100644 index 0000000..808fc3d --- /dev/null +++ b/apps/ely-doc/src/tests.rs @@ -0,0 +1,465 @@ +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; + +use gpui::{AppContext as _, Entity, TestAppContext, Window}; + +use crate::docs::Docs; +use crate::files::Template; +use ely_office::prelude::*; +use ely_office::{After, Dialog}; + +struct Blank; + +impl gpui::Render for Blank { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl gpui::IntoElement { + gpui::Empty + } +} + +/// A Docs over a fresh worker, if LOK exists. +pub(crate) fn docs(cx: &mut TestAppContext, dir: &Path) -> Option> { + // A failing test prints its log. + let _ = env_logger::builder() + .is_test(true) + .filter_level(log::LevelFilter::Info) + .try_init(); + let install = ely_lok::install_dir().ok()?; + let worker = ely_lok::Worker::spawn(&install, &dir.join("profile")).expect("the worker"); + let work = dir.join("work"); + std::fs::create_dir_all(&work).expect("the work dir"); + cx.update(|app| { + ely_ui::settings::install_defaults(app); + ely_ui::i18n::install(app); + let worker = Arc::new(Mutex::new(worker)); + Some(app.new(|cx| Docs::new(worker, work, cx))) + }) +} + +/// Steps the scheduler until `done` or ten seconds pass. +#[track_caller] +pub(crate) fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline { + if !cx.executor().tick() { + cx.executor() + .advance_clock(std::time::Duration::from_millis(20)); + } + if cx.update(|app| done(app)) { + return; + } + } + panic!("the state never settled"); +} + +/// Waits until `path` holds bytes other than `before`. +#[track_caller] +pub(crate) fn written(cx: &TestAppContext, path: &Path, before: Option<&[u8]>) { + settle(cx, |_| { + std::fs::read(path).is_ok_and(|bytes| Some(bytes.as_slice()) != before) + }); +} + +/// Runs `call` with a window, as actions get one. +pub(crate) fn with_window( + cx: &mut TestAppContext, + docs: &Entity, + call: impl FnOnce(&mut Docs, &mut Window, &mut gpui::Context), +) { + let window = cx.add_window(|_, _| Blank); + cx.update_window(window.into(), |_, window, app| { + docs.update(app, |docs, cx| { + docs.core.window = Some(window.window_handle()); + call(docs, window, cx) + }) + }) + .expect("the window lives"); +} + +pub(crate) fn type_into(cx: &mut TestAppContext, docs: &Entity, text: &str) { + let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone()); + let window = cx.add_window(|_, _| Blank); + cx.update_window(window.into(), |_, window, app| { + let mut ime = gpui::ElementInputHandler::new(gpui::Bounds::default(), view.clone()); + gpui::InputHandler::replace_text_in_range(&mut ime, None, text, window, app); + }) + .expect("the window lives"); + settle(cx, |app| { + docs.read(app) + .active_tab() + .is_some_and(|tab| tab.dirty(app) && tab.view.read(app).settled()) + }); +} + +#[test] +fn a_new_document_saves_as_docx_and_comes_back_clean() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + with_window(&mut cx, &docs, |docs, _, cx| { + docs.new_from(Template::Report, cx) + }); + settle(&cx, |app| docs.read(app).core.active.is_some()); + let (id, dirty) = cx.update(|app| { + let tab = docs.read(app).active_tab().expect("a tab"); + (tab.id, tab.dirty(app)) + }); + assert!(!dirty, "a fresh template is clean"); + type_into(&mut cx, &docs, "Ely"); + let target = dir.path().join("Report.docx"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, target.clone(), None, window, cx) + }); + written(&cx, &target, None); + let bytes = std::fs::read(&target).expect("the docx exists"); + assert!(bytes.starts_with(b"PK"), "a docx zip"); + settle(&cx, |app| { + docs.read(app) + .active_tab() + .is_some_and(|tab| !tab.dirty(app)) + }); + cx.update(|app| { + let tab = docs.read(app).active_tab().expect("a tab"); + assert_eq!(tab.name.as_ref(), "Report.docx"); + assert!(tab.registry.is_some(), "the saved file joins the registry"); + }); +} + +#[test] +fn a_save_keeps_the_format_and_save_as_converts_to_a_new_file() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let odt = dir.path().join("Notes.odt"); + with_window(&mut cx, &docs, |docs, _, cx| { + docs.new_from(Template::Blank, cx) + }); + settle(&cx, |app| docs.read(app).core.active.is_some()); + let id = cx.update(|app| docs.read(app).core.active.expect("a tab")); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, odt.clone(), None, window, cx) + }); + written(&cx, &odt, None); + type_into(&mut cx, &docs, "more"); + let first = std::fs::read(&odt).expect("the first odt"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.save(id, None, window, cx) + }); + written(&cx, &odt, Some(&first)); + let odf = std::fs::read(&odt).expect("the odt exists"); + let mime = b"application/vnd.oasis.opendocument.text"; + assert!( + odf.windows(mime.len()).any(|w| w == mime), + "the save kept ODT" + ); + let docx = dir.path().join("Notes.docx"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, docx.clone(), None, window, cx) + }); + written(&cx, &docx, None); + assert!(std::fs::read(&docx).expect("the docx").starts_with(b"PK")); + assert_eq!( + std::fs::read(&odt).expect("the odt stays"), + odf, + "the source is untouched" + ); + let pdf = dir.path().join("Notes.pdf"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, pdf.clone(), None, window, cx) + }); + assert!(!pdf.exists(), "Save As writes only DOCX or ODT"); +} + +#[test] +fn a_local_template_opens_untitled_and_stays_untouched() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let template = dir.path().join("memo.fodt"); + let fodt = cx.update(|app| Template::Letter.fodt(app)); + std::fs::write(&template, &fodt).expect("the template writes"); + with_window(&mut cx, &docs, |docs, _, cx| { + docs.new_from_file(&template, cx) + }); + settle(&cx, |app| docs.read(app).core.active.is_some()); + type_into(&mut cx, &docs, "x"); + let id = cx.update(|app| { + let tab = docs.read(app).active_tab().expect("a tab"); + assert!(tab.session.path().is_none(), "a template opens untitled"); + tab.id + }); + let out = dir.path().join("memo.docx"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, out.clone(), None, window, cx) + }); + written(&cx, &out, None); + assert!(out.exists()); + assert_eq!( + std::fs::read_to_string(&template).expect("the template"), + fodt, + "the original stays" + ); +} + +#[test] +fn an_open_file_gets_focus_and_unsaved_closes_ask() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let file = dir.path().join("open.fodt"); + let fodt = cx.update(|app| Template::Report.fodt(app)); + std::fs::write(&file, fodt).expect("the file writes"); + with_window(&mut cx, &docs, |docs, _, cx| { + docs.open_file(file.clone(), cx) + }); + settle(&cx, |app| docs.read(app).core.tabs.len() == 1); + with_window(&mut cx, &docs, |docs, _, cx| { + docs.open_file(file.clone(), cx) + }); + for _ in 0..40 { + cx.executor().tick(); + } + assert_eq!( + cx.update(|app| docs.read(app).core.tabs.len()), + 1, + "the same file focuses" + ); + type_into(&mut cx, &docs, "edit"); + let id = cx.update(|app| docs.read(app).core.active.expect("a tab")); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.request_close(id, After::Tab, window, cx) + }); + cx.update(|app| { + let asking = + matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { tab, .. }) if tab == id); + assert!(asking, "a dirty close asks"); + }); + assert!( + cx.update(|app| docs.read(app).core.tabs.len()) == 1, + "nothing closed yet" + ); +} + +/// The text the kernel reads back from `path`. +pub(crate) fn text_of(cx: &mut TestAppContext, docs: &Entity, path: &Path) -> String { + let worker = cx.update(|app| docs.read(app).core.worker.clone()); + let mut worker = worker.lock().expect("the worker lock"); + let doc = worker + .load(&ely_lok::file_url(path)) + .expect("the saved file loads"); + let flat = path.with_extension("check.fodt"); + doc.save_as(&mut worker, &ely_lok::file_url(&flat), "fodt") + .expect("flattens"); + worker.close_doc(doc).expect("closes"); + std::fs::read_to_string(&flat).expect("reads") +} + +/// Waits for the active tab's queued input to land. +pub(crate) fn settled(cx: &TestAppContext, docs: &Entity) { + settle(cx, |app| { + let tab = docs.read(app).active_tab().expect("a tab"); + tab.view.read(app).settled() + }); +} + +/// Opens the blank template and returns its tab id. +pub(crate) fn blank(cx: &mut TestAppContext, docs: &Entity) -> u64 { + let before = cx.update(|app| docs.read(app).core.tabs.len()); + with_window(cx, docs, |docs, _, cx| docs.new_from(Template::Blank, cx)); + settle(cx, |app| docs.read(app).core.tabs.len() > before); + cx.update(|app| docs.read(app).core.tabs.last().expect("a tab").id) +} + +/// Commits `text` through the view without waiting. +pub(crate) fn commit(cx: &mut TestAppContext, docs: &Entity, text: &str) { + let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone()); + let window = cx.add_window(|_, _| Blank); + cx.update_window(window.into(), |_, window, app| { + let mut ime = gpui::ElementInputHandler::new(gpui::Bounds::default(), view.clone()); + gpui::InputHandler::replace_text_in_range(&mut ime, None, text, window, app); + }) + .expect("the window lives"); +} + +#[test] +fn queued_input_reaches_the_save_and_the_close_prompt() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + commit(&mut cx, &docs, "PendingText"); + let out = dir.path().join("pending.docx"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, out.clone(), None, window, cx) + }); + written(&cx, &out, None); + settle(&cx, |_| out.exists()); + assert!( + text_of(&mut cx, &docs, &out).contains("PendingText"), + "the queued text saved" + ); + commit(&mut cx, &docs, "More"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.request_close(id, After::Tab, window, cx) + }); + settle(&cx, |app| { + matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { .. })) + }); +} + +#[test] +fn a_macro_file_opens_in_risk_mode_and_saves_only_as_a_copy() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let macro_doc = dir.path().join("macro.fodt"); + let body = cx.update(|app| Template::Report.fodt(app)) + .replace( + "office:version=", + "xmlns:script=\"urn:oasis:names:tc:opendocument:xmlns:script:1.0\" xmlns:ooo=\"http://openoffice.org/2004/office\" office:version=", + ) + .replace( + "", + "Sub Main End Sub", + ); + std::fs::write(¯o_doc, &body).expect("the macro file writes"); + with_window(&mut cx, &docs, |docs, _, cx| { + docs.open_file(macro_doc.clone(), cx) + }); + settle(&cx, |app| docs.read(app).core.active.is_some()); + let id = cx.update(|app| { + let tab = docs.read(app).active_tab().expect("a tab"); + assert!(tab.risk.is_some(), "macros put the tab in risk mode"); + tab.id + }); + type_into(&mut cx, &docs, "x"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, macro_doc.with_extension("docx"), None, window, cx) + }); + let copy = macro_doc.with_extension("docx"); + written(&cx, ©, None); + assert!(copy.exists(), "the copy writes"); + assert_eq!( + std::fs::read_to_string(¯o_doc).expect("source"), + body, + "the source stays" + ); + cx.update(|app| { + assert!( + !docs.read(app).active_tab().expect("a tab").risk.is_some(), + "the DOCX copy holds no macros" + ) + }); +} + +#[cfg(unix)] +#[test] +fn an_alias_never_converts_the_open_file() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + let source = dir.path().join("source.odt"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, source.clone(), None, window, cx) + }); + written(&cx, &source, None); + let before = std::fs::read(&source).expect("the odt"); + let alias = dir.path().join("alias.docx"); + std::os::unix::fs::symlink(&source, &alias).expect("the alias"); + type_into(&mut cx, &docs, "y"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, alias.clone(), None, window, cx) + }); + written(&cx, &source, Some(&before)); + let after = std::fs::read(&source).expect("the odt"); + let has = |bytes: &[u8], mark: &[u8]| bytes.windows(mark.len()).any(|w| w == mark); + assert!( + has(&after, b"application/vnd.oasis.opendocument.text"), + "the file stays ODT" + ); + assert!( + !has(&after, b"word/document.xml"), + "no DOCX lands in the ODT" + ); + assert_ne!(after, before, "the alias saved in the file's own format"); + let link = dir.path().join("link.docx"); + std::fs::hard_link(&source, &link).expect("the hard link"); + type_into(&mut cx, &docs, "z"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, link.clone(), None, window, cx) + }); + assert_eq!( + std::fs::read(&source).expect("the odt"), + after, + "a hard link never converts the open file" + ); + assert_eq!( + std::fs::read(&link).expect("the link"), + after, + "the link is refused, not replaced" + ); +} + +#[test] +fn an_outside_change_asks_and_keep_mine_overwrites() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + let own = dir.path().join("own.docx"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, own.clone(), None, window, cx) + }); + written(&cx, &own, None); + type_into(&mut cx, &docs, "Mine"); + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(&own, "EXTERNAL REVISION").expect("the outside write"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.save(id, None, window, cx) + }); + settle(&cx, |app| { + matches!(docs.read(app).core.dialog, Some(Dialog::Conflict { .. })) + }); + assert_eq!( + std::fs::read_to_string(&own).expect("own"), + "EXTERNAL REVISION" + ); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.keep_mine(id, None, window, cx) + }); + written(&cx, &own, Some(b"EXTERNAL REVISION")); + assert!( + text_of(&mut cx, &docs, &own).contains("Mine"), + "keep mine wrote the local version" + ); +} + +mod find; +mod more; +mod overlap; diff --git a/apps/ely-doc/src/tests/find.rs b/apps/ely-doc/src/tests/find.rs new file mode 100644 index 0000000..193ef53 --- /dev/null +++ b/apps/ely-doc/src/tests/find.rs @@ -0,0 +1,240 @@ +use ely_gpui_component::forms::TextInput; +use ely_office::{FindReplace, Finder, prelude::*}; +use gpui::{AppContext as _, Entity, TestAppContext}; + +use crate::docs::Docs; +use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written}; + +fn finder(cx: &TestAppContext, docs: &Entity, read: impl Fn(&Finder) -> R) -> R { + cx.update(|app| { + read( + docs.read(app) + .core + .finder + .as_ref() + .expect("the finder is open"), + ) + }) +} + +fn total(app: &gpui::App, docs: &Entity) -> Option { + docs.read(app) + .core + .finder + .as_ref() + .map(|finder| finder.all.len()) +} + +#[test] +fn find_counts_steps_replaces_and_counts_words() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "cat Cat cat dog"); + settle(&cx, |app| { + docs.read(app) + .active_tab() + .and_then(|tab| tab.extra.counts) + .is_some_and(|counts| counts.words == 4) + }); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_find(&FindReplace, window, cx) + }); + let query = finder(&cx, &docs, |finder| finder.query.clone()); + cx.update(|app| query.update(app, |query, cx| query.set_text("cat", cx))); + settle(&cx, |app| total(app, &docs) == Some(3)); + cx.update(|app| { + docs.update(app, |docs, cx| { + docs.core.finder.as_mut().expect("open").options.case = true; + docs.count_matches(cx); + }) + }); + settle(&cx, |app| total(app, &docs) == Some(2)); + cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx))); + settle(&cx, |app| { + docs.read(app) + .core + .finder + .as_ref() + .is_some_and(|finder| finder.current == Some(0)) + }); + with_window(&mut cx, &docs, |docs, window, cx| { + let finder = docs.core.finder.as_mut().expect("open"); + finder.replace = Some(cx.new(|cx| TextInput::new(window, cx))); + }); + let with = finder(&cx, &docs, |finder| { + finder.replace.clone().expect("a field") + }); + cx.update(|app| with.update(app, |with, cx| with.set_text("cow", cx))); + cx.update(|app| docs.update(app, |docs, cx| docs.replace(false, cx))); + settle(&cx, |app| total(app, &docs) == Some(1)); + cx.update(|app| docs.update(app, |docs, cx| docs.replace(true, cx))); + settle(&cx, |app| total(app, &docs) == Some(0)); + settled(&cx, &docs); + let path = dir.path().join("found.odt"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, path.clone(), None, window, cx) + }); + written(&cx, &path, None); + let text = text_of(&mut cx, &docs, &path); + assert!(text.contains("cow Cat cow dog"), "the cased matches go"); +} + +#[test] +fn a_misspelled_word_offers_suggestions() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "Teh end"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_find(&FindReplace, window, cx) + }); + let query = finder(&cx, &docs, |finder| finder.query.clone()); + cx.update(|app| query.update(app, |query, cx| query.set_text("Teh", cx))); + settle(&cx, |app| total(app, &docs) == Some(1)); + let (x, y, _, h) = finder(&cx, &docs, |finder| finder.all[0]); + let (worker, doc) = cx.update(|app| { + let docs = docs.read(app); + ( + docs.core.worker.clone(), + docs.active_tab().expect("a tab").doc.clone(), + ) + }); + { + let mut worker = worker.lock().expect("the worker lock"); + for (kind, buttons) in [(0, 1), (1, 1), (0, 4), (1, 4)] { + let (x, y, count, modifiers) = (x + 60, y + h / 2, 1, 0); + let event = ely_lok::Mouse { + kind, + x, + y, + count, + buttons, + modifiers, + }; + worker.post_mouse(&doc, event).expect("the click posts"); + } + } + settle(&cx, |app| docs.read(app).offer.is_some()); + let rule = cx.update(|app| { + let offer = docs.read(app).offer.as_ref().expect("an offer"); + let (_, rule) = offer + .suggestions + .iter() + .find(|(label, _)| label == "The") + .expect("The is offered"); + rule.clone() + }); + cx.update(|app| docs.update(app, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))); + settled(&cx, &docs); + let path = dir.path().join("spelled.odt"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, path.clone(), None, window, cx) + }); + written(&cx, &path, None); + assert!( + text_of(&mut cx, &docs, &path).contains("The end"), + "the suggestion lands" + ); +} + +#[test] +fn edits_mark_the_count_and_steps_scroll_to_far_matches() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let filler = "filler".repeat(100); + let body = format!( + r#" +a b c{filler}needle"# + ); + let path = dir.path().join("long.fodt"); + std::fs::write(&path, body).expect("the file writes"); + with_window(&mut cx, &docs, |docs, _, cx| { + docs.open_file(path.clone(), cx) + }); + settle(&cx, |app| docs.read(app).core.tabs.len() == 1); + settled(&cx, &docs); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_find(&FindReplace, window, cx) + }); + let query = finder(&cx, &docs, |finder| finder.query.clone()); + cx.update(|app| query.update(app, |query, cx| query.set_text(" ", cx))); + settle(&cx, |app| total(app, &docs) == Some(2)); + cx.update(|app| query.update(app, |query, cx| query.set_text("needle", cx))); + settle(&cx, |app| total(app, &docs) == Some(1)); + cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx))); + settle(&cx, |app| { + let tab = docs.read(app).active_tab().expect("a tab"); + tab.view.read(app).scroll().1 > 20_000 + }); + type_into(&mut cx, &docs, "needle needle"); + settle(&cx, |app| { + docs.read(app) + .core + .finder + .as_ref() + .is_some_and(|finder| finder.stale) + }); + cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx))); + settle(&cx, |app| { + let finder = docs.read(app).core.finder.as_ref().expect("open"); + !finder.stale && finder.all.len() == 2 + }); +} + +#[test] +fn a_step_asked_during_a_count_waits_for_it() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &docs); + type_into(&mut cx, &docs, "cat cat cat"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_find(&FindReplace, window, cx) + }); + let query = finder(&cx, &docs, |finder| finder.query.clone()); + cx.update(|app| query.update(app, |query, cx| query.set_text("cat", cx))); + cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx))); + cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx))); + assert_eq!( + finder(&cx, &docs, |finder| finder.queued.len()), + 2, + "both steps wait for the count" + ); + settle(&cx, |app| { + docs.read(app) + .core + .finder + .as_ref() + .is_some_and(|finder| finder.all.len() == 3 && finder.current == Some(1)) + }); + cx.update(|app| query.update(app, |query, cx| query.set_text("ca", cx))); + cx.update(|app| query.update(app, |query, cx| query.set_text("", cx))); + settle(&cx, |app| { + docs.read(app) + .core + .finder + .as_ref() + .is_some_and(|finder| finder.pending.is_empty()) + }); + assert_eq!( + finder(&cx, &docs, |finder| finder.all.len()), + 0, + "a cleared query voids the count on its way" + ); +} diff --git a/apps/ely-doc/src/tests/more.rs b/apps/ely-doc/src/tests/more.rs new file mode 100644 index 0000000..b23c49f --- /dev/null +++ b/apps/ely-doc/src/tests/more.rs @@ -0,0 +1,217 @@ +use gpui::TestAppContext; + +use super::*; + +/// The report template with an embedded Basic library. +fn macro_fodt(cx: &mut TestAppContext) -> String { + cx.update(|app| Template::Report.fodt(app)) + .replace( + "office:version=", + "xmlns:script=\"urn:oasis:names:tc:opendocument:xmlns:script:1.0\" xmlns:ooo=\"http://openoffice.org/2004/office\" office:version=", + ) + .replace( + "", + "Sub Main End Sub", + ) +} + +#[test] +fn zip_macro_checks_read_real_kernel_files() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let source = dir.path().join("basic.fodt"); + std::fs::write(&source, macro_fodt(&mut cx)).expect("writes"); + let worker = cx.update(|app| docs.read(app).core.worker.clone()); + let mut worker = worker.lock().expect("the worker lock"); + let doc = worker.load(&ely_lok::file_url(&source)).expect("loads"); + let (odt, docx) = (dir.path().join("basic.odt"), dir.path().join("plain.docx")); + doc.save_as(&mut worker, &ely_lok::file_url(&odt), "odt") + .expect("odt"); + doc.save_as(&mut worker, &ely_lok::file_url(&docx), "docx") + .expect("docx"); + worker.close_doc(doc).expect("closes"); + assert!( + ely_office::files::macro_risk(&Docs::KIND, &odt).expect("odt"), + "an ODT with Basic is a risk" + ); + assert!( + !ely_office::files::macro_risk(&Docs::KIND, &docx).expect("docx"), + "a DOCX drops the Basic" + ); +} + +#[test] +fn a_local_template_with_macros_opens_in_risk_mode() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let template = dir.path().join("macro-template.fodt"); + std::fs::write(&template, macro_fodt(&mut cx)).expect("writes"); + with_window(&mut cx, &docs, |docs, _, cx| { + docs.new_from_file(&template, cx) + }); + settle(&cx, |app| docs.read(app).core.active.is_some()); + cx.update(|app| { + assert!( + docs.read(app).active_tab().expect("a tab").risk.is_some(), + "the copy shows the risk" + ) + }); +} + +#[test] +fn select_all_alone_leaves_the_tab_clean() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &docs); + let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone()); + view.update(&mut cx, |view, _| { + view.run("edit.select_all", &[]).expect("select all queues") + }); + settle(&cx, |app| view.read(app).settled()); + for _ in 0..40 { + cx.executor().tick(); + } + cx.update(|app| { + assert!( + !docs.read(app).active_tab().expect("a tab").dirty(app), + "selection is no edit" + ) + }); +} + +#[test] +fn keep_mine_waits_and_two_queued_saves_never_crash() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + let mine = dir.path().join("mine.docx"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, mine.clone(), None, window, cx) + }); + written(&cx, &mine, None); + type_into(&mut cx, &docs, "x"); + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(&mine, "EXTERNAL").expect("the outside write"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.save(id, None, window, cx) + }); + commit(&mut cx, &docs, "PendingKeepMine"); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.keep_mine(id, Some(After::Tab), window, cx) + }); + settle(&cx, |app| docs.read(app).core.tabs.is_empty()); + let saved = text_of(&mut cx, &docs, &mine); + assert!( + saved.contains("PendingKeepMine"), + "keep mine saved the queued text: {}", + saved[saved.find(" = cx.update(|app| { + let docs = docs.read(app); + docs.core + .tabs + .iter() + .filter(|tab| { + tab.session + .path() + .is_some_and(|path| ely_files::same_file(path, &same).expect("comparable")) + }) + .map(|tab| tab.id) + .collect() + }); + assert_eq!(owners.len(), 1, "one tab holds the file"); + let text = text_of(&mut cx, &docs, &same); + let mark = if owners[0] == first { + "FirstOwner" + } else { + "SecondOwner" + }; + assert!(text.contains(mark), "the file is its owner's"); +} + +#[test] +fn interleaved_previews_keep_their_sources() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let alpha = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "SourceAlpha "); + update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx)); + let beta = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "SourceBeta "); + update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx)); + settled(&cx, &docs); + let csv = dir.path().join("two.csv"); + std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes"); + let table = read_csv(&csv).expect("the csv reads"); + update_doc(&mut cx, &docs, |docs, cx| { + docs.preview_record(alpha, &table, 0, cx); + docs.preview_record(beta, &table, 1, cx); + }); + settle(&cx, |app| { + let docs = docs.read(app); + // A filled preview is a modified copy. + docs.core.tabs.len() == 4 && docs.core.tabs[2..].iter().all(|tab| tab.modified) + }); + let previews: Vec = cx.update(|app| { + let tabs = &docs.read(app).core.tabs; + tabs[2..].iter().map(|tab| tab.id).collect() + }); + let mut texts = Vec::new(); + for (n, id) in previews.into_iter().enumerate() { + let path = dir.path().join(format!("preview{n}.odt")); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.write_as(id, path.clone(), None, window, cx) + }); + written(&cx, &path, None); + texts.push(text_of(&mut cx, &docs, &path)); + } + assert!( + texts.iter().any(|text| text.contains("SourceAlpha Ada")), + "{texts:?}" + ); + assert!( + texts.iter().any(|text| text.contains("SourceBeta Grace")), + "{texts:?}" + ); +} + +#[test] +fn closing_a_clean_tab_never_waits_for_the_worker() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + settled(&cx, &docs); + let worker = cx.update(|app| docs.read(app).core.worker.clone()); + let (held, hold) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _busy = worker.lock().expect("the worker lock"); + held.send(()).expect("the test waits"); + std::thread::sleep(std::time::Duration::from_millis(400)); + }); + hold.recv().expect("the lock is held"); + let started = std::time::Instant::now(); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.request_close(id, After::Tab, window, cx) + }); + let took = started.elapsed(); + assert!( + took < std::time::Duration::from_millis(100), + "the close waited {took:?}" + ); + holder.join().expect("the holder ends"); + settle(&cx, |app| docs.read(app).tab(id).is_none()); +} + +fn update_doc( + cx: &mut TestAppContext, + docs: &Entity, + f: impl FnOnce(&mut Docs, &mut gpui::Context), +) { + cx.update(|app| docs.update(app, f)); +} diff --git a/apps/ely-mail/Cargo.toml b/apps/ely-mail/Cargo.toml new file mode 100644 index 0000000..c10d28f --- /dev/null +++ b/apps/ely-mail/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ely-mail" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +ely-platform.workspace = true +ely-ui.workspace = true + +[lints] +workspace = true diff --git a/apps/ely-mail/src/main.rs b/apps/ely-mail/src/main.rs new file mode 100644 index 0000000..7510bff --- /dev/null +++ b/apps/ely-mail/src/main.rs @@ -0,0 +1,5 @@ +use ely_platform::AppId; + +fn main() { + ely_ui::run(AppId::Mail, ely_ui::blank); +} diff --git a/apps/ely-mark/Cargo.toml b/apps/ely-mark/Cargo.toml new file mode 100644 index 0000000..161ce50 --- /dev/null +++ b/apps/ely-mark/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "ely-mark" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +ely-files.workspace = true +ely-gpui-component.workspace = true +ely-office.workspace = true +ely-platform.workspace = true +ely-ui.workspace = true +ely-web-content.workspace = true +gpui.workspace = true +log.workspace = true +base64.workspace = true +futures.workspace = true +jiff.workspace = true +notify.workspace = true +serde.workspace = true +serde_json.workspace = true + +[lints] +workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/apps/ely-mark/examples/markprobe.rs b/apps/ely-mark/examples/markprobe.rs new file mode 100644 index 0000000..f2ca9d5 --- /dev/null +++ b/apps/ely-mark/examples/markprobe.rs @@ -0,0 +1,433 @@ +//! The built page, live: each package renders offline. + +use std::{path::Path, time::Duration}; + +use ely_gpui_component::theme::{ActiveTheme as _, Mode, Theme}; +use ely_platform::AppId; +use ely_web_content::{Event, WebView}; +use gpui::{ + App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, + Window, div, +}; + +/// What the page holds, each block scrolled in and mounted. +const CHECK: &str = r#" +(async () => { + const view = document.querySelector('#document > div:not([hidden])') ?? document; + const settle = () => new Promise((done) => setTimeout(done, 700)); + const seen = (node) => { + if (!node || !node.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false; + node.scrollIntoView({ block: 'nearest' }); + const box = node.getBoundingClientRect(); + if (box.width < 1 || box.height < 1) return false; + const top = document.elementFromPoint(box.x + box.width / 2, box.y + Math.min(box.height / 2, 8)); + return Boolean(top && (node.contains(top) || top.contains(node))); + }; + const blocks = []; + for (const block of view.querySelectorAll('.milkdown-code-block')) { + block.scrollIntoView({ block: 'center' }); + await settle(); + const diagram = block.querySelector('.ely-diagram svg'); + const shape = diagram?.querySelector('.node rect, .node polygon'); + const box = diagram?.getBoundingClientRect(); + blocks.push({ + language: block.querySelector('.language-button')?.textContent.trim() ?? '', + editors: block.querySelectorAll('.cm-editor').length, + editable: block.querySelector('.cm-content')?.contentEditable ?? 'none', + code: seen(block.querySelector('.cm-line')), + math: seen(block.querySelector('.preview .katex')), + diagram: box ? [Math.round(box.width), Math.round(box.height)] : [0, 0], + drawn: seen(diagram), + fill: shape ? getComputedStyle(shape).fill : '', + first: window.firstFill ?? '', + failure: seen(block.querySelector('.ely-failure')) ? block.querySelector('.ely-failure').textContent : '', + }); + } + document.scrollingElement.scrollTop = 0; + await settle(); + const root = document.getElementById('document'); + window.elyQuery({ + request: JSON.stringify({ + step: 'STEP', + mode: root.dataset.mode, + editable: view.querySelector('.ProseMirror')?.contentEditable ?? 'none', + title: view.querySelector('.milkdown h1')?.textContent ?? '', + heading: seen(view.querySelector('.milkdown h1')), + inline: seen(view.querySelector('.milkdown p .katex')), + display: seen(view.querySelector('.milkdown p .katex-display')), + tasks: view.querySelectorAll('li[data-item-type="task"]').length, + cells: view.querySelectorAll('.milkdown td').length, + notes: view.querySelectorAll('[data-type="footnote_definition"]').length, + blocks, + font: [...document.fonts].some((f) => f.family === 'KaTeX_Main' && f.status === 'loaded'), + source: seen(view.querySelector(':scope > .cm-editor .cm-line')), + first: view.querySelector('.cm-line')?.textContent ?? '', + editors: document.querySelectorAll('#document > div:not([hidden])').length, + text: view.textContent.slice(0, 40), + bg: getComputedStyle(document.documentElement).getPropertyValue('--ely-bg').trim(), + }), + onSuccess: () => {}, + onFailure: () => {}, + }); +})(); +"#; + +const TEXTS: &str = r#"{"searchLanguage":"s","noResult":"n","copy":"c","preview":"p","loading":"l","edit":"e","hide":"h"}"#; + +/// Steps in order; each reports once. +const STEPS: [&str; 13] = [ + "visual", "themed", "preview", "source", "race", "language", "midtheme", "release", "asked", + "stale", "owner", "find", "look", +]; + +/// Empty and broken queries: errors, never CodeMirror's own panel. +const FIND: &str = r#" +(async () => { + const report = {}; + for (const mode of ['source', 'visual']) { + await ely.open('a{b alpha\n', mode, TEXTS, 1, location.href); + const empty = { search: '' }; + ely.find(empty); + ely.step({ spec: empty, forward: true }); + ely.replace({ spec: empty, all: false }); + for (const search of ['a{', '\\a']) ely.find({ search, regexp: true }); + ely.unfind(); + report[mode] = document.querySelectorAll('.cm-panels .cm-search').length; + await ely.open('\u{1F600} alpha\n', mode, TEXTS, 1, location.href); + ely.find({ search: '(?=.)', regexp: true }); + ely.unfind(); + } + await new Promise((done) => setTimeout(done, 300)); + window.elyQuery({ request: JSON.stringify({ step: 'find', panels: report }), onSuccess: () => {}, onFailure: () => {} }); +})(); +"#; + +/// The writing view: dim blocks, narrow serif column, centered caret. +const LOOK: &str = r#" +(async () => { + const paragraphs = Array.from({ length: 80 }, (_, n) => `Paragraph ${n + 1} of the look.`) + await ely.open(paragraphs.join('\n\n') + '\n', 'visual', TEXTS, 1, location.href) + ely.look({ focus: true, typewriter: true, width: 'narrow', font: 'serif', size: 120 }) + ely.reveal({ line: 159, query: '' }) + await new Promise((done) => setTimeout(done, 600)) + const blocks = [...document.querySelectorAll('.milkdown .ProseMirror > *')] + const dim = blocks.filter((block) => getComputedStyle(block).opacity === '0.35').length + const bright = document.querySelector('.ely-current') + const caret = window.getSelection().getRangeAt(0).getBoundingClientRect() + const page = document.getElementById('document') + const report = { + step: 'look', + blocks: blocks.length, + dim, + bright: bright ? getComputedStyle(bright).opacity : 'none', + centered: Math.abs(caret.top - window.innerHeight / 2) < window.innerHeight / 4, + width: getComputedStyle(page).maxWidth, + serif: getComputedStyle(page).fontFamily.includes('serif'), + } + ely.look({ focus: false, typewriter: false, width: 'medium', font: 'sans', size: 100 }) + window.elyQuery({ request: JSON.stringify(report), onSuccess: () => {}, onFailure: () => {} }) +})(); +"#; + +/// Picks Rust in the first block's list, once mounted. +const TO_RUST: &str = r#" +(function pick() { + const block = document.querySelector('#document > div:not([hidden]) .milkdown-code-block'); + const button = block?.querySelector('.language-button'); + if (!button) return requestAnimationFrame(pick); + button.click(); + setTimeout(() => [...block.querySelectorAll('.language-list-item')] + .find((item) => item.textContent.trim() === 'Rust').click(), 30); +})(); +"#; + +/// Says when a diagram waits, drawn not yet. +const PENDING: &str = r#" +(function wait() { + if (!document.querySelector('.ely-diagram:empty')) return requestAnimationFrame(wait); + new MutationObserver((_, watch) => { + const shape = document.querySelector('.ely-diagram svg .node rect'); + if (!shape) return; + window.firstFill = getComputedStyle(shape).fill; + watch.disconnect(); + }).observe(document.body, { childList: true, subtree: true }); + window.elyQuery({ request: JSON.stringify({ kind: 'pending' }), onSuccess: () => {}, onFailure: () => {} }); +})(); +"#; + +/// A diagram slow enough to change things mid-draw. +fn big() -> String { + let mut source = String::from("```mermaid\ngraph TD\n"); + for at in 0..150 { + source += &format!(" N{at} --> N{}\n N{at} --> N{}\n", at + 1, (at * 7) % 151); + } + serde_json::to_string(&(source + "```\n")).expect("json") +} + +/// Short sources, slow draws: blocks mount at once. +fn chains(blocks: usize) -> String { + let mut source = String::new(); + for block in 0..blocks { + let side = |name: &str| { + let nodes: Vec = (0..20).map(|at| format!("{name}{block}x{at}")).collect(); + nodes.join(" & ") + }; + source += &format!( + "```mermaid\ngraph LR\n{} --> {}\n```\n\n", + side("A"), + side("B") + ); + } + serde_json::to_string(&source).expect("json") +} + +/// Leaves for source mid-draw; counts long stalls after. +const RELEASE: &str = r#" +(async () => { + const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); + await ely.open(BIG, 'preview', TEXTS, 1, location.href); + await sleep(50); + await ely.open('x', 'source', TEXTS, 1, location.href); + let last = performance.now(); + let long = 0; + const tick = setInterval(() => { + const now = performance.now(); + if (now - last > 500) long += 1; + last = now; + }, 20); + await sleep(7000); + clearInterval(tick); + window.elyQuery({ request: JSON.stringify({ step: 'release', long }), onSuccess: () => {}, onFailure: () => {} }); +})(); +"#; + +fn flip(cx: &mut App) { + let mode = match cx.theme().is_dark() { + true => Mode::Light, + false => Mode::Dark, + }; + Theme::set_mode_now(mode, cx); +} + +struct Probe { + view: Entity, + step: usize, + /// A fresh page: Mermaid's bundle not yet loaded. + fresh: bool, + /// Find reports that carried an error, and every total. + errors: usize, + totals: Vec, +} + +impl Probe { + /// Starts the current step; its check follows. + fn begin(&mut self, cx: &mut Context) { + let markdown = + serde_json::to_string(include_str!("../../../tests/fixtures/mark/extended.md")) + .expect("json"); + let step = STEPS[self.step]; + match step { + "themed" => { + flip(cx); + self.check(step, 2500, cx); + } + // Two quick shows: only the last may stay. + "race" => { + self.view.read(cx).run_js(&format!( + "ely.open('# OLD', 'visual', {TEXTS}, 1, location.href); \ + ely.open('# NEW', 'source', {TEXTS}, 1, location.href)" + )); + self.check(step, 2500, cx); + } + // Two modes asked at once: the last one shows. + "asked" => { + self.view.read(cx).run_js(&format!( + "ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \ + .then(() => {{ ely.mode('visual'); ely.mode('preview') }})" + )); + self.check(step, 3000, cx); + } + // A request while visual's shown is unanswered wins. + "stale" => { + self.view.read(cx).run_js(&format!( + "ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \ + .then(() => {{ ely.mode('visual'); ely.mode('preview') }})" + )); + self.check(step, 3000, cx); + } + // An old answer leaves a newer build's request alone. + "owner" => { + self.view.read(cx).run_js(&format!( + "ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \ + .then(() => ely.mode('visual'))" + )); + self.check(step, 3000, cx); + } + "find" => self.view.read(cx).run_js(&FIND.replace("TEXTS", TEXTS)), + "look" => self.view.read(cx).run_js(&LOOK.replace("TEXTS", TEXTS)), + "release" => { + let script = RELEASE.replace("BIG", &chains(3)).replace("TEXTS", TEXTS); + self.view.read(cx).run_js(&script); + } + // Changes land while the bundle still loads. + "language" | "midtheme" if !self.fresh => { + self.fresh = true; + self.view.read(cx).run_js("location.reload()"); + } + "language" | "midtheme" => { + self.fresh = false; + let show = format!("ely.open({}, 'visual', {TEXTS}, 1, location.href)", big()); + self.view.read(cx).run_js(&show); + } + mode => self.view.read(cx).run_js(&format!( + "ely.open({markdown}, '{mode}', {TEXTS}, 1, location.href)" + )), + } + } + + /// Checks after `wait`, when drawing is done. + fn check(&self, step: &'static str, wait: u64, cx: &mut Context) { + let view = self.view.clone(); + cx.spawn(async move |_, cx| { + cx.background_executor() + .timer(Duration::from_millis(wait)) + .await; + view.update(cx, |view, _| view.run_js(&CHECK.replace("STEP", step))); + }) + .detach(); + } + + fn heard( + &mut self, + _: &Entity, + event: &Event, + _: &mut Window, + cx: &mut Context, + ) { + match event { + Event::Loaded => self.begin(cx), + Event::Query(said, reply) => { + let read: serde_json::Value = serde_json::from_str(said).expect("json"); + // Some answers wait; meanwhile newer modes are asked. + let (wait, then) = match (STEPS.get(self.step), read["mode"].as_str()) { + (Some(&"stale"), Some("visual")) => (300, "ely.mode('source')"), + (Some(&"owner"), Some("visual")) => { + (300, "ely.mode('preview'); ely.mode('source')") + } + (Some(&"owner"), Some("preview")) => (600, ""), + _ => (0, ""), + }; + if !then.is_empty() { + self.view.read(cx).run_js(then); + } + let reply = reply.clone(); + cx.spawn(async move |_, cx| { + cx.background_executor() + .timer(Duration::from_millis(wait)) + .await; + reply.ok(""); + }) + .detach(); + if read["kind"] == "shown" { + println!("mark: shown {}", read["mode"]); + let Some(&step) = STEPS.get(self.step) else { + return; + }; + // Mid-draw: a new language, or a new theme. + match step { + "language" => self.view.read(cx).run_js(TO_RUST), + "midtheme" => return self.view.read(cx).run_js(PENDING), + _ if step == read["mode"] => {} + _ => return, + } + self.check(step, 5000, cx); + return; + } + if read["kind"] == "found" { + self.errors += read["error"].is_string() as usize; + self.totals.extend(read["total"].as_u64()); + return; + } + // Edits report too; the session tests them. + if ["changed", "text", "outline", "place"] + .contains(&read["kind"].as_str().unwrap_or_default()) + { + return; + } + if read["kind"] == "pending" { + // Its draw takes seconds; flip once it has begun. + println!("mark: pending"); + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(600)) + .await; + this.update(cx, |probe, cx| { + flip(cx); + probe.check("midtheme", 6000, cx); + }) + .ok(); + }) + .detach(); + return; + } + let mut read = read; + if read["step"] == "find" { + read["errors"] = self.errors.into(); + read["totals"] = self.totals.clone().into(); + } + println!("mark: {read}"); + self.step += 1; + if self.step == STEPS.len() { + println!("mark: done"); + return cx.quit(); + } + // The first view stays a while for the window's pixels. + let wait = if self.step == 1 { 3000 } else { 0 }; + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(wait)) + .await; + this.update(cx, |probe, cx| probe.begin(cx)).ok(); + }) + .detach(); + } + Event::Leave(url) => println!("mark: leave {url}"), + Event::Printed(path, ok) => println!("mark: printed {} {ok}", path.display()), + Event::Failed(why) => { + println!("mark: failed {why}"); + cx.quit(); + } + } + } +} + +impl Render for Probe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child(self.view.clone()) + } +} + +fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { + let work = tempfile::tempdir().expect("a temp dir").keep(); + ely_web_content::start(&work, cx).expect("CEF starts"); + let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark"); + let view = WebView::open(&content.join("index.html"), &content, window, cx).expect("a view"); + let probe = cx.new(|cx| { + cx.subscribe_in(&view, window, Probe::heard).detach(); + Probe { + view, + step: 0, + fresh: false, + errors: 0, + totals: Vec::new(), + } + }); + ely_ui::root(app, Some(probe.into()), window, cx) +} + +fn main() { + if let Some(code) = ely_web_content::subprocess() { + std::process::exit(code); + } + ely_ui::run(AppId::Mark, open); +} diff --git a/apps/ely-mark/src/attach.rs b/apps/ely-mark/src/attach.rs new file mode 100644 index 0000000..1965454 --- /dev/null +++ b/apps/ely-mark/src/attach.rs @@ -0,0 +1,163 @@ +//! Images dropped or pasted, copied beside the file (MARK-07). + +use std::{ + io, + io::Write as _, + path::{Path, PathBuf}, +}; + +use ely_platform::clipboard::{self, Clip}; +use ely_ui::i18n::text; +use gpui::{App, Context, WeakEntity, Window}; +use serde_json::json; + +use crate::view::{Failed, MarkView}; + +/// The folder beside the file that takes its images. +pub const FOLDER: &str = "assets"; + +const IMAGES: [&str; 8] = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"]; + +fn image(path: &Path) -> bool { + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default(); + IMAGES.contains(&extension.to_lowercase().as_str()) +} + +/// A relative link to a file in the folder, escaped. +pub fn link(name: &str) -> String { + format!("{FOLDER}/{}", crate::paper::segment(name)) +} + +/// Writes `png` into `dir` under a new name. +fn save_png(dir: &Path, png: &[u8]) -> io::Result { + std::fs::create_dir_all(dir)?; + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(io::Error::other)? + .as_millis(); + let mut n = 0; + loop { + let name = match n { + 0 => format!("pasted-{stamp}.png"), + n => format!("pasted-{stamp}-{n}.png"), + }; + let path = dir.join(name); + match std::fs::File::create_new(&path) { + Ok(mut file) => { + file.write_all(png)?; + file.flush()?; + return Ok(path); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => n += 1, + Err(error) => return Err(error), + } + } +} + +/// The paste hook: an image on the clipboard is attached. +pub fn paste(view: WeakEntity) -> impl Fn(&mut Window, &mut App) -> bool { + move |_, cx| match clipboard::read() { + Ok(Some(Clip::Png(png))) => { + view.update(cx, |view, cx| view.attach(Attach::Png(png), cx)) + .ok(); + true + } + Ok(_) => false, + Err(error) => { + log::error!("mark: the clipboard did not read: {error:#}"); + let why = format!("{error:#}"); + view.update(cx, |_, cx| cx.emit(Failed(why))).ok(); + false + } + } +} + +pub enum Attach { + Files(Vec), + Png(Vec), +} + +impl MarkView { + /// Copies into the file's folder, then links at the caret. + pub fn attach(&mut self, what: Attach, cx: &mut Context) { + let refused = match (&what, self.attachable, self.mode) { + (_, false, _) => Some("mark.attach.untitled"), + (_, _, crate::page::Mode::Preview) => Some("mark.attach.preview"), + (Attach::Files(files), ..) if !files.iter().all(|file| image(file)) => { + Some("mark.attach.images") + } + _ => None, + }; + if let Some(key) = refused { + return cx.emit(Failed(text(cx, key, &[]).to_string())); + } + let folder = self.folder.clone(); + let dir = folder.join(FOLDER); + cx.spawn(async move |view, cx| { + let copied = cx + .background_executor() + .spawn(async move { + match what { + Attach::Files(files) => files + .iter() + .map(|file| ely_files::import_attachment(file, &dir)) + .collect(), + Attach::Png(png) => save_png(&dir, &png).map(|path| vec![path]), + } + }) + .await; + view.update(cx, |view, cx| view.attached(&folder, copied, cx)) + .ok(); + }) + .detach(); + } + + fn attached( + &mut self, + folder: &Path, + copied: io::Result>, + cx: &mut Context, + ) { + let copied = match copied { + Ok(copied) => copied, + Err(error) => { + log::error!("mark: an image did not copy: {error}"); + return cx.emit(Failed(error.to_string())); + } + }; + // Links resolve from the file's folder; it moved meanwhile. + if self.folder != folder { + log::warn!("mark: the file moved while images copied; no links"); + let at = folder.join(FOLDER).display().to_string(); + return cx.emit(Failed( + text(cx, "mark.attach.moved", &[("folder", &at)]).to_string(), + )); + } + log::info!("mark: attached {} images", copied.len()); + let images: Vec<_> = copied + .iter() + .filter_map(|path| { + let name = path.file_name()?.to_string_lossy().into_owned(); + let alt = path.file_stem()?.to_string_lossy().into_owned(); + Some(json!({ "src": link(&name), "alt": alt })) + }) + .collect(); + crate::page::call(self.web.read(cx), "insert", &json!(images)); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn links_escape_and_names_never_repeat() { + assert_eq!(super::link("a b#1.png"), "assets/a%20b%231.png"); + let dir = tempfile::tempdir().expect("a temp dir"); + let first = super::save_png(dir.path(), b"x").expect("saves"); + let second = super::save_png(dir.path(), b"y").expect("saves"); + assert_ne!(first, second); + assert_eq!(std::fs::read(first).expect("reads"), b"x"); + } +} diff --git a/apps/ely-mark/src/autosave.rs b/apps/ely-mark/src/autosave.rs new file mode 100644 index 0000000..5ae8313 --- /dev/null +++ b/apps/ely-mark/src/autosave.rs @@ -0,0 +1,61 @@ +//! ElyMark saves a second after typing stops (§6.2). + +use std::time::{Duration, Instant}; + +use ely_files::{Action, DEBOUNCE, SaveState}; +use ely_office::{in_window, prelude::*}; +use gpui::{Context, Window}; + +use crate::desk::Desk; +use crate::outside::Outside; + +impl Desk { + /// A page change restarts the wait; finished saves report. + pub fn autosave(&mut self, id: u64, changed: u64, cx: &mut Context) { + let Some(tab) = self.tab_mut(id) else { + return; + }; + let saving = tab.exporting || tab.session.state() == SaveState::Saving; + if let Some(revision) = tab.extra.writing.filter(|_| !saving) { + let ok = tab.session.state() != SaveState::SaveFailed; + tab.extra.policy.save_finished(revision, ok); + tab.extra.writing = None; + } + if tab.session.path().is_some() { + tab.extra.policy.set_has_path(); + } + // A page reload restarts its count: any difference is news. + if changed == tab.extra.heard { + return; + } + tab.extra.heard = changed; + tab.extra.policy.edit(Instant::now()); + cx.spawn(async move |desk, cx| { + let wait = DEBOUNCE + Duration::from_millis(50); + cx.background_executor().timer(wait).await; + in_window(&desk, cx, |desk, window, cx| { + desk.autosave_due(id, window, cx) + }) + .ok(); + }) + .detach(); + } + + fn autosave_due(&mut self, id: u64, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.tab_mut(id) else { + return; + }; + if !matches!(tab.extra.outside, Outside::Clear) || tab.extra.writing.is_some() { + return; + } + let Action::SaveDue(revision) = tab.extra.policy.tick(Instant::now()) else { + return; + }; + if !tab.dirty(cx) { + return tab.extra.policy.save_finished(revision, true); + } + log::info!("mark: autosaves tab {id}"); + tab.extra.writing = Some(revision); + self.save(id, None, window, cx); + } +} diff --git a/apps/ely-mark/src/compare.rs b/apps/ely-mark/src/compare.rs new file mode 100644 index 0000000..ca31c96 --- /dev/null +++ b/apps/ely-mark/src/compare.rs @@ -0,0 +1,205 @@ +//! The disk's version against unsaved text, over their base (§14.4). + +use std::rc::Rc; + +use ely_files::{Fingerprint, Policy, PolicyEngine}; +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + git::{self as merging, Region, Take, ThreeWayMerge}, + overlays::Dialog, + primitives::Severity, +}; +use ely_office::prelude::*; +use ely_ui::i18n::text; +use gpui::{AnyElement, Context, IntoElement as _, ParentElement as _, Styled as _, Window, div}; + +use crate::desk::Desk; +use crate::outside::Outside; +use crate::text::Source; + +/// A comparison open over tab `id`. +pub struct Comparing { + pub id: u64, + disk: Source, + /// The disk as read; a choice checks it first. + fingerprint: Fingerprint, + regions: Rc>, + takes: Vec>, +} + +impl Desk { + /// Mine, the base and the disk's, side by side. + pub fn compare(&mut self, id: u64, cx: &mut Context) { + // The page's newest text first. + if !self.settled(id, cx) { + return self.when_settled(id, move |desk, _, cx| desk.compare(id, cx)); + } + let Some(tab) = self.tab(id) else { + return; + }; + let Outside::Changed(disk, fingerprint) = &tab.extra.outside else { + return; + }; + let mine = tab.doc.lock().expect("the text lock").current.clone(); + let regions = merging::regions(&tab.extra.base, &mine, &disk.text); + let conflicts = regions + .iter() + .filter(|region| region.kind == merging::RegionKind::Conflict) + .count(); + log::info!("mark: compares tab {id}: {conflicts} conflicts"); + self.comparing = Some(Comparing { + id, + disk: disk.clone(), + fingerprint: *fingerprint, + regions: Rc::new(regions), + takes: vec![None; conflicts], + }); + cx.notify(); + } + + fn take(&mut self, conflict: usize, take: Take, cx: &mut Context) { + if let Some(slot) = self + .comparing + .as_mut() + .and_then(|comparing| comparing.takes.get_mut(conflict)) + { + *slot = Some(take); + cx.notify(); + } + } + + /// Settles tab `id`: the disk is the new base. + fn settle(&mut self, choice: Choice, window: &mut Window, cx: &mut Context) { + let Some(comparing) = self.comparing.as_ref() else { + return; + }; + let id = comparing.id; + let now = self + .tab(id) + .and_then(|tab| tab.session.path()) + .and_then(|path| Fingerprint::of(path).ok()); + // The disk moved on: compare again, never write blind. + if now != Some(comparing.fingerprint) { + log::warn!("mark: tab {id} changed on disk during the comparison"); + let said = text(cx, "mark.compare.again", &[]); + self.toast(said, Severity::Warning, cx); + return self.look(id, cx); + } + let Some(Comparing { + id, + disk, + fingerprint, + regions, + takes, + }) = self.comparing.take() + else { + return; + }; + log::info!("mark: tab {id} settles with {choice:?}"); + match choice { + Choice::Disk => self.take_disk(id, disk, fingerprint, cx), + // The checked save: a later disk change conflicts again. + Choice::Mine => { + if let Some(tab) = self.tab_mut(id) { + tab.session.rebase(fingerprint); + tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true); + } + self.set_outside(id, Outside::Clear, cx); + self.save(id, None, window, cx); + } + Choice::Merged => { + let (merged, open) = merging::result(®ions, &takes); + if open > 0 { + return log::error!("mark: a merge with {open} open conflicts"); + } + let Some(tab) = self.tab_mut(id) else { + return; + }; + if let Err(error) = tab.session.reload_at(fingerprint) { + let why = error.to_string(); + return self.set_outside(id, Outside::Unread(why), cx); + } + tab.extra.base = disk.text.clone(); + tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true); + tab.doc.lock().expect("the text lock").source = disk; + let view = tab.view.clone(); + view.update(cx, |view, cx| view.adopt(merged, cx)); + self.set_outside(id, Outside::Clear, cx); + } + } + } + + pub fn compare_dialog(&self, cx: &mut Context) -> Option { + let comparing = self.comparing.as_ref()?; + let name = self.tab(comparing.id)?.name.to_string(); + let this = cx.entity(); + let labels = [ + "mark.compare.mine", + "mark.compare.base", + "mark.compare.disk", + ] + .map(|key| text(cx, key, &[]).to_string()); + let merge = ThreeWayMerge::new( + "mark-compare", + [&labels[0], &labels[1], &labels[2]], + comparing.regions.clone(), + ) + .takes(comparing.takes.clone()) + .on_take({ + let this = this.clone(); + move |conflict, take, _, cx| this.update(cx, |desk, cx| desk.take(conflict, take, cx)) + }); + let open = comparing.takes.iter().filter(|take| take.is_none()).count(); + let title = text(cx, "mark.compare.title", &[("name", &name)]); + let close = { + let this = this.clone(); + move |_: &mut Window, cx: &mut gpui::App| { + this.update(cx, |desk, cx| { + desk.comparing = None; + cx.notify(); + }) + } + }; + let mut dialog = Dialog::new("mark-compare-dialog", title, close) + .fullscreen() + .child(div().flex_1().min_h_0().size_full().child(merge)); + for (choice, key, variant) in [ + ( + Choice::Disk, + "mark.compare.use_disk", + ButtonVariant::Secondary, + ), + ( + Choice::Mine, + "mark.compare.keep_mine", + ButtonVariant::Secondary, + ), + ( + Choice::Merged, + "mark.compare.use_merged", + ButtonVariant::Primary, + ), + ] { + let label = text(cx, key, &[]); + let this = this.clone(); + let disabled = choice == Choice::Merged && open > 0; + dialog = dialog.action(move |_| { + Button::new(key, label) + .variant(variant) + .disabled(disabled) + .on_click(move |_, window, cx| { + this.update(cx, |desk, cx| desk.settle(choice, window, cx)) + }) + }); + } + Some(dialog.into_any_element()) + } +} + +/// How a comparison ends. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Choice { + Disk, + Mine, + Merged, +} diff --git a/apps/ely-mark/src/desk.rs b/apps/ely-mark/src/desk.rs new file mode 100644 index 0000000..60fc829 --- /dev/null +++ b/apps/ely-mark/src/desk.rs @@ -0,0 +1,446 @@ +//! Markdown files in the shared shell (§14). + +use std::{ + collections::HashMap, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use ely_gpui_component::{ + buttons::SegmentedControl, + feedback::EmptyState, + forms::TextInput, + primitives::IconName, + shell::{StatusBar, StatusBarItem}, + theme::ControlSize, +}; +use ely_office::{Core, Host, Kind, prelude::*}; +use ely_platform::{AppId, startup}; +use ely_ui::{Root, Shell, commands, i18n::text}; +use gpui::{ + App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, PathPromptOptions, Render, Styled as _, Window, div, +}; + +use crate::engine::{Mark, Pages}; +use crate::keys::{ + CheckDisk, ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, PreviewMode, + RowAbove, RowBelow, SourceMode, VisualMode, +}; +use crate::page::Mode; +use crate::view::MarkView; + +pub struct Desk { + pub core: Core, + /// The tab being renamed and its name field. + pub naming: Option<(u64, Entity)>, + pub folder: Option, + /// Folder listings so far; searches redo on a new one. + pub listings: u64, + /// Files and folders opened lately, newest first. + pub recent: Vec, + pub recent_file: PathBuf, + /// A file asked to open at a place. + pub going: Option<(PathBuf, serde_json::Value)>, + /// The path each tab last put in the recent list. + pub remembered: HashMap, + side: Entity, + pub finder: Option, + pub watcher: Option, + /// The folders the watcher covers. + pub watched: Vec, + pub comparing: Option, + pub keeping: crate::restore::Keeping, + /// The export dialog's choice, while it is open. + pub output: Option, + pub printing: Option, + pub look: crate::look::Look, + pub look_file: PathBuf, + /// Git's word on the shown file, and which tab asked. + pub tracked: Option, + asked: Option, + /// The shown file's fingerprint when Git was last asked. + asked_print: Option, +} + +impl Host for Desk { + type Extra = crate::outside::Extra; + type Engine = Mark; + const KIND: Kind = Kind { + app: AppId::Mark, + kept: &["md", "markdown"], + save_as: &["md"], + macros: None, + }; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// A quit or close stopped: the kept list follows again. + fn close_cancelled(&mut self, cx: &mut Context) { + self.keeping.leaving = false; + self.keep(cx); + } + + /// An autosave met an outside change: compare, never guess. + fn conflicted(&mut self, id: u64, cx: &mut Context) -> bool { + log::info!("mark: a save of tab {id} met an outside change"); + if let Some(tab) = self.tab_mut(id) { + tab.extra.policy.external_modified(); + } + self.look(id, cx); + true + } + + /// A closed tab's unsaved text is gone for good (§6.2). + fn tab_closed(&mut self, tab: &ely_office::Tab, cx: &mut Context) { + let path = tab.session.path().map(std::path::Path::to_path_buf); + self.drop_slot(&tab.extra.slot, path, tab.name.to_string(), cx); + } + + /// Save As or rename moves where images come from. + fn observed(&mut self, id: u64, view: &Entity, cx: &mut Context) { + self.watch_folders(cx); + let look = serde_json::to_value(&self.look).expect("a look serializes"); + view.update(cx, |view, cx| view.set_look(look, cx)); + let changed = view.read(cx).changed; + self.autosave(id, changed, cx); + let recheck = self.tab_mut(id).is_some_and(|tab| { + if !tab.dirty(cx) { + tab.extra.base = tab.doc.lock().expect("the text lock").current.clone(); + } + tab.extra.recheck + }); + if recheck { + self.look(id, cx); + } + if view.read(cx).shown { + self.restore_shown(id, cx); + } + // A save changes what Git sees. + let print = self.active_tab().and_then(|tab| tab.session.fingerprint()); + if print != self.asked_print { + self.asked_print = print; + self.ask_git(cx); + } + let path = self + .tab(id) + .and_then(|tab| tab.session.path().map(PathBuf::from)); + if let Some(path) = path { + view.update(cx, |view, _| view.attachable = true); + if let Some(folder) = path.parent() { + view.update(cx, |view, cx| view.moved(folder.to_path_buf(), cx)); + } + self.track_recent(id, &path, cx); + let real = std::fs::canonicalize(&path).ok(); + if self + .going + .as_ref() + .is_some_and(|(file, _)| Some(file) == real.as_ref()) + && let Some((_, place)) = self.going.take() + { + view.update(cx, |view, cx| view.reveal(place, cx)); + } + } + cx.notify(); + } +} + +/// The window root; files given at launch open. +pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { + let fail = |error: String| -> ! { startup::fail(app, &error) }; + let data = ely_platform::data_dir(app).unwrap_or_else(|error| fail(format!("{error:#}"))); + if let Err(error) = ely_web_content::start(&data, cx) { + fail(format!("{error:#}")); + } + let dir = crate::page::content().unwrap_or_else(|error| fail(format!("{error:#}"))); + let work = ely_office::fresh_work(&data).unwrap_or_else(|error| fail(format!("{error}"))); + crate::keys::bind(cx); + let pages = Arc::new(Mutex::new(Pages { dir })); + let recent_file = data.join("recent.json"); + let look_file = data.join("look.json"); + // An unreadable look is reported; the default shows. + let (look, look_unread) = match crate::look::load(&look_file) { + Ok(look) => (look, None), + Err(error) => (crate::look::Look::default(), Some(error)), + }; + let (keeping, kept, missing) = + crate::restore::Keeping::start(&data).unwrap_or_else(|error| fail(format!("{error}"))); + // An unreadable list is reported, never overwritten. + let (recent, unread) = match ely_files::recent::recent(&recent_file) { + Ok(list) => (list, None), + Err(error) => (Vec::new(), Some(error)), + }; + let desk = cx.new(|cx| { + let this = cx.entity(); + let side = cx.new(|cx| crate::side::Side::new(&this, window, cx)); + Desk { + core: Core::new(pages, work, cx), + naming: None, + folder: None, + listings: 0, + recent, + recent_file, + going: None, + remembered: HashMap::new(), + side, + finder: None, + watcher: None, + watched: Vec::new(), + comparing: None, + keeping, + output: None, + printing: None, + look: look.clone(), + look_file, + tracked: None, + asked: None, + asked_print: None, + } + }); + let menus = crate::keys::menus(&look, cx); + let root = ely_ui::document_root( + app, + desk.clone().into(), + &commands::RENAMING, + menus, + window, + cx, + ); + let asked = ely_ui::launch_files(cx); + desk.update(cx, |desk, cx| { + desk.core.root = root.downgrade(); + desk.core.window = Some(window.window_handle()); + window.focus(&desk.core.focus, cx); + // Launch files stand alone; unsaved text still returns. + if asked.is_empty() { + for path in kept { + desk.open_path(path, cx); + } + } + for name in desk.keeping.untitled() { + desk.new_from_text(&name, "", cx); + } + for path in asked { + desk.open_path(path, cx); + } + desk.keep_every(cx); + cx.on_release(|desk, _| desk.keeping.close()).detach(); + // Another shown tab asks Git again. + cx.observe_self(|desk, cx| { + let shown = desk.active_tab().map(|tab| tab.id); + if shown != desk.asked { + desk.asked = shown; + desk.tracked = None; + desk.ask_git(cx); + } + }) + .detach(); + cx.on_app_quit(|desk, _| { + desk.keeping.close(); + async {} + }) + .detach(); + for gone in missing { + let said = text(cx, "mark.restore.missing", &[("file", &gone)]); + desk.toast(said, ely_gpui_component::primitives::Severity::Warning, cx); + } + if let Some(error) = look_unread { + log::error!("mark: the look did not read: {error}"); + let said = text(cx, "office.error.engine", &[("error", &error.to_string())]); + desk.toast(said, ely_gpui_component::primitives::Severity::Danger, cx); + } + if let Some(error) = unread { + log::error!("mark: the recent list did not read: {error}"); + let said = text(cx, "office.error.engine", &[("error", &error.to_string())]); + desk.toast(said, ely_gpui_component::primitives::Severity::Danger, cx); + } + // Back in front: the folder may have changed. + cx.observe_window_activation(window, |desk, window, cx| { + if window.is_window_active() { + desk.relist(cx); + desk.ask_git(cx); + } + }) + .detach(); + }); + let opening = desk.clone(); + ely_ui::on_open(cx, move |path, cx| { + opening.update(cx, |desk, cx| desk.open_path(path, cx)) + }); + let closing = desk.clone(); + window.on_window_should_close(cx, move |window, cx| { + closing.update(cx, |desk, cx| { + desk.leave(cx); + desk.should_close(window, cx) + }) + }); + root +} + +impl Desk { + pub fn active_view(&self) -> Option> { + self.active_tab().map(|tab| tab.view.clone()) + } + + /// Asks for files; each opens in its own tab. + fn open_picked(&mut self, _: &commands::Open, _: &mut Window, cx: &mut Context) { + let picked = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: true, + multiple: true, + prompt: None, + }); + cx.spawn(async move |this, cx| { + let picked = match picked.await { + Ok(Ok(paths)) => Ok(paths), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + let done = this.update(cx, |desk, cx| match picked { + Ok(Some(paths)) => paths.into_iter().for_each(|path| desk.open_path(path, cx)), + Ok(None) => log::info!("mark: the open panel was cancelled"), + Err(error) => desk.panel_failed(&error, cx), + }); + if done.is_err() { + log::info!("mark: the window closed before the panel"); + } + }) + .detach(); + } + + /// An empty untitled file (MARK-01). + fn on_new(&mut self, _: &commands::New, _: &mut Window, cx: &mut Context) { + self.new_from_text("untitled.md", "", cx); + } + + fn table(&mut self, op: &str, cx: &mut Context) { + if let Some(view) = self.active_view() { + view.update(cx, |view, cx| view.table(op, cx)); + } + } + + fn switch(&mut self, mode: Mode, cx: &mut Context) { + if let Some(view) = self.active_view() { + view.update(cx, |view, cx| view.switch(mode, cx)); + } + } + + fn status(&self, cx: &mut Context) -> StatusBar { + let state = StatusBarItem::new("mark-state").label(self.save_state(cx)); + let Some(view) = self.active_view() else { + return StatusBar::new().left(state); + }; + let git = self.git_item(cx); + let this = cx.entity(); + let modes = Mode::ALL.into_iter().fold( + SegmentedControl::new("mark-mode", view.read(cx).mode.name()).size(ControlSize::Sm), + |control, mode| { + let icon = match mode { + Mode::Visual => IconName::PenLine, + Mode::Source => IconName::Code, + Mode::Preview => IconName::Eye, + }; + control.segment(mode.name(), crate::keys::label(mode, cx), Some(icon)) + }, + ); + let modes = modes.on_change(move |key, _, cx| { + if let Some(mode) = Mode::parse(key) { + this.update(cx, |desk, cx| desk.switch(mode, cx)); + } + }); + let bar = StatusBar::new().left(state); + let bar = match git { + Some(git) => bar.left(git), + None => bar, + }; + bar.right(modes) + } +} + +impl Render for Desk { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let tab = self.active_tab(); + let title = tab.map_or_else(|| AppId::Mark.name().into(), |tab| tab.name.clone()); + let body = match tab { + Some(tab) => div() + .size_full() + .flex() + .flex_col() + .children(self.outside_banner(cx)) + .children(large_banner(&tab.view, cx)) + .child(div().flex_1().min_h_0().child(tab.view.clone())) + .into_any_element(), + None => { + let none = text(cx, "command.blocked.no_document", &[]); + EmptyState::new("mark-empty", IconName::File, none).into_any_element() + } + }; + // Focus mode folds tabs, side and status away. + let shell = match self.look.focus { + true => Shell::new(title, &self.core.focus), + false => Shell::new(title, &self.core.focus) + .tabs(self.tab_strip(cx)) + .left(Some(self.side.clone().into())) + .status(self.status(cx)), + } + .child(body); + let root = div() + .size_full() + .key_context(commands::CONTEXT) + .track_focus(&self.core.focus) + .on_action(cx.listener(Self::open_picked)) + .on_action(cx.listener(Self::on_new)) + .on_action(cx.listener(Self::on_save)) + .on_action(cx.listener(Self::on_save_as)) + .on_action(cx.listener(Self::on_rename)) + .on_action(cx.listener(Self::on_find)) + .on_action(cx.listener(Self::on_export)) + .on_action(cx.listener(Self::on_print)) + .on_action(cx.listener(Self::on_close_tab)) + .on_action(cx.listener(|desk, quit, window, cx| { + desk.leave(cx); + desk.on_quit(quit, window, cx) + })) + .on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table("insert", cx))) + .on_action(cx.listener(|desk, _: &RowAbove, _, cx| desk.table("row-above", cx))) + .on_action(cx.listener(|desk, _: &RowBelow, _, cx| desk.table("row-below", cx))) + .on_action(cx.listener(|desk, _: &ColumnLeft, _, cx| desk.table("column-left", cx))) + .on_action(cx.listener(|desk, _: &ColumnRight, _, cx| desk.table("column-right", cx))) + .on_action(cx.listener(|desk, _: &DeleteRow, _, cx| desk.table("delete-row", cx))) + .on_action(cx.listener(|desk, _: &DeleteColumn, _, cx| desk.table("delete-column", cx))) + .on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx))) + .on_action(cx.listener(|desk, _: &VisualMode, _, cx| desk.switch(Mode::Visual, cx))) + .on_action(cx.listener(|desk, _: &SourceMode, _, cx| desk.switch(Mode::Source, cx))) + .on_action(cx.listener(|desk, _: &PreviewMode, _, cx| desk.switch(Mode::Preview, cx))) + .child(shell) + .children(self.core_dialog(cx)) + .children(self.naming_element(cx)) + .children(self.find_bar(cx)) + .children(self.compare_dialog(cx)) + .children(self.export_dialog(cx)); + Self::look_actions(root, cx) + } +} + +/// A large file says why it shows as source. +fn large_banner( + view: &Entity, + cx: &mut Context, +) -> Option { + if !view.read(cx).large { + return None; + } + let size = view.read(cx).bytes() as f64 / (1024.0 * 1024.0); + let said = text(cx, "mark.large", &[("size", &format!("{size:.1}"))]); + Some(ely_gpui_component::feedback::Banner::new( + "mark-large", + ely_gpui_component::primitives::Severity::Info, + said, + )) +} diff --git a/apps/ely-mark/src/engine.rs b/apps/ely-mark/src/engine.rs new file mode 100644 index 0000000..f2fcf9f --- /dev/null +++ b/apps/ely-mark/src/engine.rs @@ -0,0 +1,201 @@ +//! Markdown files as the shell's engine (§14.3). + +use std::{ + convert::Infallible, + hash::{DefaultHasher, Hash as _, Hasher as _}, + io, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use ely_office::{Engine, Host, Load}; +use ely_web_content::WebView; +use gpui::{App, AppContext as _, Context, Entity, FocusHandle, Window}; + +use crate::text::Source; +use crate::view::{Failed, MarkView, Reported}; + +/// The bundled page every tab shows. +pub struct Pages { + pub dir: PathBuf, +} + +/// A file as read, and its text now. +pub struct Text { + pub source: Source, + pub current: String, + /// The last write's mark, until history reads it. + written: Option, +} + +pub type Doc = Arc>; + +/// A load: the file as read, and where it is. +pub struct Opened { + pub source: Source, + pub path: PathBuf, +} + +/// The history mark: this text's length and hash. +pub fn mark(text: &str) -> String { + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + format!("{}:{:016x}", text.len(), hasher.finish()) +} + +pub struct Mark; + +impl Engine for Mark { + type Worker = Pages; + type Doc = Doc; + type View = MarkView; + type Loaded = Opened; + type Import = Infallible; + + fn load(_: &mut Pages, from: Load) -> io::Result { + let path = match from { + Load::File(path, _) => path, + Load::Import(_, never) => match *never {}, + }; + // The shell reads PermissionDenied as a locked file. + let bytes = std::fs::read(path).map_err(|error| match error.kind() { + io::ErrorKind::PermissionDenied => io::Error::other(error), + _ => error, + })?; + Ok(Opened { + source: Source::decode(bytes)?, + path: path.to_path_buf(), + }) + } + + fn open( + worker: &Arc>, + loaded: Opened, + window: &mut Window, + cx: &mut App, + ) -> io::Result<(Doc, Entity, String)> { + let Opened { + source: loaded, + path, + } = loaded; + let folder = path + .parent() + .expect("a file sits in a folder") + .to_path_buf(); + let dir = worker.lock().expect("the pages lock").dir.clone(); + let web = + WebView::open(&dir.join("index.html"), &dir, window, cx).map_err(io::Error::other)?; + let mark = mark(&loaded.text); + let current = loaded.text.clone(); + let doc = Arc::new(Mutex::new(Text { + source: loaded, + current, + written: None, + })); + web.read(cx) + .allow_images(&folder) + .map_err(io::Error::other)?; + let view = cx.new(|cx| MarkView::new(web, Arc::clone(&doc), mark.clone(), folder, cx)); + Ok((doc, view, mark)) + } + + /// After a write, what it wrote: later edits stay unsaved. + fn history(_: &mut Pages, doc: &Doc) -> io::Result { + let mut text = doc.lock().expect("the text lock"); + Ok(text.written.take().unwrap_or_else(|| mark(&text.current))) + } + + fn write(_: &mut Pages, doc: &Doc, path: &Path, format: &str) -> io::Result<()> { + if !matches!(format, "md" | "markdown") { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("writing .{format}"), + )); + } + let mut text = doc.lock().expect("the text lock"); + std::fs::write(path, text.source.encode(&text.current))?; + text.written = Some(mark(&text.current)); + Ok(()) + } + + fn close(_: &mut Pages, _: Doc) -> io::Result<()> { + Ok(()) + } + + fn retire(view: &Entity, cx: &mut App) -> ely_gpui_component::primitives::Retired { + let web = view.read(cx).web.clone(); + web.update(cx, |web, _| web.retire_all()) + } + + fn settled(view: &MarkView) -> bool { + view.settled() + } + + fn edited(view: &MarkView) -> u64 { + view.changed + } + + fn edit_pending(view: &MarkView) -> bool { + !view.settled() + } + + fn focus(view: &MarkView) -> FocusHandle { + view.focus.clone() + } + + fn saved(view: &Entity, mark: &str, cx: &mut App) { + view.update(cx, |view, cx| view.saved(mark, cx)); + } + + fn watch>( + view: &Entity, + cx: &mut Context, + reported: impl Fn(&mut H, &Entity, bool, &mut Context) + 'static, + failed: impl Fn(&mut H, &str, &mut Context) + 'static, + ) { + cx.subscribe(view, move |host, _, error: &Failed, cx| { + failed(host, &error.0, cx) + }) + .detach(); + cx.subscribe(view, move |host, view, _: &Reported, cx| { + let modified = view.read(cx).modified(); + reported(host, &view, modified, cx) + }) + .detach(); + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use ely_office::Engine as _; + + use super::{Mark, Pages, Text, mark}; + use crate::text::Source; + + #[test] + fn a_save_marks_the_text_it_wrote() { + let dir = tempfile::tempdir().expect("a temp dir"); + let source = Source::decode(b"Alpha.\n".to_vec()).expect("reads"); + let current = source.text.clone(); + let doc = Arc::new(Mutex::new(Text { + source, + current, + written: None, + })); + let mut pages = Pages { + dir: dir.path().to_path_buf(), + }; + let path = dir.path().join("save.md"); + Mark::write(&mut pages, &doc, &path, "md").expect("writes"); + // An edit lands between the write and the history read. + doc.lock().expect("the text lock").current = "xAlpha.\n".into(); + let saved = Mark::history(&mut pages, &doc).expect("a mark"); + assert_eq!(saved, mark("Alpha.\n")); + assert_eq!( + Mark::history(&mut pages, &doc).expect("a mark"), + mark("xAlpha.\n") + ); + } +} diff --git a/apps/ely-mark/src/export.rs b/apps/ely-mark/src/export.rs new file mode 100644 index 0000000..edcd7c7 --- /dev/null +++ b/apps/ely-mark/src/export.rs @@ -0,0 +1,474 @@ +//! Export and print: HTML, linked HTML, PDF, text (MARK-11). + +use std::{ + io, + path::{Path, PathBuf}, +}; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + overlays::Dialog, + primitives::Severity, +}; +use ely_office::{cancel, files::file_name, in_window, prelude::*}; +use ely_ui::{commands, i18n::text}; +use ely_web_content::{Event, WebView}; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, IntoElement as _, ParentElement as _, + Subscription, Window, +}; +use serde_json::Value; + +use crate::desk::Desk; +use crate::paper; + +/// What an export writes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Output { + Page, + Linked, + Pdf, + Text, +} + +impl Output { + const ALL: [Output; 4] = [Output::Page, Output::Linked, Output::Pdf, Output::Text]; + + fn key(self) -> &'static str { + match self { + Output::Page => "page", + Output::Linked => "linked", + Output::Pdf => "pdf", + Output::Text => "text", + } + } + + fn extension(self) -> &'static str { + match self { + Output::Page | Output::Linked => "html", + Output::Pdf => "pdf", + Output::Text => "txt", + } + } +} + +/// What an export lacks: images, diagrams, formulas. +#[derive(Clone, Debug, Default)] +pub struct Lost { + images: Vec, + diagrams: Vec, + /// Each broken formula's line and KaTeX's message. + formulas: Vec<(u64, String)>, +} + +impl Lost { + fn of(images: Vec, rendered: &Value) -> Lost { + let diagrams = rendered["failed"].as_array().into_iter().flatten(); + let formulas = rendered["formulas"].as_array().into_iter().flatten(); + Lost { + images, + diagrams: diagrams.filter_map(Value::as_u64).collect(), + formulas: formulas + .filter_map(|f| Some((f["line"].as_u64()?, f["error"].as_str()?.to_owned()))) + .collect(), + } + } + + fn whole(&self) -> bool { + self.images.is_empty() && self.diagrams.is_empty() && self.formulas.is_empty() + } +} + +/// No export lands on a file a tab holds (FILE-06). +fn not_open(desk: &Desk, path: &Path) -> io::Result<()> { + match desk.holder(path)? { + Some(_) => Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("{} is open in ElyMark", file_name(path)), + )), + None => Ok(()), + } +} + +/// A page printing to PDF in a view nobody sees. +pub struct Printing { + _view: Entity, + _heard: Subscription, +} + +impl Desk { + pub fn on_export(&mut self, _: &commands::Export, _: &mut Window, cx: &mut Context) { + if self.active_tab().is_some() { + self.output = Some(Output::Page); + cx.notify(); + } + } + + pub fn on_print(&mut self, _: &commands::Print, _: &mut Window, cx: &mut Context) { + let Some(id) = self.active_tab().map(|tab| tab.id) else { + return; + }; + let pdf = self.core.work.join(format!("print-{id}.pdf")); + self.render_pdf(id, pdf, None, cx); + } + + /// Asks where, renders the whole text, then writes it. + fn export_to(&mut self, output: Output, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return; + }; + let id = tab.id; + let stem = tab + .name + .rsplit_once('.') + .map_or(tab.name.as_ref(), |(stem, _)| stem) + .to_owned(); + let dir = ely_office::folder(tab).unwrap_or_else(std::env::temp_dir); + let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", output.extension()))); + cx.spawn(async move |desk, cx| { + let Ok(Ok(Some(path))) = chosen.await else { + return log::info!("mark: the export panel closed without a path"); + }; + desk.update(cx, |desk, cx| { + if let Err(error) = not_open(desk, &path) { + return desk.exported(Err(error), cx); + } + match output { + Output::Pdf => desk.render_pdf( + id, + desk.core.work.join(format!("export-{id}.pdf")), + Some(path), + cx, + ), + _ => desk.write_output(id, output, path, cx), + } + }) + .ok(); + }) + .detach(); + } + + /// HTML or text, assembled and written off the main thread. + fn write_output(&mut self, id: u64, output: Output, path: PathBuf, cx: &mut Context) { + let Some(tab) = self.tab(id) else { + return; + }; + let (view, folder) = (tab.view.clone(), tab.view.read(cx).folder.clone()); + let open = tab.session.path().map(Path::to_path_buf); + let style = self.pages_dir().join("export.css"); + let kind = if output == Output::Text { + "text" + } else { + "html" + }; + let desk = cx.weak_entity(); + view.update(cx, |view, cx| { + view.render( + kind, + move |rendered, cx: &mut App| { + // Checked again: tabs may have opened meanwhile. + let free = desk.update(cx, |desk, _| not_open(desk, &path)); + let free = free.map_err(io::Error::other).and_then(|free| free); + let job = cx.background_spawn(async move { + free?; + let rendered = rendered.map_err(io::Error::other)?; + write(output, &rendered, &folder, &style, &path, open.as_deref()) + }); + cx.spawn(async move |cx| { + let written = job.await; + desk.update(cx, |desk, cx| desk.exported(written, cx)).ok(); + }) + .detach(); + }, + cx, + ) + }); + } + + fn exported(&mut self, written: io::Result<(PathBuf, Lost)>, cx: &mut Context) { + match written { + Ok((path, lost)) if lost.whole() => { + log::info!("mark: exported {}", path.display()); + let said = text(cx, "mark.export.done", &[("name", &file_name(&path))]); + self.toast_output(said, path, cx); + } + Ok((path, lost)) => { + // Counts only: image names come from the text. + log::warn!( + "mark: exported {} without {} images and {} diagrams; formulas failed at lines {:?}", + path.display(), + lost.images.len(), + lost.diagrams.len(), + lost.formulas.iter().map(|(line, _)| line).collect::>() + ); + self.warn_lost(&lost, Some(path), cx); + } + Err(error) => { + log::error!("mark: an export failed: {error}"); + let said = text(cx, "mark.export.failed", &[("error", &error.to_string())]); + self.toast(said, Severity::Danger, cx); + } + } + } + + /// Says which images, diagrams and formulas an output lacks. + fn warn_lost(&mut self, lost: &Lost, output: Option, cx: &mut Context) { + let mut said = Vec::new(); + if !lost.images.is_empty() { + said.push(text( + cx, + "mark.export.missing", + &[("list", &lost.images.join(", "))], + )); + } + if !lost.diagrams.is_empty() { + said.push(text( + cx, + "mark.export.diagrams", + &[( + "list", + &lost + .diagrams + .iter() + .map(u64::to_string) + .collect::>() + .join(", "), + )], + )); + } + for (line, error) in &lost.formulas { + let line = line.to_string(); + said.push(text( + cx, + "mark.export.formula", + &[("line", &line), ("error", error)], + )); + } + // Names and messages quote the text: no log. + let said: gpui::SharedString = said.join(" ").into(); + match (output, self.core.root.upgrade()) { + (Some(path), Some(root)) => root.update(cx, |root, cx| root.toast_output(said, Severity::Warning, path, cx)), + _ => self.toast_unlogged(said, Severity::Warning, cx), + } + } + + fn pages_dir(&self) -> PathBuf { + self.core.worker.lock().expect("the pages lock").dir.clone() + } + + /// The whole text as PDF; then to `to`, else printed. + fn render_pdf(&mut self, id: u64, pdf: PathBuf, to: Option, cx: &mut Context) { + let Some(tab) = self.tab(id) else { + return; + }; + let (view, folder, name) = ( + tab.view.clone(), + tab.view.read(cx).folder.clone(), + tab.name.to_string(), + ); + let open = tab.session.path().map(Path::to_path_buf); + let (style, work) = (self.pages_dir().join("export.css"), self.core.work.clone()); + let desk = cx.weak_entity(); + view.update(cx, |view, cx| { + view.render( + "html", + move |rendered, cx: &mut App| { + let staged = work.join(format!("print-{id}.html")); + let page = rendered.map_err(io::Error::other).and_then(|rendered| { + let (html, images) = paper::Marked::read(&rendered)?.inline(&folder); + let style = format!("", std::fs::read_to_string(&style)?); + std::fs::write(&staged, paper::page(&name, &style, &html))?; + Ok(Lost::of(images, &rendered)) + }); + cx.spawn(async move |cx| { + in_window(&desk, cx, |desk, window, cx| match page { + Ok(lost) => { + let job = Job { + pdf, + to, + open, + lost, + }; + desk.print_page(&staged, job, window, cx) + } + Err(error) => desk.exported(Err(error), cx), + }) + .ok(); + }) + .detach(); + }, + cx, + ) + }); + } + + /// Chromium prints the staged page; the PDF goes where asked. + fn print_page(&mut self, page: &Path, job: Job, window: &mut Window, cx: &mut Context) { + let root = page + .parent() + .expect("a staged page sits in a folder") + .to_path_buf(); + let view = match WebView::open(page, &root, window, cx) { + Ok(view) => view, + Err(error) => return self.exported(Err(io::Error::other(error)), cx), + }; + let heard = cx.subscribe(&view, move |desk, view, event: &Event, cx| match event { + Event::Loaded => view.read(cx).print_pdf(&job.pdf), + Event::Printed(path, ok) => { + desk.printing = None; + let printed = ok + .then(|| path.clone()) + .ok_or_else(|| io::Error::other("Chromium did not print the page")); + desk.printed(printed, job.clone(), cx); + } + Event::Failed(why) => { + desk.printing = None; + desk.exported(Err(io::Error::other(why.clone())), cx); + } + Event::Query(_, reply) => reply.ok(""), + Event::Leave(_) => {} + }); + self.printing = Some(Printing { + _view: view, + _heard: heard, + }); + } + + fn printed(&mut self, pdf: io::Result, job: Job, cx: &mut Context) { + let pdf = match pdf { + Ok(pdf) => pdf, + Err(error) => return self.exported(Err(error), cx), + }; + let Some(to) = job.to else { + if !job.lost.whole() { + self.warn_lost(&job.lost, None, cx); + } + return ely_office::print_later(pdf, cx, |desk, why, cx| { + desk.exported(Err(io::Error::other(why)), cx) + }); + }; + // Never over the open file, not even through a link. + let moved = not_open(self, &to) + .and_then(|()| std::fs::read(&pdf)) + .and_then(|bytes| ely_files::export(&to, job.open.as_deref(), &bytes, 0).map(|_| ())); + let _ = std::fs::remove_file(&pdf); + self.exported(moved.map(|()| (to, job.lost)), cx); + } + + pub fn export_dialog(&self, cx: &mut Context) -> Option { + let chosen = self.output?; + let this = cx.entity(); + let kinds = Output::ALL.into_iter().fold( + SegmentedControl::new("mark-export-kind", chosen.key()), + |control, output| { + control.segment( + output.key(), + text(cx, &format!("mark.export.{}", output.key()), &[]), + None, + ) + }, + ); + let kinds = kinds.on_change({ + let this = this.clone(); + move |key, _, cx| { + let picked = Output::ALL + .into_iter() + .find(|output| key.as_ref() == output.key()); + this.update(cx, |desk, cx| { + desk.output = picked; + cx.notify(); + }) + } + }); + let close = { + let this = this.clone(); + move |_: &mut Window, cx: &mut App| { + this.update(cx, |desk, cx| { + desk.output = None; + cx.notify(); + }) + } + }; + let label = text(cx, "mark.export.go", &[]); + let dialog = Dialog::new("mark-export", text(cx, "mark.export", &[]), close) + .child(kinds) + .action(cancel(cx)) + .action(move |close| { + Button::new("mark-export-go", label) + .variant(ButtonVariant::Primary) + .on_click(move |_, window, cx| { + close(window, cx); + this.update(cx, |desk, cx| desk.export_to(chosen, cx)); + }) + }); + Some(dialog.into_any_element()) + } +} + +/// A PDF in flight: where printed, where bound, what lacks. +#[derive(Clone)] +struct Job { + pdf: PathBuf, + to: Option, + /// The tab's own file, never overwritten. + open: Option, + lost: Lost, +} + +/// Writes `output` at `path`; and what it left out. +fn write( + output: Output, + rendered: &Value, + folder: &Path, + style: &Path, + path: &Path, + open: Option<&Path>, +) -> io::Result<(PathBuf, Lost)> { + let title = path + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_default(); + let (bytes, missing) = match output { + Output::Text => ( + rendered["text"] + .as_str() + .unwrap_or_default() + .as_bytes() + .to_vec(), + Vec::new(), + ), + Output::Page => { + let (html, missing) = paper::Marked::read(rendered)?.inline(folder); + let style = format!("", std::fs::read_to_string(style)?); + (paper::page(&title, &style, &html).into_bytes(), missing) + } + Output::Linked => { + let assets = format!("{title}_files"); + let place = path.with_file_name(&assets); + let (html, copies, missing) = paper::Marked::read(rendered)?.linked(folder, &assets); + // A folder already there is never written into. + std::fs::create_dir(&place)?; + let copied = copies + .iter() + .try_for_each(|(from, name)| std::fs::copy(from, place.join(name)).map(|_| ())) + .and_then(|()| std::fs::copy(style, place.join("export.css")).map(|_| ())); + if let Err(error) = copied { + let _ = std::fs::remove_dir_all(&place); + return Err(error); + } + let style = format!( + "", + paper::segment(&assets) + ); + (paper::page(&title, &style, &html).into_bytes(), missing) + } + Output::Pdf => unreachable!("PDF prints in a page"), + }; + if let Err(error) = ely_files::export(path, open, &bytes, 0) { + if output == Output::Linked { + let _ = std::fs::remove_dir_all(path.with_file_name(format!("{title}_files"))); + } + return Err(error); + } + Ok((path.to_path_buf(), Lost::of(missing, rendered))) +} diff --git a/apps/ely-mark/src/finding.rs b/apps/ely-mark/src/finding.rs new file mode 100644 index 0000000..f68f54f --- /dev/null +++ b/apps/ely-mark/src/finding.rs @@ -0,0 +1,185 @@ +//! Find and replace over the shown editor (MARK-06). + +use ely_gpui_component::primitives::raise; +use ely_gpui_component::{ + editor::{FindOptions, FindWidget}, + forms::{InputEvent, TextInput}, +}; +use ely_office::prelude::*; +use gpui::{ + AnyElement, AppContext as _, Context, Entity, Focusable as _, InteractiveElement as _, + IntoElement as _, ParentElement as _, Styled as _, Subscription, Window, actions, div, +}; +use serde_json::json; + +use crate::desk::Desk; + +actions!(mark, [Find]); + +/// Where the shown editor's search stands. +#[derive(Clone, Debug, PartialEq)] +pub struct Found { + pub current: Option, + pub total: usize, + /// A pattern that does not compile. + pub error: Option, +} + +pub struct Finder { + query: Entity, + with: Entity, + options: FindOptions, + replacing: bool, + /// The tab last searched; another one is searched anew. + tab: Option, + _follow: Subscription, +} + +impl Desk { + /// Opens the find box, or takes focus to it. + pub fn on_find(&mut self, _: &Find, window: &mut Window, cx: &mut Context) { + if let Some(finder) = &self.finder { + let focus = finder.query.focus_handle(cx); + return window.focus(&focus, cx); + } + let query = cx.new(|cx| TextInput::new(window, cx)); + let with = cx.new(|cx| TextInput::new(window, cx)); + cx.subscribe(&query, |desk, _, event: &InputEvent, cx| match event { + InputEvent::Changed => desk.seek(cx), + InputEvent::Submit => desk.step(true, cx), + _ => {} + }) + .detach(); + cx.subscribe(&with, |desk, _, event: &InputEvent, cx| match event { + InputEvent::Changed => desk.seek(cx), + InputEvent::Submit => desk.replace(false, cx), + _ => {} + }) + .detach(); + let focus = query.focus_handle(cx); + let follow = cx.observe_self(|desk, cx| { + let shown = desk.active_tab().map(|tab| tab.id); + if desk + .finder + .as_ref() + .is_some_and(|finder| finder.tab != shown) + { + desk.seek(cx); + } + }); + self.finder = Some(Finder { + query, + with, + options: FindOptions::default(), + replacing: false, + tab: None, + _follow: follow, + }); + window.focus(&focus, cx); + cx.notify(); + } + + /// The query and options as the page's search spec. + fn spec(&self, cx: &Context) -> Option { + let finder = self.finder.as_ref()?; + Some(json!({ + "search": finder.query.read(cx).text(), + "replace": finder.with.read(cx).text(), + "caseSensitive": finder.options.case, + "regexp": finder.options.regex, + "wholeWord": finder.options.word, + })) + } + + fn seek(&mut self, cx: &mut Context) { + let shown = self.active_tab().map(|tab| tab.id); + if let Some(finder) = self.finder.as_mut() { + finder.tab = shown; + } + if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) { + view.update(cx, |view, cx| view.find(spec, cx)); + } + } + + fn step(&mut self, forward: bool, cx: &mut Context) { + if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) { + view.update(cx, |view, cx| view.step(spec, forward, cx)); + } + } + + fn replace(&mut self, all: bool, cx: &mut Context) { + if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) { + view.update(cx, |view, cx| view.replace(spec, all, cx)); + } + } + + fn close_find(&mut self, window: &mut Window, cx: &mut Context) { + self.finder = None; + if let Some(view) = self.active_view() { + view.update(cx, |view, cx| view.unfind(cx)); + let focus = view.read(cx).focus.clone(); + window.focus(&focus, cx); + } + cx.notify(); + } + + pub fn find_bar(&self, cx: &mut Context) -> Option { + let finder = self.finder.as_ref()?; + let found = self + .active_view() + .and_then(|view| view.read(cx).found.clone()); + let (current, total) = found + .as_ref() + .map_or((None, 0), |found| (found.current, found.total)); + let this = cx.entity(); + let widget = FindWidget::new("mark-find", &finder.query, current, total) + .options(finder.options) + .on_options({ + let this = this.clone(); + move |options, _, cx| { + this.update(cx, |desk, cx| { + if let Some(finder) = desk.finder.as_mut() { + finder.options = options; + } + desk.seek(cx); + }) + } + }) + .on_step({ + let this = this.clone(); + move |forward, _, cx| this.update(cx, |desk, cx| desk.step(forward, cx)) + }) + .on_replace({ + let this = this.clone(); + move |all, _, cx| this.update(cx, |desk, cx| desk.replace(all, cx)) + }) + .on_toggle_replace({ + let this = this.clone(); + move |open, _, cx| { + this.update(cx, |desk, cx| { + if let Some(finder) = desk.finder.as_mut() { + finder.replacing = open; + } + cx.notify(); + }) + } + }) + .on_close(move |window, cx| this.update(cx, |desk, cx| desk.close_find(window, cx))); + let widget = match finder.replacing { + true => widget.replace(&finder.with), + false => widget, + }; + let widget = match found.and_then(|found| found.error) { + Some(error) => widget.error(error), + None => widget, + }; + // Over the page's top right, as elsewhere. + let place = div() + .absolute() + .top_2() + .right_4() + .block_mouse_except_scroll() + .child(widget); + Some(raise("mark-find-layer", place).into_any_element()) + } +} diff --git a/apps/ely-mark/src/folder.rs b/apps/ely-mark/src/folder.rs new file mode 100644 index 0000000..bcabf97 --- /dev/null +++ b/apps/ely-mark/src/folder.rs @@ -0,0 +1,115 @@ +//! An opened folder's Markdown files, and searching them (MARK-05, MARK-10). + +use std::{ + io, + path::{Path, PathBuf}, +}; + +/// An opened folder and its Markdown files. +pub struct Folder { + pub root: PathBuf, + pub files: Vec, +} + +/// Hits a search keeps; it looks no further. +pub const MOST_HITS: usize = 500; + +fn markdown(name: &str) -> bool { + let name = name.to_lowercase(); + name.ends_with(".md") || name.ends_with(".markdown") +} + +/// Markdown files under `root`; no links, no dot names. +pub fn markdown_under(root: &Path) -> io::Result> { + let mut found = Vec::new(); + let mut folders = vec![(root.to_path_buf(), String::new())]; + while let Some((dir, prefix)) = folders.pop() { + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') { + continue; + } + let kind = entry.file_type()?; + let path = format!("{prefix}{name}"); + if kind.is_dir() { + folders.push((entry.path(), format!("{path}/"))); + } else if kind.is_file() && markdown(&name) { + found.push(path); + } + } + } + found.sort_by_key(|path| path.to_lowercase()); + Ok(found) +} + +/// One line that holds the words. +#[derive(Clone, Debug, PartialEq)] +pub struct Hit { + pub file: PathBuf, + /// Counted from one. + pub line: usize, + pub text: String, +} + +/// Lines of `text` holding `query`, any case. +pub fn lines_with(file: &Path, text: &str, query: &str, hits: &mut Vec) { + let query = query.to_lowercase(); + for (at, line) in text.lines().enumerate() { + if hits.len() == MOST_HITS { + return; + } + if line.to_lowercase().contains(&query) { + let shown: String = line.trim().chars().take(160).collect(); + hits.push(Hit { + file: file.to_path_buf(), + line: at + 1, + text: shown, + }); + } + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + #[cfg(unix)] + #[test] + fn markdown_files_list_without_hidden_or_other_files() { + let dir = tempfile::tempdir().expect("a temp dir"); + let root = dir.path(); + std::fs::create_dir_all(root.join("notes/deep")).expect("makes"); + std::fs::create_dir_all(root.join(".git")).expect("makes"); + for file in [ + "b.md", + "A.markdown", + "notes/deep/c.MD", + "notes/skip.txt", + ".git/x.md", + ".hidden.md", + ] { + std::fs::write(root.join(file), "x").expect("writes"); + } + #[cfg(unix)] + std::os::unix::fs::symlink(root.join("notes"), root.join("loop")).expect("links"); + let found = super::markdown_under(root).expect("lists"); + assert_eq!(found, ["A.markdown", "b.md", "notes/deep/c.MD"]); + } + + #[test] + fn lines_with_the_words_any_case() { + let mut hits = Vec::new(); + super::lines_with( + Path::new("a.md"), + "# Title\nsome Words here\nnone\nWORDS", + "words", + &mut hits, + ); + let lines: Vec<_> = hits + .iter() + .map(|hit| (hit.line, hit.text.as_str())) + .collect(); + assert_eq!(lines, [(2, "some Words here"), (4, "WORDS")]); + } +} diff --git a/apps/ely-mark/src/keys.rs b/apps/ely-mark/src/keys.rs new file mode 100644 index 0000000..53381a4 --- /dev/null +++ b/apps/ely-mark/src/keys.rs @@ -0,0 +1,118 @@ +//! Modes, menus and keys (MARK-01, MARK-02). + +use ely_ui::{commands, i18n::text}; +use gpui::{App, KeyBinding, Menu, MenuItem, actions}; + +use crate::page::Mode; + +actions!( + mark, + [ + VisualMode, + SourceMode, + PreviewMode, + InsertTable, + RowAbove, + RowBelow, + ColumnLeft, + ColumnRight, + DeleteRow, + DeleteColumn, + CheckDisk + ] +); + +pub fn bind(cx: &mut App) { + let at = Some(commands::CONTEXT); + cx.bind_keys([ + KeyBinding::new("secondary-1", VisualMode, at), + KeyBinding::new("secondary-2", SourceMode, at), + KeyBinding::new("secondary-3", PreviewMode, at), + KeyBinding::new("secondary-f", crate::finding::Find, at), + KeyBinding::new("secondary-shift-f", crate::look::FocusMode, at), + KeyBinding::new("secondary-=", crate::look::Larger, at), + KeyBinding::new("secondary--", crate::look::Smaller, at), + KeyBinding::new("secondary-0", crate::look::ActualSize, at), + ]); +} + +/// The label of `mode`. +pub fn label(mode: Mode, cx: &App) -> gpui::SharedString { + text(cx, &format!("mark.mode.{}", mode.name()), &[]) +} + +pub fn menus(look: &crate::look::Look, cx: &App) -> Vec { + use crate::look::{self, Font, Width}; + let checked = |key: &str, action: Box, checked: bool| MenuItem::Action { + name: text(cx, key, &[]), + action, + os_action: None, + checked, + disabled: false, + }; + let item = |key: &str, action: Box| checked(key, action, false); + let table = Menu::new(text(cx, "mark.menu.table", &[])).items([ + item("mark.table.insert", Box::new(InsertTable)), + MenuItem::separator(), + item("mark.table.row_above", Box::new(RowAbove)), + item("mark.table.row_below", Box::new(RowBelow)), + item("mark.table.column_left", Box::new(ColumnLeft)), + item("mark.table.column_right", Box::new(ColumnRight)), + MenuItem::separator(), + item("mark.table.delete_row", Box::new(DeleteRow)), + item("mark.table.delete_column", Box::new(DeleteColumn)), + ]); + vec![ + table, + Menu::new(text(cx, "menu.view", &[])).items([ + MenuItem::action(label(Mode::Visual, cx), VisualMode), + MenuItem::action(label(Mode::Source, cx), SourceMode), + MenuItem::action(label(Mode::Preview, cx), PreviewMode), + MenuItem::separator(), + checked("mark.look.focus", Box::new(look::FocusMode), look.focus), + checked( + "mark.look.typewriter", + Box::new(look::Typewriter), + look.typewriter, + ), + MenuItem::separator(), + checked( + "mark.look.narrow", + Box::new(look::Narrow), + look.width == Width::Narrow, + ), + checked( + "mark.look.medium", + Box::new(look::Medium), + look.width == Width::Medium, + ), + checked( + "mark.look.wide", + Box::new(look::Wide), + look.width == Width::Wide, + ), + MenuItem::separator(), + checked( + "mark.look.sans", + Box::new(look::Sans), + look.font == Font::Sans, + ), + checked( + "mark.look.serif", + Box::new(look::Serif), + look.font == Font::Serif, + ), + checked( + "mark.look.mono", + Box::new(look::Mono), + look.font == Font::Mono, + ), + MenuItem::separator(), + item("mark.look.larger", Box::new(look::Larger)), + item("mark.look.smaller", Box::new(look::Smaller)), + item("mark.look.actual", Box::new(look::ActualSize)), + MenuItem::separator(), + item("mark.outside.check", Box::new(CheckDisk)), + ]), + ] +} diff --git a/apps/ely-mark/src/look.rs b/apps/ely-mark/src/look.rs new file mode 100644 index 0000000..780c580 --- /dev/null +++ b/apps/ely-mark/src/look.rs @@ -0,0 +1,166 @@ +//! The writing view, kept across launches (MARK-09, MARK-12). + +use std::{io, path::Path}; + +use gpui::{Context, actions}; +use serde::{Deserialize, Serialize}; + +use crate::desk::Desk; + +actions!( + mark, + [ + FocusMode, Typewriter, Narrow, Medium, Wide, Sans, Serif, Mono, Larger, Smaller, ActualSize + ] +); + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Width { + Narrow, + Medium, + Wide, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Font { + Sans, + Serif, + Mono, +} + +/// How text shows; the Markdown never changes. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Look { + pub focus: bool, + pub typewriter: bool, + pub width: Width, + pub font: Font, + /// Percent of the app's text size. + pub size: u32, +} + +impl Default for Look { + fn default() -> Self { + Look { + focus: false, + typewriter: false, + width: Width::Medium, + font: Font::Sans, + size: 100, + } + } +} + +/// The kept look; none kept is the default. +pub fn load(file: &Path) -> io::Result { + match std::fs::read(file) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Look::default()), + Err(error) => Err(error), + } +} + +/// Sizes step by a tenth, from 70 to 200 percent. +const SIZES: (u32, u32, u32) = (70, 200, 10); + +impl Desk { + /// Changes the look; pages, file and menus follow. + pub fn relook(&mut self, change: impl FnOnce(&mut Look), cx: &mut Context) { + change(&mut self.look); + self.look.size = self.look.size.clamp(SIZES.0, SIZES.1); + log::info!("mark: the look is now {:?}", self.look); + self.show_look(cx); + let json = serde_json::to_vec_pretty(&self.look).expect("a look serializes"); + if let Err(error) = ely_files::save_atomic(&self.look_file, &json, 0) { + log::error!("mark: the look did not save: {error}"); + } + let menus = crate::keys::menus(&self.look, cx); + ely_ui::set_document_menus( + ely_platform::AppId::Mark, + &ely_ui::commands::RENAMING, + menus, + cx, + ); + cx.notify(); + } + + /// Every tab's page takes the look. + pub fn show_look(&mut self, cx: &mut Context) { + let look = serde_json::to_value(&self.look).expect("a look serializes"); + for tab in &self.core.tabs { + let look = look.clone(); + tab.view.update(cx, |view, cx| view.set_look(look, cx)); + } + } + + pub fn look_actions(element: gpui::Div, cx: &mut Context) -> gpui::Div { + use gpui::InteractiveElement as _; + let step = SIZES.2; + element + .on_action(cx.listener(|desk, _: &FocusMode, _, cx| { + desk.relook(|look| look.focus = !look.focus, cx) + })) + .on_action(cx.listener(|desk, _: &Typewriter, _, cx| { + desk.relook(|look| look.typewriter = !look.typewriter, cx) + })) + .on_action(cx.listener(|desk, _: &Narrow, _, cx| { + desk.relook(|look| look.width = Width::Narrow, cx) + })) + .on_action(cx.listener(|desk, _: &Medium, _, cx| { + desk.relook(|look| look.width = Width::Medium, cx) + })) + .on_action( + cx.listener(|desk, _: &Wide, _, cx| { + desk.relook(|look| look.width = Width::Wide, cx) + }), + ) + .on_action( + cx.listener(|desk, _: &Sans, _, cx| desk.relook(|look| look.font = Font::Sans, cx)), + ) + .on_action( + cx.listener(|desk, _: &Serif, _, cx| { + desk.relook(|look| look.font = Font::Serif, cx) + }), + ) + .on_action( + cx.listener(|desk, _: &Mono, _, cx| desk.relook(|look| look.font = Font::Mono, cx)), + ) + .on_action( + cx.listener(move |desk, _: &Larger, _, cx| { + desk.relook(|look| look.size += step, cx) + }), + ) + .on_action(cx.listener(move |desk, _: &Smaller, _, cx| { + desk.relook(|look| look.size = look.size.saturating_sub(step), cx) + })) + .on_action( + cx.listener(|desk, _: &ActualSize, _, cx| desk.relook(|look| look.size = 100, cx)), + ) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn a_kept_look_reads_back_and_none_is_default() { + let dir = tempfile::tempdir().expect("a temp dir"); + let file = dir.path().join("look.json"); + assert_eq!( + super::load(&file).expect("none kept"), + super::Look::default() + ); + std::fs::write( + &file, + r#"{"focus":true,"typewriter":false,"width":"wide","font":"serif","size":120}"#, + ) + .expect("a look"); + let look = super::load(&file).expect("it reads"); + assert!(look.focus); + assert_eq!( + (look.width, look.font, look.size), + (super::Width::Wide, super::Font::Serif, 120) + ); + } +} diff --git a/apps/ely-mark/src/main.rs b/apps/ely-mark/src/main.rs new file mode 100644 index 0000000..203de3a --- /dev/null +++ b/apps/ely-mark/src/main.rs @@ -0,0 +1,30 @@ +mod attach; +mod autosave; +mod compare; +mod desk; +mod engine; +mod export; +mod finding; +mod folder; +mod keys; +mod look; +mod outside; +mod page; +mod paper; +mod places; +mod rename; +mod restore; +mod side; +mod text; +mod vcs; +mod view; + +use ely_platform::AppId; + +fn main() { + // CEF runs its helper processes from this same binary. + if let Some(code) = ely_web_content::subprocess() { + std::process::exit(code); + } + ely_ui::run(AppId::Mark, desk::open); +} diff --git a/apps/ely-mark/src/outside.rs b/apps/ely-mark/src/outside.rs new file mode 100644 index 0000000..daa68d6 --- /dev/null +++ b/apps/ely-mark/src/outside.rs @@ -0,0 +1,347 @@ +//! Changes made to a file outside ElyMark (MARK-08, §14.4). + +use std::{ + io, + path::{Path, PathBuf}, + time::Duration, +}; + +use ely_files::{Fingerprint, Policy, PolicyEngine, SaveState}; +use ely_gpui_component::{feedback::Banner, primitives::Severity}; +use ely_office::prelude::*; +use futures::{StreamExt as _, channel::mpsc}; +use gpui::Context; +use notify::{RecommendedWatcher, RecursiveMode, Watcher as _}; + +use crate::desk::Desk; +use crate::text::Source; +use ely_ui::i18n::text; + +/// Bursts, such as a save's rename, settle into one look. +const SETTLE: Duration = Duration::from_millis(200); +/// An atomic replace empties the path this long at most. +const GAP: Duration = Duration::from_millis(300); + +/// What happened to a tab's file on disk. +#[derive(Default)] +pub enum Outside { + #[default] + Clear, + /// Changed under unsaved text: the disk's version, as read. + Changed(Source, Fingerprint), + Deleted, + /// Changes cannot be followed; why. + Unread(String), +} + +/// What ElyMark keeps per tab. +pub struct Extra { + pub outside: Outside, + /// The text as last loaded or saved: the merge base. + pub base: String, + pub policy: PolicyEngine, + /// The page change autosave last heard. + pub heard: u64, + /// The policy revision an autosave writes. + pub writing: Option, + /// A look put off while a save runs. + pub recheck: bool, + /// Its recovery folder's name, once given. + pub slot: String, + /// The last recovery point's mark, and one in flight. + pub pointed: Option, + pub pointing: bool, + /// A kept tab's mode, place and text came back once. + pub restored: bool, +} + +impl Default for Extra { + fn default() -> Self { + Extra { + outside: Outside::Clear, + base: String::new(), + policy: PolicyEngine::new(Policy::MarkAutoSave, false), + heard: 0, + writing: None, + recheck: false, + slot: String::new(), + pointed: None, + pointing: false, + restored: false, + } + } +} + +/// The file's bytes and the fingerprint they match. +fn read_still(path: &Path) -> io::Result<(Fingerprint, Source)> { + // A writer mid-way changes the fingerprint; read again. + for _ in 0..5 { + let before = Fingerprint::of(path)?; + let bytes = std::fs::read(path)?; + if Fingerprint::of(path)? == before { + return Ok((before, Source::decode(bytes)?)); + } + } + Err(io::Error::other("the file kept changing while read")) +} + +impl Desk { + /// Watches every tab's folder; a change looks at the tabs. + pub fn watch_folders(&mut self, cx: &mut Context) { + let mut dirs: Vec = self + .core + .tabs + .iter() + .filter_map(|tab| Some(tab.session.path()?.parent()?.to_path_buf())) + .collect(); + dirs.sort(); + dirs.dedup(); + if dirs == self.watched { + return; + } + self.watcher = None; + self.watched = dirs.clone(); + if dirs.is_empty() { + return; + } + match watch(&dirs, cx) { + Ok(watcher) => self.watcher = Some(watcher), + Err(error) => self.unwatched(error.to_string(), cx), + } + } + + /// Watching failed: every file says so and offers a look. + fn unwatched(&mut self, why: String, cx: &mut Context) { + log::error!("mark: watching files failed: {why}"); + let ids: Vec = self.core.tabs.iter().map(|tab| tab.id).collect(); + for id in ids { + self.set_outside(id, Outside::Unread(why.clone()), cx); + } + } + + pub fn look_all(&mut self, cx: &mut Context) { + // A write, ours or not, may change what Git sees. + self.ask_git(cx); + let ids: Vec = self.core.tabs.iter().map(|tab| tab.id).collect(); + for id in ids { + self.look(id, cx); + } + } + + /// Compares tab `id`'s file with its last read or write. + pub fn look(&mut self, id: u64, cx: &mut Context) { + let Some(tab) = self.tab_mut(id) else { + return; + }; + let Some(path) = tab.session.path().map(Path::to_path_buf) else { + return; + }; + if tab.exporting || tab.session.state() == SaveState::Saving { + tab.extra.recheck = true; + return; + } + tab.extra.recheck = false; + let ours = tab.session.fingerprint(); + match Fingerprint::of(&path) { + Ok(now) if Some(now) == ours => { + if !matches!(tab.extra.outside, Outside::Changed(..)) { + self.set_outside(id, Outside::Clear, cx); + } + } + Ok(_) => self.read_disk(id, path, cx), + Err(error) if error.kind() == io::ErrorKind::NotFound => self.missing(id, path, cx), + Err(error) => self.set_outside(id, Outside::Unread(error.to_string()), cx), + } + } + + /// Gone: an atomic replace, a rename, or a delete. + fn missing(&mut self, id: u64, path: PathBuf, cx: &mut Context) { + cx.spawn(async move |desk, cx| { + cx.background_executor().timer(GAP).await; + desk.update(cx, |desk, cx| desk.still_missing(id, path, cx)) + .ok(); + }) + .detach(); + } + + fn still_missing(&mut self, id: u64, path: PathBuf, cx: &mut Context) { + let Some(tab) = self.tab(id).filter(|tab| tab.session.path() == Some(&path)) else { + return; + }; + if path.exists() { + return self.look(id, cx); + } + let found = match (tab.registry, path.parent()) { + (Some(registry), Some(dir)) => self.core.sessions.renamed_in(registry, dir), + _ => Ok(None), + }; + match found { + Ok(Some(to)) => { + log::info!("mark: {} was renamed outside", path.display()); + self.follow(id, to, cx); + self.look(id, cx); + } + Ok(None) => { + log::warn!("mark: {} was deleted outside", path.display()); + self.set_outside(id, Outside::Deleted, cx); + } + Err(error) => self.set_outside(id, Outside::Unread(error.to_string()), cx), + } + } + + /// Changed: a clean tab follows, a dirty one asks. + fn read_disk(&mut self, id: u64, path: PathBuf, cx: &mut Context) { + let reading = path.clone(); + cx.spawn(async move |desk, cx| { + let read = cx + .background_executor() + .spawn(async move { read_still(&reading) }) + .await; + desk.update(cx, |desk, cx| desk.disk_read(id, path, read, cx)) + .ok(); + }) + .detach(); + } + + fn disk_read( + &mut self, + id: u64, + path: PathBuf, + read: io::Result<(Fingerprint, Source)>, + cx: &mut Context, + ) { + let Some(tab) = self.tab(id).filter(|tab| tab.session.path() == Some(&path)) else { + return log::info!("mark: a read for a moved or closed tab was dropped"); + }; + let (fingerprint, disk) = match read { + Ok(read) => read, + Err(error) => return self.set_outside(id, Outside::Unread(error.to_string()), cx), + }; + let current = tab.doc.lock().expect("the text lock").current.clone(); + let dirty = tab.dirty(cx); + log::info!("mark: {} changed outside; unsaved {dirty}", path.display()); + match (disk.text == current, dirty) { + (true, _) | (false, false) => self.take_disk(id, disk, fingerprint, cx), + (false, true) => { + let tab = self.tab_mut(id).expect("open"); + tab.session.external_modified(); + tab.extra.policy.external_modified(); + self.set_outside(id, Outside::Changed(disk, fingerprint), cx); + // An open comparison shows the newest disk text. + if self + .comparing + .as_ref() + .is_some_and(|comparing| comparing.id == id) + { + self.compare(id, cx); + } + } + } + } + + /// The disk's text replaces the tab's; autosave starts afresh. + pub fn take_disk( + &mut self, + id: u64, + disk: Source, + fingerprint: Fingerprint, + cx: &mut Context, + ) { + let Some(tab) = self.tab_mut(id) else { + return; + }; + if let Err(error) = tab.session.reload_at(fingerprint) { + return self.set_outside(id, Outside::Unread(error.to_string()), cx); + } + tab.extra.base = disk.text.clone(); + tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true); + let (view, registry) = (tab.view.clone(), tab.registry); + let path = tab.session.path().map(Path::to_path_buf); + // An atomic replace is a new file underneath. + if let (Some(registry), Some(path)) = (registry, path) + && let Err(error) = self.core.sessions.track(registry, &path) + { + log::warn!("mark: the replaced file did not register: {error}"); + } + view.update(cx, |view, cx| view.refresh(disk, cx)); + self.set_outside(id, Outside::Clear, cx); + } + + pub fn set_outside(&mut self, id: u64, outside: Outside, cx: &mut Context) { + if let Some(tab) = self.tab_mut(id) { + tab.extra.outside = outside; + cx.notify(); + } + } + + /// The active file's trouble on disk, with its way out. + pub fn outside_banner(&self, cx: &mut Context) -> Option { + let tab = self.active_tab()?; + let (id, name) = (tab.id, tab.name.to_string()); + let this = cx.entity(); + let (key, why, action, severity) = match &tab.extra.outside { + Outside::Clear => return None, + Outside::Changed(..) => ( + "mark.outside.changed", + None, + "mark.outside.compare", + Severity::Warning, + ), + Outside::Deleted => ( + "mark.outside.deleted", + None, + "mark.outside.save_as", + Severity::Warning, + ), + Outside::Unread(why) => ( + "mark.outside.unread", + Some(why.clone()), + "mark.outside.refresh", + Severity::Danger, + ), + }; + let why = why.unwrap_or_default(); + let said = text(cx, key, &[("name", &name), ("why", &why)]); + let label = text(cx, action, &[]); + let banner = Banner::new("mark-outside", severity, said); + Some(banner.action(label, move |_, cx| { + this.update(cx, |desk, cx| { + match desk.tab(id).map(|tab| &tab.extra.outside) { + Some(Outside::Changed(..)) => desk.compare(id, cx), + Some(Outside::Deleted) => desk.save_as(id, None, cx), + _ => desk.look(id, cx), + } + }) + })) + } +} + +/// Watches `dirs`; each settled burst looks at the tabs. +fn watch(dirs: &[PathBuf], cx: &mut Context) -> notify::Result { + let (sender, mut changes) = mpsc::unbounded(); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let _ = sender.unbounded_send(event.map(|_| ()).map_err(|error| error.to_string())); + })?; + for dir in dirs { + watcher.watch(dir, RecursiveMode::NonRecursive)?; + } + cx.spawn(async move |desk, cx| { + while let Some(first) = changes.next().await { + cx.background_executor().timer(SETTLE).await; + let mut failed = first.err(); + while let Ok(more) = changes.try_recv() { + failed = more.err().or(failed); + } + let looked = desk.update(cx, |desk, cx| match failed { + Some(why) => desk.unwatched(why, cx), + None => desk.look_all(cx), + }); + if looked.is_err() { + break; + } + } + }) + .detach(); + log::info!("mark: watching {} folders", dirs.len()); + Ok(watcher) +} diff --git a/apps/ely-mark/src/page.rs b/apps/ely-mark/src/page.rs new file mode 100644 index 0000000..2d41dc7 --- /dev/null +++ b/apps/ely-mark/src/page.rs @@ -0,0 +1,92 @@ +//! The bundled page: its folder and its input. + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, bail}; +use ely_web_content::WebView; +use gpui::App; +use serde_json::json; + +/// The app's Resources, else this build's `engines/content`. +pub fn content() -> Result { + let bundled = std::env::current_exe()? + .parent() + .and_then(|dir| Some(dir.parent()?.join("Resources/content"))); + let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark"); + for dir in bundled.into_iter().chain([built]) { + if dir.join("index.html").is_file() { + log::info!("mark: page from {}", dir.display()); + return Ok(dir); + } + } + bail!("ElyMark's page is not built; run scripts/build-mark-content.sh") +} + +/// How the page shows a document (MARK-02). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Visual, + Source, + Preview, +} + +impl Mode { + pub const ALL: [Mode; 3] = [Mode::Visual, Mode::Source, Mode::Preview]; + + pub fn name(self) -> &'static str { + match self { + Mode::Visual => "visual", + Mode::Source => "source", + Mode::Preview => "preview", + } + } + + pub fn parse(name: &str) -> Option { + Mode::ALL.into_iter().find(|mode| mode.name() == name) + } +} + +/// Shows `markdown` as `mode` after load `load`, maybe placed. +pub fn open( + view: &WebView, + markdown: &str, + (mode, load, place): (Mode, u64, Option), + folder: &Path, + cx: &App, +) { + let text = |key| ely_ui::i18n::text(cx, key, &[]).to_string(); + let texts = json!({ + "searchLanguage": text("mark.search_language"), + "noResult": text("mark.no_result"), + "copy": text("mark.copy"), + "preview": text("mark.preview"), + "loading": text("mark.loading"), + "edit": text("mark.edit"), + "hide": text("mark.hide"), + }); + // Relative links and images resolve in the document's folder. + let base = format!("{}/", ely_web_content::file_url(folder)); + view.run_js(&format!( + "ely.open({}, '{}', {texts}, {load}, {}, {})", + json!(markdown), + mode.name(), + json!(base), + json!(place) + )); +} + +/// The same text in another mode; pending edits go first. +pub fn switch(view: &WebView, mode: Mode) { + view.run_js(&format!("ely.mode('{}')", mode.name())); +} + +/// The folder for relative links and images. +pub fn folder(view: &WebView, folder: &Path) { + let base = format!("{}/", ely_web_content::file_url(folder)); + view.run_js(&format!("ely.base({})", json!(base))); +} + +/// Calls `ely.(argument)` in the page. +pub fn call(view: &WebView, name: &str, argument: &serde_json::Value) { + view.run_js(&format!("ely.{name}({argument})")); +} diff --git a/apps/ely-mark/src/paper.rs b/apps/ely-mark/src/paper.rs new file mode 100644 index 0000000..fbe5188 --- /dev/null +++ b/apps/ely-mark/src/paper.rs @@ -0,0 +1,229 @@ +//! Rendered Markdown as files: HTML pages and their images (MARK-11). + +use std::path::{Path, PathBuf}; + +use base64::Engine as _; + +/// A whole HTML page: the body, its title, its stylesheet. +pub fn page(title: &str, style: &str, body: &str) -> String { + format!( + "\n\n\n\n{}\n{style}\n\n\n{body}\n\n\n", + escape(title) + ) +} + +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// `%XX` decoded, as links to files are written. +fn decoded(text: &str) -> String { + let bytes = text.as_bytes(); + let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0); + while at < bytes.len() { + let hex = bytes + .get(at + 1..at + 3) + .and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()); + match (bytes[at], hex) { + (b'%', Some(byte)) => (out.push(byte), at += 3), + (byte, _) => (out.push(byte), at += 1), + }; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// One path segment for a URL: reserved bytes percent-escaped. +pub fn segment(name: &str) -> String { + let mut out = String::new(); + for byte in name.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(byte as char) + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +/// A file image's path; web links stay as they are. +fn local(src: &str, folder: &Path) -> Option { + if src.starts_with("data:") || src.starts_with('#') { + return None; + } + if let Some(path) = src.strip_prefix("file://") { + return Some(PathBuf::from(decoded(path))); + } + if src.contains("://") { + return None; + } + let path = PathBuf::from(decoded(src.split(['?', '#']).next().unwrap_or(src))); + Some(if path.is_absolute() { + path + } else { + folder.join(path) + }) +} + +fn kind(path: &Path) -> &'static str { + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default(); + match extension.to_lowercase().as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "bmp" => "image/bmp", + "avif" => "image/avif", + _ => "application/octet-stream", + } +} + +/// The page's HTML; image `src` values replaced by numbered tokens. +#[derive(Clone, Debug, Default)] +pub struct Marked { + pub html: String, + /// The original `src` values, decoded, in order. + pub images: Vec, + pub token: String, +} + +impl Marked { + /// From the page's answer; anything missing is an error. + pub fn read(rendered: &serde_json::Value) -> std::io::Result { + let bad = || std::io::Error::other("the page's render lacks its parts"); + let html = rendered["html"].as_str().ok_or_else(bad)?.to_owned(); + let token = rendered["token"].as_str().ok_or_else(bad)?.to_owned(); + let images = rendered["images"].as_array().ok_or_else(bad)?; + let images = images + .iter() + .map(|src| src.as_str().map(str::to_owned).ok_or_else(bad)); + let images = images.collect::>()?; + Ok(Marked { + html, + images, + token, + }) + } + + /// Each mark swapped for `each` of its file; unread listed. + fn rewrite( + &self, + folder: &Path, + mut each: impl FnMut(&Path) -> Option, + ) -> (String, Vec) { + let (mut html, mut missing) = (self.html.clone(), Vec::new()); + for (n, src) in self.images.iter().enumerate() { + let new = match local(src, folder) { + None => src.clone(), + Some(path) => each(&path).unwrap_or_else(|| { + missing.push(src.clone()); + src.clone() + }), + }; + // Quoted, so mark 1 never matches inside mark 10. + let mark = format!("\"{}{n}\"", self.token); + html = html.replacen(&mark, &format!("\"{}\"", escape(&new)), 1); + } + (html, missing) + } + + /// Images as data URLs: one file carries all (self-contained). + pub fn inline(&self, folder: &Path) -> (String, Vec) { + self.rewrite(folder, |path| { + let bytes = std::fs::read(path).ok()?; + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + Some(format!("data:{};base64,{data}", kind(path))) + }) + } + + /// Images linked into the `assets` folder; and the copies. + pub fn linked( + &self, + folder: &Path, + assets: &str, + ) -> (String, Vec<(PathBuf, String)>, Vec) { + let mut copies: Vec<(PathBuf, String)> = Vec::new(); + let (html, missing) = self.rewrite(folder, |path| { + if !path.is_file() { + return None; + } + if let Some((_, name)) = copies.iter().find(|(from, _)| from == path) { + return Some(format!("{}/{}", segment(assets), segment(name))); + } + let file = path.file_name()?.to_string_lossy().into_owned(); + // Same names from different folders stay apart. + let name = format!("{}-{file}", copies.len() + 1); + let url = format!("{}/{}", segment(assets), segment(&name)); + copies.push((path.to_path_buf(), name)); + Some(url) + }); + (html, copies, missing) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// HTML as the page marks it. + fn marked(images: &[&str]) -> Marked { + let token = "ely-image-t-".to_owned(); + let html = (0..images.len()) + .map(|n| format!("\"a b src='x'\" src=\"{token}{n}\">")) + .collect(); + Marked { + html, + images: images.iter().map(|src| src.to_string()).collect(), + token, + } + } + + #[test] + fn images_inline_and_links_stay() { + let dir = tempfile::tempdir().expect("a temp dir"); + std::fs::create_dir(dir.path().join("assets")).expect("assets"); + std::fs::write(dir.path().join("assets/a b.png"), b"png").expect("an image"); + let page = marked(&["assets/a%20b.png", "https://e.x/y.png", "gone.png"]); + let (out, missing) = page.inline(dir.path()); + assert!(out.contains(r#"src="data:image/png;base64,cG5n""#), "{out}"); + assert!(out.contains(r#"src="https://e.x/y.png""#)); + assert!( + out.contains(r#"alt="a > b src='x'""#), + "alt text stays: {out}" + ); + assert_eq!(missing, vec!["gone.png".to_string()]); + } + + #[test] + fn linked_images_copy_once_under_encoded_names() { + let dir = tempfile::tempdir().expect("a temp dir"); + std::fs::write(dir.path().join("a#b.png"), b"1").expect("an image"); + let mut page = marked(&["a%23b.png"; 11]); + page.images[10] = "a%23b.png".into(); + let (out, copies, missing) = page.linked(dir.path(), "report#1_files"); + assert_eq!( + out.matches("src=\"report%231_files/1-a%23b.png\"").count(), + 11, + "{out}" + ); + assert_eq!(copies.len(), 1); + assert!(missing.is_empty()); + } + + #[test] + fn a_page_lacking_its_parts_is_an_error() { + assert!(Marked::read(&serde_json::json!({ "html": "

" })).is_err()); + } + + #[test] + fn titles_escape() { + assert!(page("a&", "", "").contains("a<b>&")); + } +} diff --git a/apps/ely-mark/src/places.rs b/apps/ely-mark/src/places.rs new file mode 100644 index 0000000..4159791 --- /dev/null +++ b/apps/ely-mark/src/places.rs @@ -0,0 +1,162 @@ +//! Folders, files, the recent list, going to a line. + +use std::path::PathBuf; + +use ely_gpui_component::primitives::Severity; +use ely_office::prelude::*; +use ely_ui::i18n::text; +use gpui::{Context, Window}; + +use crate::desk::Desk; +use crate::folder::{Folder, markdown_under}; + +impl Desk { + /// A folder lists beside; a file opens in a tab. + pub fn open_path(&mut self, path: PathBuf, cx: &mut Context) { + match path.is_dir() { + true => self.open_folder(path, cx), + false => self.open_file(path, cx), + } + } + + /// Lists `root`'s Markdown files off the window (MARK-01). + pub fn open_folder(&mut self, root: PathBuf, cx: &mut Context) { + log::info!("mark: opens folder {}", root.display()); + cx.spawn(async move |desk, cx| { + let listed = root.clone(); + // The real root: listed files compare with tabs' paths. + let files = cx + .background_executor() + .spawn(async move { + let real = std::fs::canonicalize(&listed)?; + Ok::<_, std::io::Error>((markdown_under(&real)?, real)) + }) + .await; + desk.update(cx, |desk, cx| match files { + Ok((files, real)) => { + desk.remember(&real, cx); + desk.folder = Some(Folder { root: real, files }); + desk.listings += 1; + cx.notify(); + } + Err(error) => { + log::error!("mark: {} did not list: {error}", root.display()); + let (name, error) = (root.display().to_string(), error.to_string()); + let said = text( + cx, + "office.error.open", + &[("name", &name), ("error", &error)], + ); + desk.toast(said, Severity::Danger, cx); + } + }) + .ok(); + }) + .detach(); + } + + /// The open folder again, as the disk has it now. + pub fn relist(&mut self, cx: &mut Context) { + if let Some(root) = self.folder.as_ref().map(|folder| folder.root.clone()) { + self.open_folder(root, cx); + } + } + + /// Puts `path` first in the recent list. + pub fn remember(&mut self, path: &std::path::Path, cx: &mut Context) { + // One spelling per file: its real path. + let remembered = std::fs::canonicalize(path) + .and_then(|path| ely_files::recent::remember(&self.recent_file, &path)); + match remembered { + Ok(list) => self.recent = list, + Err(error) => { + log::error!("mark: the recent list did not save: {error}"); + self.toast( + text(cx, "office.error.engine", &[("error", &error.to_string())]), + Severity::Danger, + cx, + ); + } + } + } + + /// Tab `id` shows `path`; the recent list follows. + pub fn track_recent(&mut self, id: u64, path: &std::path::Path, cx: &mut Context) { + let Ok(real) = std::fs::canonicalize(path) else { + return log::warn!("mark: {} is gone; the recent list keeps it", path.display()); + }; + if self.remembered.get(&id) == Some(&real) { + return; + } + if let Some(old) = self.remembered.insert(id, real) + && !old.exists() + { + self.forget(&old, cx); + } + self.remember(path, cx); + } + + /// Takes a path that is gone out of the list. + pub fn forget(&mut self, path: &std::path::Path, cx: &mut Context) { + match ely_files::recent::forget(&self.recent_file, path) { + Ok(()) => self.recent.retain(|known| known != path), + Err(error) => { + log::error!("mark: the recent list did not save: {error}"); + let said = text(cx, "office.error.engine", &[("error", &error.to_string())]); + self.toast(said, Severity::Danger, cx); + } + } + } + + /// `place` in `tab`, else in `file` once open. + pub fn open_at( + &mut self, + tab: Option, + file: PathBuf, + place: serde_json::Value, + window: &mut Window, + cx: &mut Context, + ) { + let held = tab.or_else(|| self.tab_of(&file)); + if let Some(id) = held.filter(|id| self.tab(*id).is_some()) { + self.core.active = Some(id); + let view = self.tab(id).map(|open| open.view.clone()); + if let Some(view) = view { + view.update(cx, |view, cx| view.reveal(place, cx)); + // After the list's own key handling, which keeps focus. + let focus = view.read(cx).focus.clone(); + window.defer(cx, move |window, cx| window.focus(&focus, cx)); + } + return cx.notify(); + } + // A file gone is reported by the open itself. + if let Ok(real) = std::fs::canonicalize(&file) { + self.going = Some((real, place)); + } + self.open_file(file, cx); + } + + /// The tab showing `path`, by real path. + pub fn tab_of(&self, path: &std::path::Path) -> Option { + let real = std::fs::canonicalize(path).ok()?; + let held = |tab: &&ely_office::Tab| { + tab.session + .path() + .and_then(|open| std::fs::canonicalize(open).ok()) + == Some(real.clone()) + }; + self.core.tabs.iter().find(held).map(|tab| tab.id) + } + + /// Tab `id`'s text as the page last sent it. + pub fn text_of(&self, id: u64) -> Option { + Some( + self.tab(id)? + .doc + .lock() + .expect("the text lock") + .current + .clone(), + ) + } +} diff --git a/apps/ely-mark/src/rename.rs b/apps/ely-mark/src/rename.rs new file mode 100644 index 0000000..d056bd5 --- /dev/null +++ b/apps/ely-mark/src/rename.rs @@ -0,0 +1,165 @@ +//! Renaming the open file in its folder (MARK-01). + +use std::{io, path::PathBuf}; + +use ely_files::SaveState; +use ely_gpui_component::{forms::TextInput, overlays::PromptDialog, primitives::Severity}; +use ely_office::{Host as _, files::file_name, prelude::*}; +use ely_ui::{commands, i18n::text}; +use gpui::{AnyElement, AppContext as _, Context, IntoElement as _, Window}; + +use crate::desk::Desk; + +/// The text key saying why `name` is refused. +fn refused(name: &str) -> Option<&'static str> { + let name = name.trim(); + if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']) { + return Some("mark.rename.bad"); + } + let extension = std::path::Path::new(name) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_lowercase(); + (!Desk::KIND.kept.contains(&extension.as_str())).then_some("mark.rename.format") +} + +impl Desk { + /// Asks a new name for the active file. + pub fn on_rename(&mut self, _: &commands::Rename, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return; + }; + let (id, name) = (tab.id, tab.name.clone()); + let field = cx.new(|cx| { + let mut field = TextInput::new(window, cx); + field.set_text(name, cx); + field + }); + self.naming = Some((id, field)); + cx.notify(); + } + + pub fn naming_element(&self, cx: &mut Context) -> Option { + let (id, field) = self.naming.as_ref()?; + let (closed, sent, id) = (cx.entity(), cx.entity(), *id); + let formats = Desk::KIND.kept.join(", ."); + let bad = text(cx, "mark.rename.bad", &[]); + let format = text(cx, "mark.rename.format", &[("formats", &formats)]); + let prompt = PromptDialog::new( + "mark-rename", + text(cx, "mark.rename", &[]), + field, + move |_, cx| { + closed.update(cx, |desk, cx| { + desk.naming = None; + cx.notify(); + }) + }, + ) + .label(text(cx, "mark.rename.name", &[])) + .submit(text(cx, "mark.rename.apply", &[])) + .check(move |name| match refused(name) { + None => Ok(()), + Some("mark.rename.bad") => Err(bad.clone()), + Some(_) => Err(format.clone()), + }) + .on_submit(move |name, _, cx| { + sent.update(cx, |desk, cx| { + desk.naming = None; + desk.rename(id, name, cx); + }) + }); + Some(prompt.into_any_element()) + } + + /// Renames tab `id`'s file; an existing name is refused. + fn rename(&mut self, id: u64, name: &str, cx: &mut Context) { + let Some(tab) = self.tab(id) else { + return; + }; + let Some(from) = tab.session.path().map(|path| path.to_path_buf()) else { + return self.toast(text(cx, "mark.rename.untitled", &[]), Severity::Warning, cx); + }; + if tab.exporting || tab.session.state() == SaveState::Saving { + return self.toast(text(cx, "mark.rename.saving", &[]), Severity::Warning, cx); + } + let to = from.with_file_name(name.trim()); + let (source, target) = (from.clone(), to.clone()); + cx.spawn(async move |this, cx| { + let done = cx + .background_executor() + .spawn(async move { ely_files::rename_new(&source, &target) }) + .await; + this.update(cx, |desk, cx| desk.renamed(id, from, to, done, cx)) + .ok(); + }) + .detach(); + } + + /// The tab follows only if it holds the old path. + fn renamed( + &mut self, + id: u64, + from: PathBuf, + to: PathBuf, + done: io::Result<()>, + cx: &mut Context, + ) { + if let Err(error) = done { + log::error!("mark: {} did not rename: {error}", from.display()); + let (name, error) = (file_name(&from), error.to_string()); + let said = text( + cx, + "mark.rename.failed", + &[("name", &name), ("error", &error)], + ); + return self.toast(said, Severity::Danger, cx); + } + log::info!("mark: renamed {} to {}", from.display(), to.display()); + match self.tab(id).and_then(|tab| tab.session.path()) { + Some(path) if path == from => self.follow(id, to, cx), + // The watcher saw the rename first. + Some(path) if path == to => log::info!("mark: the tab already follows"), + _ => log::info!("mark: the renamed file's tab is gone"), + } + } + + /// Tab `id`'s file now lives at `to`. + pub fn follow(&mut self, id: u64, to: PathBuf, cx: &mut Context) { + let Some(registry) = self.tab(id).map(|tab| tab.registry) else { + return; + }; + // One session moves: an open in the gap focused it. + if let Some(registry) = registry + && let Err(error) = self.core_mut().sessions.track(registry, &to) + { + log::error!("mark: the renamed file did not register: {error}"); + let said = text(cx, "office.error.engine", &[("error", &error.to_string())]); + self.toast(said, Severity::Danger, cx); + } + if let Some(tab) = self.tab_mut(id) { + tab.session.renamed(to.clone()); + tab.name = file_name(&to); + } + if let Some(view) = self.tab(id).map(|tab| tab.view.clone()) { + self.observed(id, &view, cx); + } + // The tree and folder search see the new name. + self.relist(cx); + cx.notify(); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn names_need_no_slashes_and_a_markdown_end() { + for bad in ["", " ", "..", "a/b.md", "a\\b.md"] { + assert_eq!(super::refused(bad), Some("mark.rename.bad"), "{bad:?}"); + } + assert_eq!(super::refused("notes.txt"), Some("mark.rename.format")); + assert_eq!(super::refused("Notes.MD"), None); + assert_eq!(super::refused("a.markdown"), None); + } +} diff --git a/apps/ely-mark/src/restore.rs b/apps/ely-mark/src/restore.rs new file mode 100644 index 0000000..eeb981c --- /dev/null +++ b/apps/ely-mark/src/restore.rs @@ -0,0 +1,395 @@ +//! The session kept across launches (MARK-12, §6.2, §6.3). + +use std::{ + io, + path::{Path, PathBuf}, + time::SystemTime, +}; + +use ely_files::{Crash, Marker, Recovery}; +use ely_office::prelude::*; +use ely_ui::i18n::text; +use gpui::Context; +use serde::{Deserialize, Serialize}; + +use crate::desk::Desk; +use crate::engine::mark; +use crate::page::Mode; +use crate::view::Place; + +/// A kept tab: file, mode, place, recovery folder. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Kept { + path: Option, + name: String, + mode: String, + place: Option, + slot: String, +} + +#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] +struct Saved { + tabs: Vec, +} + +/// A kept tab yet to show; recovered text copied. +struct Waiting { + kept: Kept, + real: Option, + text: Option<(String, SystemTime)>, +} + +/// The session's files and what still waits to restore. +pub struct Keeping { + dir: PathBuf, + crash: Option, + waiting: Vec, + written: Option, + /// A quit began: the kept list stays as then. + pub leaving: bool, + /// The list as kept when the quit began. + frozen: Vec, +} + +fn recovery(dir: &Path, slot: &str) -> PathBuf { + dir.join("recovery").join(slot) +} + +/// The newest point's text in `slot`, after a crash. +fn recovered(dir: &Path, slot: &str) -> io::Result> { + let Some(point) = Recovery::new(recovery(dir, slot)) + .points()? + .into_iter() + .next() + else { + return Ok(None); + }; + let bytes = std::fs::read(&point.content)?; + let text = String::from_utf8(bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + Ok(Some((text, point.at))) +} + +impl Keeping { + /// Reads the last session; a crash brings its unsaved text. + pub fn start(dir: &Path) -> io::Result<(Keeping, Vec, Vec)> { + let marker = Crash::check(dir, ely_files::alive)?; + log::info!("mark: the last session ended {marker:?}"); + let crash = Crash::open(dir, std::process::id())?; + let saved: Saved = match std::fs::read(dir.join("session.json")) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other)?, + Err(error) if error.kind() == io::ErrorKind::NotFound => Saved::default(), + Err(error) => return Err(error), + }; + let (mut waiting, mut missing) = (Vec::new(), Vec::new()); + for kept in saved.tabs { + let text = match marker { + Marker::Crashed => recovered(dir, &kept.slot)?, + Marker::Clean | Marker::Discarded => None, + }; + if let Some(path) = &kept.path { + match std::fs::canonicalize(path) { + // The file reopens as it is on disk. + Ok(real) => waiting.push(Waiting { + kept: Kept { + slot: String::new(), + ..kept.clone() + }, + real: Some(real), + text: None, + }), + Err(error) => { + log::warn!("mark: a kept file does not open: {error}"); + missing.push(format!("{}: {error}", path.display())); + } + } + } + // Recovered text never overwrites a file: it is a copy. + if text.is_some() { + let kept = Kept { path: None, ..kept }; + waiting.push(Waiting { + kept, + real: None, + text, + }); + } + } + if marker != Marker::Crashed { + clear(&dir.join("recovery"))?; + } + let paths = waiting + .iter() + .filter_map(|waiting| waiting.real.clone()) + .collect(); + let keeping = Keeping { + dir: dir.to_path_buf(), + crash: Some(crash), + waiting, + written: None, + leaving: false, + frozen: Vec::new(), + }; + Ok((keeping, paths, missing)) + } + + /// The untitled tabs to make, with their names. + pub fn untitled(&self) -> Vec { + let untitled = self + .waiting + .iter() + .filter(|waiting| waiting.kept.path.is_none()); + untitled.map(|waiting| waiting.kept.name.clone()).collect() + } + + /// A clean end: no crash next time, no stale points. + pub fn close(&mut self) { + let Some(crash) = self.crash.take() else { + return; + }; + if let Err(error) = crash + .close() + .and_then(|()| clear(&self.dir.join("recovery"))) + { + log::error!("mark: the session did not close cleanly: {error}"); + } + } +} + +/// Removes every slot folder in `dir` not in `live`. +fn sweep(dir: &Path, live: &[&str]) -> io::Result<()> { + let entries = match std::fs::read_dir(dir) { + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + entries => entries?, + }; + for entry in entries { + let entry = entry?; + if !live.contains(&entry.file_name().to_string_lossy().as_ref()) { + clear(&entry.path())?; + } + } + Ok(()) +} + +fn clear(dir: &Path) -> io::Result<()> { + match std::fs::remove_dir_all(dir) { + Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + } +} + +impl Desk { + /// A tab just shown takes its kept mode, place, text. + pub fn restore_shown(&mut self, id: u64, cx: &mut Context) { + let Some(tab) = self.tab_mut(id).filter(|tab| !tab.extra.restored) else { + return; + }; + // Each tab takes one kept entry, on its first show. + tab.extra.restored = true; + let tab = self.tab(id).expect("open"); + let real = tab + .session + .path() + .and_then(|path| std::fs::canonicalize(path).ok()); + let untitled = tab.session.path().is_none(); + let waiting = &mut self.keeping.waiting; + let Some(at) = waiting + .iter() + .position(|waiting| match (&waiting.real, &real) { + (Some(kept), Some(open)) => kept == open, + (None, None) => untitled, + _ => false, + }) + else { + return; + }; + let Waiting { + kept, text: back, .. + } = waiting.remove(at); + let mode = Mode::parse(&kept.mode).unwrap_or(Mode::Visual); + log::info!("mark: restores tab {id} as {}", mode.name()); + let tab = self.tab_mut(id).expect("open"); + // A recovered copy keeps writing to the same points. + tab.extra.slot = kept.slot.clone(); + let view = tab.view.clone(); + if let Some((_, at)) = &back { + let at = humantime(*at); + let said = text( + cx, + "mark.restore.recovered", + &[("name", &kept.name), ("at", &at)], + ); + self.toast(said, ely_gpui_component::primitives::Severity::Info, cx); + } + let back = back.map(|(back, _)| back); + view.update(cx, |view, cx| view.reopen(mode, kept.place, back, cx)); + } + + /// Every ten seconds: unsaved text to points, tabs to disk. + pub fn keep(&mut self, cx: &mut Context) { + let dir = self.keeping.dir.clone(); + let started = std::time::UNIX_EPOCH + .elapsed() + .map_or(0, |since| since.as_millis()); + let mut kept = Vec::new(); + for tab in &mut self.core.tabs { + if tab.extra.slot.is_empty() { + tab.extra.slot = format!("{started}-{}", tab.id); + } + let view = tab.view.read(cx); + kept.push(Kept { + path: tab.session.path().map(Path::to_path_buf), + name: tab.name.to_string(), + mode: view.mode.name().to_owned(), + place: view.place, + slot: tab.extra.slot.clone(), + }); + let current = tab.doc.lock().expect("the text lock").current.clone(); + let point = mark(¤t); + if !tab.dirty(cx) || tab.extra.pointed.as_ref() == Some(&point) || tab.extra.pointing { + continue; + } + tab.extra.pointing = true; + let (id, slot) = (tab.id, recovery(&dir, &tab.extra.slot)); + cx.spawn(async move |desk, cx| { + let folder = slot.clone(); + let wrote = cx + .background_executor() + .spawn(async move { + let now = SystemTime::now(); + let limits = ely_platform::storage::limits(ely_platform::AppId::Mark)?; + let mut recovery = Recovery::new(folder).with_limits(limits.keep, limits.megabytes * 1024 * 1024); + recovery.write_once(current.as_bytes(), None, now) + }) + .await; + desk.update(cx, |desk, _| desk.pointed(id, point, wrote, &slot)) + .ok(); + }) + .detach(); + } + // Closed or discarded tabs' points go (§6.2). + let live = kept.iter().map(|kept| kept.slot.as_str()); + let live: Vec<&str> = live + .chain( + self.keeping + .waiting + .iter() + .map(|waiting| waiting.kept.slot.as_str()), + ) + .collect(); + if let Err(error) = sweep(&dir.join("recovery"), &live) { + log::error!("mark: old recovery points stay: {error}"); + } + if !self.keeping.leaving { + self.keeping.frozen = kept.clone(); + self.write_session(Saved { tabs: kept }); + } + } + + fn pointed(&mut self, id: u64, point: String, wrote: io::Result>, slot: &Path) { + // The tab closed while it wrote: the point goes too. + let Some(tab) = self.tab_mut(id) else { + if let Err(error) = clear(slot) { + log::error!("mark: a closed tab's point stays: {error}"); + } + return; + }; + tab.extra.pointing = false; + match wrote { + Ok(_) => tab.extra.pointed = Some(point), + Err(error) => log::error!("mark: a recovery point for tab {id} failed: {error}"), + } + } + + fn write_session(&mut self, saved: Saved) { + let json = serde_json::to_string_pretty(&saved).expect("the session serializes"); + if self.keeping.written.as_ref() == Some(&json) { + return; + } + let file = self.keeping.dir.join("session.json"); + match ely_files::save_atomic(&file, json.as_bytes(), 0) { + Ok(_) => self.keeping.written = Some(json), + Err(error) => log::error!("mark: the session did not save: {error}"), + } + } + + /// Keeps every ten seconds while the window lives (§6.2). + pub fn keep_every(&mut self, cx: &mut Context) { + cx.spawn(async move |desk, cx| { + loop { + cx.background_executor().timer(Recovery::INTERVAL).await; + if desk.update(cx, |desk, cx| desk.keep(cx)).is_err() { + break; + } + } + }) + .detach(); + } + + /// Slot `slot` closed: its points go, untitled entries too. + pub fn drop_slot( + &mut self, + slot: &str, + path: Option, + name: String, + cx: &mut Context, + ) { + if !slot.is_empty() + && let Err(error) = clear(&recovery(&self.keeping.dir, slot)) + { + log::error!("mark: a closed tab's points stay: {error}"); + } + if !self.keeping.leaving { + return self.keep(cx); + } + // Discarded untitled text goes; a first save keeps its file. + match path { + None => self.keeping.frozen.retain(|kept| kept.slot != slot), + Some(path) => { + let saved = self + .keeping + .frozen + .iter_mut() + .filter(|kept| kept.slot == slot); + for kept in saved { + (kept.path, kept.name) = (Some(path.clone()), name.clone()); + } + } + } + let tabs = self.keeping.frozen.clone(); + self.write_session(Saved { tabs }); + } + + /// A quit or close begins: the tabs now stay kept. + pub fn leave(&mut self, cx: &mut Context) { + self.keeping.leaving = false; + self.keep(cx); + self.keeping.leaving = true; + } +} + +/// A recovery time as this machine's clock reads it. +fn humantime(at: SystemTime) -> String { + match jiff::Timestamp::try_from(at) { + Ok(at) => at + .to_zoned(jiff::tz::TimeZone::system()) + .strftime("%Y-%m-%d %H:%M") + .to_string(), + Err(error) => { + log::warn!("mark: a recovery time is out of range: {error}"); + error.to_string() + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn a_place_without_a_column_reads_zero() { + let kept: super::Kept = serde_json::from_str( + r#"{"path":null,"name":"a.md","mode":"source","place":{"line":3,"scroll":0},"slot":"s"}"#, + ) + .expect("an older place reads"); + let place = kept.place.expect("a place"); + assert_eq!((place.line, place.column), (3, 0)); + } +} diff --git a/apps/ely-mark/src/side.rs b/apps/ely-mark/src/side.rs new file mode 100644 index 0000000..dd22b65 --- /dev/null +++ b/apps/ely-mark/src/side.rs @@ -0,0 +1,377 @@ +//! Files, outline and search beside the document (MARK-05, MARK-10). + +use std::path::PathBuf; + +use ely_gpui_component::{ + buttons::{Button, SegmentedControl}, + feedback::EmptyState, + forms::{InputEvent, SearchInput, TextInput}, + lists::{FileTree, ListItem, Outline, SelectableList}, + primitives::IconName, + theme::ControlSize, +}; +use ely_office::prelude::*; +use ely_ui::{commands, i18n::text}; +use gpui::{ + AnyElement, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + SharedString, Styled as _, WeakEntity, Window, div, +}; +use serde_json::json; + +use crate::desk::Desk; +use crate::folder::{Hit, MOST_HITS, lines_with}; + +#[derive(Clone, Copy, PartialEq)] +enum Pane { + Files, + Outline, + Search, +} + +pub struct Side { + desk: WeakEntity, + pane: Pane, + query: Entity, + /// Search the whole folder, else the shown file. + wide: bool, + /// Each hit, with its tab when the file is open. + hits: Vec<(Option, Hit)>, + /// Searches started; a late answer is dropped. + runs: u64, + /// The words searched, and the tab when one file was. + searched: String, + tab: Option, + /// The folder listing searched. + listing: u64, + /// Files that could not be read, and why. + missed: Vec<(PathBuf, String)>, +} + +impl Side { + pub fn new(desk: &Entity, window: &mut Window, cx: &mut Context) -> Self { + // A new shown file is searched again. + cx.observe(desk, |side, desk, cx| { + let (active, listing) = (desk.read(cx).core.active, desk.read(cx).listings); + let shown = !side.wide && side.tab != active; + let listed = side.wide && side.listing != listing; + if !side.searched.is_empty() && (shown || listed) { + side.search(cx); + } + cx.notify(); + }) + .detach(); + let query = cx.new(|cx| TextInput::new(window, cx)); + cx.subscribe(&query, |side, _, event: &InputEvent, cx| { + if *event == InputEvent::Submit { + side.search(cx); + } + }) + .detach(); + Side { + desk: desk.downgrade(), + pane: Pane::Files, + query, + wide: false, + hits: Vec::new(), + runs: 0, + searched: String::new(), + tab: None, + listing: 0, + missed: Vec::new(), + } + } + + /// Lines holding the query, off the window. + fn search(&mut self, cx: &mut Context) { + let query = self.query.read(cx).text().trim().to_owned(); + let Some(desk) = self.desk.upgrade() else { + return; + }; + self.runs += 1; + let run = self.runs; + let desk = desk.read(cx); + (self.tab, self.listing) = (desk.core.active, desk.listings); + self.searched = query.clone(); + if query.is_empty() { + (self.hits, self.missed) = (Vec::new(), Vec::new()); + return cx.notify(); + } + // Open files search as they are now, saved or not. + let open: Vec<(u64, PathBuf, String)> = desk + .core + .tabs + .iter() + .filter_map(|tab| { + let name = PathBuf::from(tab.name.as_ref()); + let path = tab.session.path().map_or(name, PathBuf::from); + Some((tab.id, path, desk.text_of(tab.id)?)) + }) + .collect(); + // Open files by real path, as the folder lists them. + let reals: Vec<(u64, PathBuf)> = open + .iter() + .filter_map(|(id, path, _)| Some((*id, std::fs::canonicalize(path).ok()?))) + .collect(); + let sources: Vec<(Option, PathBuf)> = match (self.wide, &desk.folder) { + (true, Some(folder)) => folder + .files + .iter() + .map(|file| folder.root.join(file)) + .map(|path| { + let held = reals + .iter() + .find(|(_, real)| *real == path) + .map(|(id, _)| *id); + (held, path) + }) + .collect(), + _ => desk + .active_tab() + .map(|tab| (Some(tab.id), PathBuf::new())) + .into_iter() + .collect(), + }; + log::info!("mark: searches {} files", sources.len()); + cx.spawn(async move |side, cx| { + let hits = cx + .background_executor() + .spawn(async move { + let (mut hits, mut missed) = (Vec::new(), Vec::new()); + for (tab, path) in sources { + let held = open.iter().find(|(id, ..)| Some(*id) == tab); + let (path, text) = match held { + Some((_, path, text)) => (path.clone(), text.clone()), + None => { + match std::fs::read(&path).and_then(crate::text::Source::decode) { + Ok(source) => (path, source.text), + Err(error) => { + log::warn!( + "mark: {} was not searched: {error}", + path.display() + ); + missed.push((path, error.to_string())); + continue; + } + } + } + }; + let mut found = Vec::new(); + lines_with(&path, &text, &query, &mut found); + hits.extend(found.into_iter().map(|hit| (tab, hit))); + if hits.len() >= MOST_HITS { + hits.truncate(MOST_HITS); + break; + } + } + (hits, missed) + }) + .await; + side.update(cx, |side, cx| { + if side.runs == run { + (side.hits, side.missed) = hits; + cx.notify(); + } + }) + .ok(); + }) + .detach(); + } + + fn files(&self, desk: &Entity, cx: &mut Context) -> AnyElement { + let read = desk.read(cx); + if let Some(folder) = &read.folder { + let (root, opener) = (folder.root.clone(), desk.downgrade()); + return FileTree::new("mark-files", folder.files.clone()) + .on_open(move |path, _, cx| { + let path = root.join(path.as_ref()); + opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok(); + }) + .size_full() + .into_any_element(); + } + if read.recent.is_empty() { + let open = Button::new("mark-open", text(cx, "command.file.open", &[])) + .on_click(|_, window, cx| window.dispatch_action(Box::new(commands::Open), cx)); + return EmptyState::new( + "mark-no-recent", + IconName::Folder, + text(cx, "mark.recent.none", &[]), + ) + .action(open) + .into_any_element(); + } + let opener = desk.downgrade(); + let rows = read.recent.iter().map(|path| { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let key: SharedString = path.to_string_lossy().into_owned().into(); + let item = ListItem::new(key.clone(), name).description( + path.parent() + .map(|dir| dir.display().to_string()) + .unwrap_or_default(), + ); + (key, item) + }); + rows.fold( + SelectableList::new("mark-recent").label(text(cx, "mark.recent", &[])), + |list, (key, item)| list.row(key, item), + ) + .on_activate(move |key, _, cx| { + let path = PathBuf::from(key.as_ref()); + opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok(); + }) + .into_any_element() + } + + fn outline(&self, desk: &Entity, cx: &mut Context) -> AnyElement { + let Some(view) = desk.read(cx).active_view() else { + return EmptyState::new( + "mark-no-outline", + IconName::File, + text(cx, "command.blocked.no_document", &[]), + ) + .into_any_element(); + }; + let headings = view.read(cx).outline.clone(); + if headings.is_empty() { + return EmptyState::new( + "mark-no-headings", + IconName::File, + text(cx, "mark.outline.none", &[]), + ) + .into_any_element(); + } + let outline = headings.iter().enumerate().fold( + Outline::new("mark-outline"), + |outline, (at, heading)| { + outline.item(at.to_string(), heading.title.clone(), heading.level) + }, + ); + outline + .on_select(move |key, window, cx| { + if let Ok(at) = key.parse::() { + view.update(cx, |view, cx| view.reveal(json!({ "heading": at }), cx)); + // Keys go to the document, at the heading. + let focus = view.read(cx).focus.clone(); + window.focus(&focus, cx); + } + }) + .into_any_element() + } + + fn search_pane(&self, desk: &Entity, cx: &mut Context) -> AnyElement { + let this = cx.entity(); + let scope = SegmentedControl::new("mark-scope", if self.wide { "folder" } else { "file" }) + .size(ControlSize::Sm) + .segment("file", text(cx, "mark.search.file", &[]), Some(IconName::File)); + let scope = match desk.read(cx).folder { + Some(_) => scope.segment("folder", text(cx, "mark.search.folder", &[]), Some(IconName::Folder)), + None => scope, + }; + let scope = scope.on_change(move |key, _, cx| { + this.update(cx, |side, cx| { + side.wide = key == "folder"; + side.search(cx); + }) + }); + let opener = desk.downgrade(); + let (hits, query) = (self.hits.clone(), self.searched.clone()); + // Files not read come first: the results lack them. + let missed = self.missed.iter().enumerate().map(|(at, (path, why))| { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let title = text(cx, "mark.search.missed", &[("name", &name)]); + ( + SharedString::from(format!("missed-{at}")), + ListItem::new(format!("missed-{at}"), title).description(why.clone()), + ) + }); + let rows = self.hits.iter().enumerate().map(|(at, (_, hit))| { + let name = hit + .file + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let item = ListItem::new(at.to_string(), hit.text.clone()) + .description(format!("{name}:{}", hit.line)); + (SharedString::from(at.to_string()), item) + }); + let list = missed + .collect::>() + .into_iter() + .chain(rows) + .fold( + SelectableList::new("mark-hits").label(text(cx, "mark.search", &[])), + |list, (key, item)| list.row(key, item), + ) + .on_activate(move |key, window, cx| { + let Some((tab, hit)) = key.parse::().ok().and_then(|at| hits.get(at)) else { + return; + }; + let (tab, file, line) = (*tab, hit.file.clone(), hit.line); + let place = json!({ "line": line, "query": query }); + opener + .update(cx, |desk, cx| desk.open_at(tab, file, place, window, cx)) + .ok(); + }); + let more = (self.hits.len() == MOST_HITS) + .then(|| text(cx, "mark.search.more", &[("count", &MOST_HITS.to_string())])); + div() + .flex() + .flex_col() + .gap_2() + .size_full() + .child(SearchInput::new("mark-search", &self.query)) + .child(scope) + .child(list) + .children(more) + .into_any_element() + } +} + +impl Render for Side { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(desk) = self.desk.upgrade() else { + return div().into_any_element(); + }; + let this = cx.entity(); + let key = match self.pane { + Pane::Files => "files", + Pane::Outline => "outline", + Pane::Search => "search", + }; + let panes = SegmentedControl::new("mark-pane", key) + .size(ControlSize::Sm) + .segment("files", text(cx, "mark.pane.files", &[]), Some(IconName::Folder)) + .segment("outline", text(cx, "mark.pane.outline", &[]), Some(IconName::List)) + .segment("search", text(cx, "mark.pane.search", &[]), Some(IconName::Search)) + .on_change(move |key, _, cx| { + this.update(cx, |side, cx| { + side.pane = match key.as_ref() { + "outline" => Pane::Outline, + "search" => Pane::Search, + _ => Pane::Files, + }; + cx.notify(); + }) + }); + let body = match self.pane { + Pane::Files => self.files(&desk, cx), + Pane::Outline => self.outline(&desk, cx), + Pane::Search => self.search_pane(&desk, cx), + }; + div() + .flex() + .flex_col() + .gap_2() + .p_2() + .size_full() + .child(panes) + .child(div().flex_1().min_h_0().child(body)) + .into_any_element() + } +} diff --git a/apps/ely-mark/src/text.rs b/apps/ely-mark/src/text.rs new file mode 100644 index 0000000..65b5de5 --- /dev/null +++ b/apps/ely-mark/src/text.rs @@ -0,0 +1,104 @@ +//! File bytes to editor text and back (§14.3, QA-21). + +use std::io; + +const BOM: &[u8] = b"\xEF\xBB\xBF"; + +/// A file as read: its bytes, BOM and line ending. +#[derive(Clone, Debug)] +pub struct Source { + bytes: Vec, + bom: bool, + /// More CRLF than lone LF line ends. + crlf: bool, + /// The editor's text: no BOM, CRLF as LF. + pub text: String, +} + +impl Source { + /// Reads UTF-8, with or without BOM; other bytes fail. + pub fn decode(bytes: Vec) -> io::Result { + if bytes.starts_with(b"\xFF\xFE") || bytes.starts_with(b"\xFE\xFF") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "UTF-16 text is not read", + )); + } + let bom = bytes.starts_with(BOM); + let body = &bytes[if bom { BOM.len() } else { 0 }..]; + let text = std::str::from_utf8(body).map_err(|error| { + let at = error.valid_up_to(); + io::Error::new( + io::ErrorKind::InvalidData, + format!("not UTF-8 at byte {at}"), + ) + })?; + let crlfs = text.matches("\r\n").count(); + let crlf = crlfs > text.matches('\n').count() - crlfs; + let text = text.replace("\r\n", "\n"); + Ok(Source { + bytes, + bom, + crlf, + text, + }) + } + + /// The bytes for `text`; unedited text is the file itself. + pub fn encode(&self, text: &str) -> Vec { + if text == self.text { + return self.bytes.clone(); + } + let text = match self.crlf { + true => text.replace('\n', "\r\n"), + false => text.to_owned(), + }; + let mut bytes = Vec::with_capacity(text.len() + BOM.len()); + if self.bom { + bytes.extend_from_slice(BOM); + } + bytes.extend_from_slice(text.as_bytes()); + bytes + } +} + +#[cfg(test)] +mod tests { + use super::Source; + + #[test] + fn unedited_text_writes_the_same_bytes() { + for bytes in [ + &b"# a\r\n\r\nb\n"[..], + b"\xEF\xBB\xBF# a\r\n", + b"a\rb\n", + b"", + b"no end", + ] { + let source = Source::decode(bytes.to_vec()).expect("reads"); + assert_eq!(source.encode(&source.text.clone()), bytes); + } + } + + #[test] + fn edits_keep_the_bom_and_line_ending() { + let source = Source::decode(b"\xEF\xBB\xBF# a\r\n\r\nb\r\n".to_vec()).expect("reads"); + assert_eq!(source.text, "# a\n\nb\n"); + assert_eq!(source.encode("# a\n\nc\n"), b"\xEF\xBB\xBF# a\r\n\r\nc\r\n"); + let plain = Source::decode(b"a\nb\n".to_vec()).expect("reads"); + assert_eq!(plain.encode("a\nc\n"), b"a\nc\n"); + } + + #[test] + fn mixed_line_ends_follow_the_most() { + let source = Source::decode(b"a\r\nb\r\nc\n".to_vec()).expect("reads"); + assert_eq!(source.encode("a\nb\nd\n"), b"a\r\nb\r\nd\r\n"); + } + + #[test] + fn other_encodings_fail_with_the_place() { + let error = Source::decode(b"ok \xFF".to_vec()).expect_err("not UTF-8"); + assert!(error.to_string().contains("byte 3"), "{error}"); + assert!(Source::decode(b"\xFF\xFEa\0".to_vec()).is_err()); + } +} diff --git a/apps/ely-mark/src/vcs.rs b/apps/ely-mark/src/vcs.rs new file mode 100644 index 0000000..b1fe949 --- /dev/null +++ b/apps/ely-mark/src/vcs.rs @@ -0,0 +1,189 @@ +//! Whether Git sees the file changed; ElyTree does the rest. + +use std::{ + io, + path::Path, +}; + +use ely_gpui_component::{primitives::Severity, shell::StatusBarItem}; +use ely_office::prelude::*; +use ely_platform::AppId; +use ely_ui::i18n::text; +use gpui::Context; + +use crate::desk::Desk; + +/// What Git says of one file. +#[derive(Clone, Debug, PartialEq)] +pub enum Tracked { + /// Not in a repository: nothing to show. + Outside, + Unchanged, + Changed, + Untracked, + Ignored, + Conflicted, + /// Git could not answer; why. + Unknown(String), +} + +/// Git's word on `file`: in a repository, then its status. +pub fn ask(file: &Path) -> Tracked { + match status(file) { + Ok(tracked) => tracked, + Err(error) => Tracked::Unknown(error.to_string()), + } +} + +fn run(git: &Path, dir: &Path, args: &[&str]) -> io::Result { + ely_platform::git::command(git, dir, args).output() +} + +fn status(file: &Path) -> io::Result { + let (git, _) = ely_platform::git::locate()?; + let dir = file + .parent() + .ok_or_else(|| io::Error::other("a file without a folder"))?; + let top = run(&git, dir, &["rev-parse", "--show-toplevel"])?; + let said = String::from_utf8_lossy(&top.stderr); + // Not a repository is an answer; other failures are errors. + if !top.status.success() { + return match said.contains("not a git repository") { + true => Ok(Tracked::Outside), + false => Err(io::Error::other(said.trim().to_owned())), + }; + } + let name = file + .file_name() + .ok_or_else(|| io::Error::other("a file without a name"))?; + let name = name.to_string_lossy(); + let said = run( + &git, + dir, + &["status", "--porcelain=v2", "-z", "--ignored", "--", &name], + )?; + if !said.status.success() { + return Err(io::Error::other( + String::from_utf8_lossy(&said.stderr).trim().to_owned(), + )); + } + Ok(match said.stdout.first() { + None => Tracked::Unchanged, + Some(b'1' | b'2') => Tracked::Changed, + Some(b'u') => Tracked::Conflicted, + Some(b'?') => Tracked::Untracked, + Some(b'!') => Tracked::Ignored, + Some(other) => { + return Err(io::Error::other(format!( + "git status said {:?}", + *other as char + ))); + } + }) +} + +impl Desk { + /// Asks Git about the shown file, off the main thread. + pub fn ask_git(&mut self, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return; + }; + let Some(path) = tab.session.path().map(Path::to_path_buf) else { + self.tracked = None; + return; + }; + let id = tab.id; + cx.spawn(async move |desk, cx| { + let asked = path.clone(); + let tracked = cx + .background_executor() + .spawn(async move { ask(&asked) }) + .await; + desk.update(cx, |desk, cx| { + // Only the shown file's answer counts. + let shown = desk.active_tab().filter(|tab| tab.id == id); + if shown.and_then(|tab| tab.session.path()) == Some(&path) { + log::info!("mark: git says {tracked:?}"); + desk.tracked = Some(tracked); + cx.notify(); + } + }) + .ok(); + }) + .detach(); + } + + /// The Git status item; pressing shows the file in ElyTree. + pub fn git_item(&self, cx: &mut Context) -> Option { + let key = match self.tracked.as_ref()? { + Tracked::Outside => return None, + Tracked::Unchanged => "mark.git.unchanged", + Tracked::Changed => "mark.git.changed", + Tracked::Untracked => "mark.git.untracked", + Tracked::Ignored => "mark.git.ignored", + Tracked::Conflicted => "mark.git.conflicted", + Tracked::Unknown(why) => { + let label = text(cx, "mark.git.unknown", &[]); + return Some( + StatusBarItem::new("mark-git") + .label(label) + .tooltip(why.clone()), + ); + } + }; + // ElyTree takes the file: its repository and its history (§16). + let file = self.active_tab()?.session.path()?.to_path_buf(); + let item = StatusBarItem::new("mark-git") + .label(text(cx, key, &[])) + .tooltip(text(cx, "mark.git.open", &[])); + let this = cx.entity(); + Some(item.on_click(move |_, _, cx| { + if !ely_platform::apps::installed(AppId::Tree) { + let said = text(cx, "mark.git.missing", &[]); + this.update(cx, |desk, cx| desk.toast(said, Severity::Warning, cx)); + return; + } + let (this, file) = (this.clone(), file.clone()); + cx.spawn(async move |cx| { + let opened = cx.background_executor().spawn(async move { ely_platform::apps::open(AppId::Tree, &[file]) }).await; + if let Err(error) = opened { + log::error!("mark: ElyTree did not open: {error}"); + cx.update(|cx| { + let said = text(cx, "mark.git.failed", &[("error", &error.to_string())]); + this.update(cx, |desk, cx| desk.toast(said, Severity::Danger, cx)); + }); + } + }) + .detach(); + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn git(dir: &Path, args: &[&str]) { + let (git, _) = ely_platform::git::locate().expect("a Git"); + let done = std::process::Command::new(git) + .args(args) + .current_dir(dir) + .output(); + assert!(done.expect("git runs").status.success(), "git {args:?}"); + } + + #[test] + fn spaces_stay_and_broken_repositories_say_why() { + let dir = tempfile::tempdir().expect("a temp dir"); + let repo = dir.path().join("repo "); + std::fs::create_dir(&repo).expect("the folder"); + git(&repo, &["init", "-q"]); + std::fs::write(repo.join("t.md"), "a").expect("a file"); + assert_eq!(ask(&repo.join("t.md")), Tracked::Untracked); + std::fs::write(repo.join(".git/config"), "[bad\n").expect("a broken config"); + assert!(matches!(ask(&repo.join("t.md")), Tracked::Unknown(_))); + let outside = tempfile::tempdir().expect("a temp dir"); + std::fs::write(outside.path().join("t.md"), "a").expect("a file"); + assert_eq!(ask(&outside.path().join("t.md")), Tracked::Outside); + } +} diff --git a/apps/ely-mark/src/view.rs b/apps/ely-mark/src/view.rs new file mode 100644 index 0000000..05a7ed8 --- /dev/null +++ b/apps/ely-mark/src/view.rs @@ -0,0 +1,446 @@ +//! One Markdown file in a page, its text returned. + +use ely_web_content::{Event, Reply, WebView}; +use gpui::{ + Context, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable as _, + InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _, Window, div, +}; +use serde_json::Value; + +use std::path::PathBuf; + +use crate::engine::{Doc, mark}; +use crate::page::Mode; + +/// The page reported a change or new text. +pub struct Reported; + +/// One heading of the document's outline. +#[derive(Clone, Debug, PartialEq)] +pub struct Heading { + pub level: usize, + pub title: String, +} +/// Where the reader was: a file line and the scroll. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Place { + pub line: u64, + /// Sessions from before the column was kept read zero. + #[serde(default)] + pub column: u64, + pub scroll: f64, +} + +/// Text past this many bytes opens as source (PERF-07). +pub const LARGE: usize = 2 * 1024 * 1024; + +/// A render's answer: the page's reply, or why not. +type Rendered = Box, &mut gpui::App)>; + +/// The page failed; the shell shows why. +pub struct Failed(pub String); + +pub struct MarkView { + pub(crate) web: Entity, + doc: Doc, + /// The newest change the page announced. + pub changed: u64, + /// The change whose text `doc` holds. + reported: u64, + /// The mark at the last load or save. + saved: String, + /// Page loads so far; a reload restarts its versions. + load: u64, + pub mode: Mode, + /// Headings as the shown editor reads them. + pub outline: Vec, + /// Shown since the last page load. + pub shown: bool, + /// A place to bring into view once shown. + waiting: Option, + /// Where the page's search stands, while one runs. + pub found: Option, + /// The file's folder: links and images resolve here. + pub(crate) folder: PathBuf, + /// A saved file: images can go beside it. + pub attachable: bool, + /// Past `LARGE`: source alone, every byte still saved (MARK-14). + pub large: bool, + /// The caret's line and the scroll, as last reported. + pub place: Option, + /// The writing view, as the page applies it (MARK-09). + look: Value, + /// Renders asked of the page, by number. + renders: std::collections::HashMap, + asked: u64, + pub focus: FocusHandle, +} + +impl EventEmitter for MarkView {} +impl EventEmitter for MarkView {} + +impl MarkView { + pub fn new( + web: Entity, + doc: Doc, + saved: String, + folder: PathBuf, + cx: &mut Context, + ) -> Self { + let focus = web.read(cx).focus_handle(cx); + let large = doc.lock().expect("the text lock").current.len() > LARGE; + cx.subscribe(&web, Self::heard).detach(); + let paste = crate::attach::paste(cx.weak_entity()); + web.update(cx, |web, _| web.intercept_paste(paste)); + MarkView { + web, + doc, + changed: 0, + reported: 0, + saved, + load: 0, + mode: if large { Mode::Source } else { Mode::Visual }, + large, + outline: Vec::new(), + shown: false, + waiting: None, + found: None, + attachable: false, + place: None, + look: Value::Null, + renders: std::collections::HashMap::new(), + asked: 0, + folder, + focus, + } + } + + /// Every change's text has arrived. + pub fn settled(&self) -> bool { + self.reported == self.changed + } + + /// The text's size now, in bytes. + pub fn bytes(&self) -> usize { + self.doc.lock().expect("the text lock").current.len() + } + + pub fn modified(&self) -> bool { + mark(&self.doc.lock().expect("the text lock").current) != self.saved + } + + pub fn saved(&mut self, mark: &str, cx: &mut Context) { + self.saved = mark.to_owned(); + cx.emit(Reported); + } + + /// The same text in `mode` (MARK-02). + pub fn switch(&mut self, mode: Mode, cx: &mut Context) { + if self.large && mode != Mode::Source { + log::info!("mark: a large file stays in source"); + let said = ely_ui::i18n::text(cx, "mark.large.modes", &[]).to_string(); + return cx.emit(Failed(said)); + } + log::info!("mark: switches to {}", mode.name()); + self.mode = mode; + crate::page::switch(self.web.read(cx), mode); + cx.notify(); + } + + /// Searches the page for `spec` (MARK-06). + pub fn find(&mut self, spec: Value, cx: &mut Context) { + crate::page::call(self.web.read(cx), "find", &spec); + } + + /// Steps under `spec`, the box's own; tabs keep older ones. + pub fn step(&mut self, spec: Value, forward: bool, cx: &mut Context) { + let ask = serde_json::json!({ "spec": spec, "forward": forward }); + crate::page::call(self.web.read(cx), "step", &ask); + } + + pub fn replace(&mut self, spec: Value, all: bool, cx: &mut Context) { + let ask = serde_json::json!({ "spec": spec, "all": all }); + crate::page::call(self.web.read(cx), "replace", &ask); + } + + /// A table step: insert, rows and columns (MARK-06). + pub fn table(&mut self, op: &str, cx: &mut Context) { + crate::page::call(self.web.read(cx), "table", &Value::from(op)); + } + + pub fn unfind(&mut self, cx: &mut Context) { + self.found = None; + crate::page::call(self.web.read(cx), "unfind", &Value::Null); + } + + /// Brings `{"heading": n}` or `{"line": n}` into view. + pub fn reveal(&mut self, place: Value, cx: &mut Context) { + match self.shown { + true => crate::page::call(self.web.read(cx), "reveal", &place), + false => self.waiting = Some(place), + } + } + + /// The disk's text replaces a clean one; the place stays. + pub fn refresh(&mut self, source: crate::text::Source, cx: &mut Context) { + let text = source.text.clone(); + { + let mut doc = self.doc.lock().expect("the text lock"); + doc.current = text.clone(); + doc.source = source; + } + self.saved = mark(&text); + (self.changed, self.reported) = (0, 0); + crate::page::call(self.web.read(cx), "refresh", &Value::from(text)); + cx.emit(Reported); + } + + /// The writing view's settings, applied now and at each show. + pub fn set_look(&mut self, look: Value, cx: &mut Context) { + if look == self.look { + return; + } + if self.shown { + crate::page::call(self.web.read(cx), "look", &look); + } + self.look = look; + } + + /// The whole text as `kind`; `done` takes it. + pub fn render( + &mut self, + kind: &str, + done: impl FnOnce(Result, &mut gpui::App) + 'static, + cx: &mut Context, + ) { + self.asked += 1; + self.renders.insert(self.asked, Box::new(done)); + let ask = serde_json::json!({ "kind": kind, "ask": self.asked }); + crate::page::call(self.web.read(cx), "render", &ask); + } + + /// `text` replaces the editor's, as one undoable edit. + pub fn adopt(&mut self, text: String, cx: &mut Context) { + crate::page::call(self.web.read(cx), "adopt", &Value::from(text)); + } + + /// Shows again as `mode` at `place`; recovered text unsaved. + pub fn reopen( + &mut self, + mode: Mode, + place: Option, + text: Option, + cx: &mut Context, + ) { + let mut doc = self.doc.lock().expect("the text lock"); + if let Some(text) = text { + doc.current = text; + } + let current = doc.current.clone(); + drop(doc); + let mode = if self.large { Mode::Source } else { mode }; + self.mode = mode; + let at = (mode, self.load, place); + crate::page::open(self.web.read(cx), ¤t, at, &self.folder, cx); + cx.emit(Reported); + } + + /// The file moved; images and links follow it. + pub fn moved(&mut self, folder: PathBuf, cx: &mut Context) { + if folder == self.folder { + return; + } + if let Err(error) = self.web.read(cx).allow_images(&folder) { + log::error!("mark: images cannot follow the file: {error:#}"); + return cx.emit(Failed(format!("{error:#}"))); + } + crate::page::folder(self.web.read(cx), &folder); + self.folder = folder; + } + + fn heard(&mut self, web: Entity, event: &Event, cx: &mut Context) { + match event { + // A reload shows the last text that arrived. + Event::Loaded => { + self.load += 1; + self.shown = false; + (self.changed, self.reported) = (0, 0); + let text = self.doc.lock().expect("the text lock").current.clone(); + let at = (self.mode, self.load, self.place); + crate::page::open(web.read(cx), &text, at, &self.folder, cx); + cx.emit(Reported); + } + Event::Query(said, reply) => self.query(said, reply, cx), + Event::Leave(url) => log::info!("mark: a link out was not followed: {url}"), + Event::Failed(why) => cx.emit(Failed(why.clone())), + Event::Printed(path, _) => { + log::error!("mark: an editor page printed {}", path.display()) + } + } + } + + fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context) { + let said: Value = match serde_json::from_str(said) { + Ok(said) => said, + Err(error) => { + log::error!("mark: the page sent bad JSON: {error}"); + return reply.fail("bad JSON"); + } + }; + if said["generation"] + .as_u64() + .is_some_and(|load| load != self.load) + { + log::info!("mark: a report from an earlier page load was dropped"); + return reply.ok(""); + } + let version = said["version"].as_u64(); + match (said["kind"].as_str(), version, said["text"].as_str()) { + // A fallback shows source; the tab follows. + (Some("shown"), ..) => { + log::info!( + "mark: shown as {}, fallback {}", + said["mode"], + said["fallback"] + ); + if let Some(mode) = said["mode"].as_str().and_then(Mode::parse) { + self.mode = mode; + cx.notify(); + } + self.shown = true; + if let Some(place) = self.waiting.take() { + crate::page::call(self.web.read(cx), "reveal", &place); + } + if !self.look.is_null() { + crate::page::call(self.web.read(cx), "look", &self.look); + } + cx.emit(Reported); + } + (Some("outline"), ..) => match headings(&said["items"]) { + Some(outline) => { + self.outline = outline; + cx.notify(); + } + None => { + log::error!("mark: the page sent a bad outline"); + return reply.fail("bad outline"); + } + }, + (Some("found"), ..) => match found(&said) { + Some(found) => { + self.found = Some(found); + cx.notify(); + } + None => { + log::error!("mark: the page sent a bad search state"); + return reply.fail("bad search state"); + } + }, + (Some("table"), ..) if said["done"] == false => { + let key = match self.mode { + Mode::Visual => "mark.table.none", + _ => "mark.table.visual", + }; + cx.emit(Failed(ely_ui::i18n::text(cx, key, &[]).to_string())); + } + (Some("inserted"), ..) if said["done"] == false => { + cx.emit(Failed( + ely_ui::i18n::text(cx, "mark.attach.preview", &[]).to_string(), + )); + } + (Some("table" | "inserted"), ..) => {} + (Some("rendered"), ..) => { + let ask = said["ask"].as_u64().unwrap_or_default(); + let Some(done) = self.renders.remove(&ask) else { + log::error!("mark: a render nobody asked for came back"); + return reply.fail("unasked render"); + }; + let result = match said["error"].as_str() { + Some(error) => Err(error.to_owned()), + None => Ok(said.clone()), + }; + done(result, cx); + } + (Some("place"), ..) => match ( + said["line"].as_u64(), + said["column"].as_u64(), + said["scroll"].as_f64(), + ) { + (Some(line), Some(column), Some(scroll)) => { + self.place = Some(Place { + line, + column, + scroll, + }) + } + _ => { + log::error!("mark: the page sent a bad place"); + return reply.fail("bad place"); + } + }, + (Some("changed"), Some(version), _) => { + self.changed = self.changed.max(version); + cx.emit(Reported); + } + // Only the newest text counts. + (Some("text"), Some(version), Some(text)) if version >= self.reported => { + self.doc.lock().expect("the text lock").current = text.to_owned(); + self.reported = version; + cx.emit(Reported); + } + (Some("text"), Some(_), Some(_)) => log::info!("mark: an older text was dropped"), + _ => { + log::error!("mark: the page asked something unknown"); + return reply.fail("unknown request"); + } + } + reply.ok(""); + } +} + +/// A search state from the page; anything malformed is None. +fn found(said: &Value) -> Option { + if let Some(error) = said["error"].as_str() { + let error = Some(error.to_owned()); + return Some(crate::finding::Found { + current: None, + total: 0, + error, + }); + } + let total = said["total"].as_u64()? as usize; + let current = match &said["current"] { + Value::Null => None, + at => Some(at.as_u64()? as usize), + }; + Some(crate::finding::Found { + current, + total, + error: None, + }) +} + +/// Headings from the page; anything malformed is None. +fn headings(items: &Value) -> Option> { + let heading = |item: &Value| { + let level = item["level"] + .as_u64() + .filter(|level| (1..=6).contains(level))?; + let title = item["title"].as_str()?.to_owned(); + Some(Heading { + level: level as usize, + title, + }) + }; + items.as_array()?.iter().map(heading).collect() +} + +impl Render for MarkView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + // Dropped files: images go beside the document. + let drop = cx.listener(|view, dropped: &ExternalPaths, _, cx| { + view.attach(crate::attach::Attach::Files(dropped.paths().to_vec()), cx) + }); + div().size_full().on_drop(drop).child(self.web.clone()) + } +} diff --git a/apps/ely-mark/web/build.mjs b/apps/ely-mark/web/build.mjs new file mode 100644 index 0000000..bda6635 --- /dev/null +++ b/apps/ely-mark/web/build.mjs @@ -0,0 +1,89 @@ +// Builds the page into the folder given; no network. +import { build } from 'esbuild' +import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +const out = process.argv[2] +if (!out) throw new Error('usage: node build.mjs ') +await rm(out, { recursive: true, force: true }) +await mkdir(out, { recursive: true }) + +const built = await build({ + entryPoints: ['src/main.js', 'src/diagram.js', 'src/main.css'], + outdir: out, + bundle: true, + minify: true, + format: 'iife', + target: 'chrome140', + loader: { '.woff2': 'file', '.woff': 'file', '.ttf': 'file' }, + assetNames: 'fonts/[name]-[hash]', + legalComments: 'none', + metafile: true, + logLevel: 'warning', +}) +// Exports carry one stylesheet: fonts inline, woff2 alone. +const exported = await build({ + entryPoints: ['src/export.css'], + outdir: out, + bundle: true, + minify: true, + loader: { '.woff2': 'dataurl', '.woff': 'empty', '.ttf': 'empty' }, + legalComments: 'none', + metafile: true, + logLevel: 'warning', +}) +const sheet = join(out, 'export.css') +// The dropped woff and ttf left empty sources. +await writeFile(sheet, (await readFile(sheet, 'utf8')).replace(/,url\(\) format\("(?:woff|truetype)"\)/g, '')) +await copyFile('index.html', join(out, 'index.html')) + +/** Each bundled package's folder under node_modules. */ +function packages(inputs) { + const found = new Set() + for (const input of inputs) { + const at = input.lastIndexOf('node_modules/') + if (at < 0) continue + const parts = input.slice(at + 13).split('/') + const name = parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0] + found.add(input.slice(0, at + 13) + name) + } + return [...found].sort() +} + +/** Name, version and license; the file, else the manifest's. */ +async function notice(folder) { + const manifest = JSON.parse(await readFile(join(folder, 'package.json'), 'utf8')) + const head = `${manifest.name} ${manifest.version} (${manifest.license})` + const file = (await readdir(folder)).find((name) => /^(licen[cs]e|copying)/i.test(name)) + if (file) return `${head}\n\n${(await readFile(join(folder, file), 'utf8')).trim()}\n` + const author = manifest.author?.name ?? manifest.author + if (manifest.license !== 'MIT' || !author) throw new Error(`${manifest.name}: no license file`) + console.log(`${manifest.name} ships no license file; MIT from its manifest`) + return `${head}\n\nCopyright (c) ${author}\n\n${MIT}\n` +} + +const MIT = `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.` + +const folders = packages([ + ...Object.keys(built.metafile.inputs), + ...Object.keys(exported.metafile.inputs), +]) +const notices = await Promise.all(folders.map(notice)) +await writeFile(join(out, 'THIRD-PARTY-NOTICES.txt'), notices.join('\n' + '-'.repeat(72) + '\n\n')) +console.log(`built ${folders.length} packages into ${out}`) diff --git a/apps/ely-mark/web/index.html b/apps/ely-mark/web/index.html new file mode 100644 index 0000000..11548e1 --- /dev/null +++ b/apps/ely-mark/web/index.html @@ -0,0 +1,11 @@ + + + + + + + + +

+ + diff --git a/apps/ely-mark/web/package-lock.json b/apps/ely-mark/web/package-lock.json new file mode 100644 index 0000000..02f7975 --- /dev/null +++ b/apps/ely-mark/web/package-lock.json @@ -0,0 +1,4871 @@ +{ + "name": "ely-mark-content", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ely-mark-content", + "dependencies": { + "@codemirror/commands": "6.11.1", + "@codemirror/lang-markdown": "6.5.2", + "@codemirror/language": "6.12.4", + "@codemirror/language-data": "6.5.2", + "@codemirror/search": "6.7.2", + "@codemirror/state": "6.7.6", + "@codemirror/view": "6.43.13", + "@lezer/highlight": "1.2.5", + "@milkdown/kit": "7.22.2", + "codemirror": "6.0.2", + "hast-util-from-dom": "5.0.1", + "hast-util-to-text": "4.0.2", + "katex": "0.19.0", + "mermaid": "12.1.0", + "prosemirror-search": "1.1.1", + "rehype-katex": "7.0.1", + "rehype-stringify": "10.0.1", + "remark-gfm": "4.0.1", + "remark-math": "6.0.0", + "remark-parse": "11.0.0", + "remark-rehype": "11.1.2", + "unified": "11.0.5", + "unist-util-visit": "5.1.0", + "vfile": "6.0.3" + }, + "devDependencies": { + "esbuild": "0.28.2" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz", + "integrity": "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.8.0", + "tinyexec": "^1.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-13.2.0.tgz", + "integrity": "sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "13.2.0", + "@chevrotain/types": "13.2.0" + } + }, + "node_modules/@chevrotain/gast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-13.2.0.tgz", + "integrity": "sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "13.2.0" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-13.2.0.tgz", + "integrity": "sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz", + "integrity": "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-13.2.0.tgz", + "integrity": "sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==", + "license": "Apache-2.0" + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-angular": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", + "integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.3" + } + }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", + "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-go": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", + "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/go": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.12", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz", + "integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-java": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", + "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/java": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-jinja": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz", + "integrity": "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-less": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", + "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-liquid": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.3.tgz", + "integrity": "sha512-U9EnyzWuAx1TX3MZSKDXydKH734n1Aogk/ijKdW+jdIwmbf9zSPQYOIKggC/b2PTRjQb7ddvMktmxnfTLuv/SQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz", + "integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-php": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", + "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/php": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-rust": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", + "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/rust": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sass": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", + "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/sass": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-vue": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", + "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-wast": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", + "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/language-data": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.2.tgz", + "integrity": "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-angular": "^0.1.0", + "@codemirror/lang-cpp": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-go": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-java": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/lang-jinja": "^6.0.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-less": "^6.0.0", + "@codemirror/lang-liquid": "^6.0.0", + "@codemirror/lang-markdown": "^6.0.0", + "@codemirror/lang-php": "^6.0.0", + "@codemirror/lang-python": "^6.0.0", + "@codemirror/lang-rust": "^6.0.0", + "@codemirror/lang-sass": "^6.0.0", + "@codemirror/lang-sql": "^6.0.0", + "@codemirror/lang-vue": "^0.1.1", + "@codemirror/lang-wast": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-yaml": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/legacy-modes": "^6.4.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz", + "integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.13", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz", + "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz", + "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^2.0.1", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/cpp": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz", + "integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.8.tgz", + "integrity": "sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/go": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/java": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.4.tgz", + "integrity": "sha512-gQylJ2xHV2xXsVqtnAcJrcROvLZJaQiC0tR//+q0k961BxqvyYttusPkJ8d9T1fPDJKHJW2etUH8XbIn9jhVUg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz", + "integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz", + "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/php": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.6.tgz", + "integrity": "sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.3.tgz", + "integrity": "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/sass": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", + "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@mermaid-js/parser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.1.tgz", + "integrity": "sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~13.2.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@milkdown/components": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/components/-/components-7.22.2.tgz", + "integrity": "sha512-qlXh+KvMeELXdy2h0ndIVKffbuAr2mkuVYbEDuyTBffRC00SfRKOVf+ObZBKpvAoQKo18iDS4tCHFMmkzr29EA==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.5.1", + "@milkdown/core": "7.22.2", + "@milkdown/ctx": "7.22.2", + "@milkdown/exception": "7.22.2", + "@milkdown/plugin-diff": "7.22.2", + "@milkdown/plugin-tooltip": "7.22.2", + "@milkdown/preset-commonmark": "7.22.2", + "@milkdown/preset-gfm": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/transformer": "7.22.2", + "@milkdown/utils": "7.22.2", + "@types/lodash-es": "^4.17.12", + "clsx": "^2.0.0", + "dompurify": "^3.2.5", + "lodash-es": "^4.17.21", + "nanoid": "^6.0.0", + "unist-util-visit": "^5.0.0", + "vue": "^3.5.20" + }, + "peerDependencies": { + "@codemirror/language": "^6", + "@codemirror/state": "^6", + "@codemirror/view": "^6" + } + }, + "node_modules/@milkdown/core": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/core/-/core-7.22.2.tgz", + "integrity": "sha512-grJ0enYKFwfV9Ur2GLiDtzOi7kzSqhCueBudqgfmmlx3HVGXwg78/CXsYDhn8cNFqpPSA0TMhdxIpBFKxXOMKA==", + "license": "MIT", + "dependencies": { + "@milkdown/ctx": "7.22.2", + "@milkdown/exception": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/transformer": "7.22.2", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.3" + } + }, + "node_modules/@milkdown/ctx": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/ctx/-/ctx-7.22.2.tgz", + "integrity": "sha512-w2kkMfBIxTpXtnjqltxIiMhflfZzyPN+q31ZwLtwUZjgTpiPCHS/ROUOH1U891mKNUKcD10lOFfOQSS959yIqg==", + "license": "MIT", + "dependencies": { + "@milkdown/exception": "7.22.2" + } + }, + "node_modules/@milkdown/exception": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/exception/-/exception-7.22.2.tgz", + "integrity": "sha512-uS4Gx6xE/iTSKslverVWIWQ8RN5B+NXxP8BLSATnlwDKiYlgBYZYLbvSx632VM9j5Qsd5YuKRGt9k3dXe54EWw==", + "license": "MIT" + }, + "node_modules/@milkdown/kit": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/kit/-/kit-7.22.2.tgz", + "integrity": "sha512-omjaInj/xiuVnSchr9vm7Kv+ORgFtmdmk0xUbeCEj7ptpCl0aS71vTFjuuc/FKSpTxcJ2ptHa52mrR6MvK8Bhw==", + "license": "MIT", + "dependencies": { + "@milkdown/components": "7.22.2", + "@milkdown/core": "7.22.2", + "@milkdown/ctx": "7.22.2", + "@milkdown/exception": "7.22.2", + "@milkdown/plugin-block": "7.22.2", + "@milkdown/plugin-clipboard": "7.22.2", + "@milkdown/plugin-cursor": "7.22.2", + "@milkdown/plugin-diff": "7.22.2", + "@milkdown/plugin-history": "7.22.2", + "@milkdown/plugin-indent": "7.22.2", + "@milkdown/plugin-listener": "7.22.2", + "@milkdown/plugin-slash": "7.22.2", + "@milkdown/plugin-streaming": "7.22.2", + "@milkdown/plugin-tooltip": "7.22.2", + "@milkdown/plugin-trailing": "7.22.2", + "@milkdown/plugin-upload": "7.22.2", + "@milkdown/preset-commonmark": "7.22.2", + "@milkdown/preset-gfm": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/transformer": "7.22.2", + "@milkdown/utils": "7.22.2" + } + }, + "node_modules/@milkdown/plugin-block": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-block/-/plugin-block-7.22.2.tgz", + "integrity": "sha512-9rwkvGsM+zP+rlxIVjZVQX86nUe7/vBc38HwcYTqEEaXAuVcw1BJ8dcpvvlbsywlZqKlazyFJ32uf+X2+tFfnw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.5.1", + "@milkdown/core": "7.22.2", + "@milkdown/ctx": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/utils": "7.22.2", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21" + } + }, + "node_modules/@milkdown/plugin-clipboard": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-clipboard/-/plugin-clipboard-7.22.2.tgz", + "integrity": "sha512-6Zr5r7hURwZKvKi7ox+7lrET7ktRgxcBSvrB/XwmfxN69F8M+vILJFlW+ZZ2ezm3x81pu3kpAsvxZJN7d7ol2g==", + "license": "MIT", + "dependencies": { + "@milkdown/core": "7.22.2", + "@milkdown/ctx": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/utils": "7.22.2" + } + }, + "node_modules/@milkdown/plugin-cursor": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-cursor/-/plugin-cursor-7.22.2.tgz", + "integrity": "sha512-lTjYXmhYFxlB6JLa+UMI3dvKQJog+I58Gtpl1qn26ynG+jC24bGLNNzj9H7/EaN1yKGevnvl/Hq6EhCjnnUXtg==", + "license": "MIT", + "dependencies": { + "@milkdown/ctx": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/utils": "7.22.2", + "prosemirror-drop-indicator": "^0.1.0" + } + }, + "node_modules/@milkdown/plugin-diff": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-diff/-/plugin-diff-7.22.2.tgz", + "integrity": "sha512-lIAPV1Xh9M3kwXyXZ57CYQBMPQanxSgz3r0n3QKxpCR0LEZnCBZMF61CMsD2c+gcGNG9drnmfFQJLXAhEq2hjw==", + "license": "MIT", + "dependencies": { + "@milkdown/core": "7.22.2", + "@milkdown/ctx": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/transformer": "7.22.2", + "@milkdown/utils": "7.22.2" + } + }, + "node_modules/@milkdown/plugin-history": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-history/-/plugin-history-7.22.2.tgz", + "integrity": "sha512-30jM0TWWbG2gwd9QtbrXHfNi737sr6Q5O26eZqIPsmDVFAfqm+ZEd5cpJWngcQ3C5GflvWxyPC+xPEqY97B9AA==", + "license": "MIT", + "dependencies": { + "@milkdown/core": "7.22.2", + "@milkdown/ctx": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/utils": "7.22.2" + } + }, + "node_modules/@milkdown/plugin-indent": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-indent/-/plugin-indent-7.22.2.tgz", + "integrity": "sha512-U5W1ZAFV0VmP4YBn192fD/Uk1qnf4q+boWEgtHcXL84hb0EwbXqXHsTTkW8SjKHGQchnT8cQyDRomfbVw/4Q2A==", + "license": "MIT", + "dependencies": { + "@milkdown/ctx": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/utils": "7.22.2" + } + }, + "node_modules/@milkdown/plugin-listener": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-listener/-/plugin-listener-7.22.2.tgz", + "integrity": "sha512-KXsfm2f1tmHtBZHTIvaVsu/Y6TVdgmxJBacooCE6R8tcB9Segj24ICNy0/0+t3i85GUTr1rzx+2rGmpj5WhNZA==", + "license": "MIT", + "dependencies": { + "@milkdown/core": "7.22.2", + "@milkdown/ctx": "7.22.2", + "@milkdown/prose": "7.22.2", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21" + } + }, + "node_modules/@milkdown/plugin-slash": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-slash/-/plugin-slash-7.22.2.tgz", + "integrity": "sha512-4hL/dj6fCs3MOFKGHpXIXAAvu/Hm9fxG2tv2XOZ6Z3ps63z9q8xMSTyuFx1cV9U0z+3J64LnKaQ1lgwzviwD9A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.5.1", + "@milkdown/ctx": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/utils": "7.22.2", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21" + } + }, + "node_modules/@milkdown/plugin-streaming": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-streaming/-/plugin-streaming-7.22.2.tgz", + "integrity": "sha512-fxB2veftnm490aE1TPCJuTftiZSfTR7CZ2JEVmgMQHGy4HApIqJ9MqOyMevR23GSX/Qef9rWsZJNQRgI+9VazA==", + "license": "MIT", + "dependencies": { + "@milkdown/core": "7.22.2", + "@milkdown/ctx": "7.22.2", + "@milkdown/plugin-diff": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/utils": "7.22.2" + } + }, + "node_modules/@milkdown/plugin-tooltip": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-tooltip/-/plugin-tooltip-7.22.2.tgz", + "integrity": "sha512-bMT5Kj+sNliKHzaH9o/7JdUAnmoTkTjanG7muRBw6vo9oQVoeSpWuRrZaE6qC25mQoUzCeqrGukMRtVhnwSyhg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.5.1", + "@milkdown/ctx": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/utils": "7.22.2", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21" + } + }, + "node_modules/@milkdown/plugin-trailing": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-trailing/-/plugin-trailing-7.22.2.tgz", + "integrity": "sha512-VH5oVcT4Nvb7MbmJpQdqgTZmaBDtRsUmrbIEewMsyDMCF6WWiV9Ykxxg4S+D/BKZdyQkooWUTd97pP/XGN3eEw==", + "license": "MIT", + "dependencies": { + "@milkdown/ctx": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/utils": "7.22.2" + } + }, + "node_modules/@milkdown/plugin-upload": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-upload/-/plugin-upload-7.22.2.tgz", + "integrity": "sha512-fkBiyvQt25IX9DxIeiT5kScCAv2uo/94wwXUEOtEFs+F2RtvbiXHRXx6xzrymepT3L6TYGchxTyKSEWOOLe/qg==", + "license": "MIT", + "dependencies": { + "@milkdown/core": "7.22.2", + "@milkdown/ctx": "7.22.2", + "@milkdown/exception": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/utils": "7.22.2" + } + }, + "node_modules/@milkdown/preset-commonmark": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/preset-commonmark/-/preset-commonmark-7.22.2.tgz", + "integrity": "sha512-+dy6qG7kcHsuh63saIhP4fg2wdqYMrJWvXZH7CxGDFMg1GMVbBeCPtOHlhO+2CssCKgbwctelbHRvdEHZYRmIw==", + "license": "MIT", + "dependencies": { + "@milkdown/core": "7.22.2", + "@milkdown/ctx": "7.22.2", + "@milkdown/exception": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/transformer": "7.22.2", + "@milkdown/utils": "7.22.2", + "remark-inline-links": "^7.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1" + } + }, + "node_modules/@milkdown/preset-gfm": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/preset-gfm/-/preset-gfm-7.22.2.tgz", + "integrity": "sha512-G9NJ0seKnuM6k6SM9rXzXmT8zfemFcVJn+yekrqPkQ2VniwX+cJDHG3s7+cWedPVRFOMzKvpHKMkaPSO34IZtg==", + "license": "MIT", + "dependencies": { + "@milkdown/core": "7.22.2", + "@milkdown/ctx": "7.22.2", + "@milkdown/exception": "7.22.2", + "@milkdown/preset-commonmark": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/transformer": "7.22.2", + "@milkdown/utils": "7.22.2", + "prosemirror-safari-ime-span": "^1.0.1", + "remark-gfm": "^4.0.1" + } + }, + "node_modules/@milkdown/prose": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/prose/-/prose-7.22.2.tgz", + "integrity": "sha512-sh23B62WiA9+rRwDV1OhC34bpOPRGKbDWDdYxkJkMn+YGUj5HXhNb6hJmqDSqTdPcB2WQTF+WNVbrtde/jkVPg==", + "license": "MIT", + "dependencies": { + "@milkdown/exception": "7.22.2", + "prosemirror-changeset": "^2.3.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.0", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.1", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.3" + } + }, + "node_modules/@milkdown/transformer": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/transformer/-/transformer-7.22.2.tgz", + "integrity": "sha512-RP2NkKFKHwzUidPvi7V9le7o68KwCuMdFCxkvwX5eaxaxHEmH6v4p5GKoCnjU3+SWUc4XOPKXA15/b6Byg7TuA==", + "license": "MIT", + "dependencies": { + "@milkdown/exception": "7.22.2", + "@milkdown/prose": "7.22.2", + "remark": "^15.0.1", + "unified": "^11.0.3" + } + }, + "node_modules/@milkdown/utils": { + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@milkdown/utils/-/utils-7.22.2.tgz", + "integrity": "sha512-lRKIaX+kFZycqHxc2xtIwgPdp5BWlSKJKCljZ8Uiih98uaNWQvFlpfzc5Bp4+XJS6BEq2K3ZVILUKa5eWJofWw==", + "license": "MIT", + "dependencies": { + "@milkdown/core": "7.22.2", + "@milkdown/ctx": "7.22.2", + "@milkdown/exception": "7.22.2", + "@milkdown/prose": "7.22.2", + "@milkdown/transformer": "7.22.2", + "nanoid": "^6.0.0" + } + }, + "node_modules/@ocavue/utils": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@ocavue/utils/-/utils-1.8.0.tgz", + "integrity": "sha512-BD1ro+6Kllkmr/at+o32kvnLrVooUbk9gw3JAPaoAZXx+A5GhZ6Laa+UA38ifhG3vaamx+jcgEwX22BmTSMUVA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "license": "MIT" + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chevrotain": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-13.2.0.tgz", + "integrity": "sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "13.2.0", + "@chevrotain/gast": "13.2.0", + "@chevrotain/regexp-to-ast": "13.2.0", + "@chevrotain/types": "13.2.0", + "@chevrotain/utils": "13.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/katex": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.19.0.tgz", + "integrity": "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^15.0.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.3.tgz", + "integrity": "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz", + "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz", + "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mermaid": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-12.1.0.tgz", + "integrity": "sha512-wlVCp+8eTupfCeeFvoZNNiTuHrvag0P2jz/ILgb/f/6jkVokUefOcujefi8qUe/j2asHiSePncVsz/xzzA80LQ==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^2.0.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "chevrotain": "~13.2.0", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.21", + "dompurify": "^3.4.12", + "elkjs": "^0.9.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.47", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/mermaid/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/mermaid/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/micromark-extension-math/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-6.0.2.tgz", + "integrity": "sha512-hIvVOb9Qr1p/9CNNLOe0OlWvgx/ziTsVwKN/GDSsuArN2K0wDiafP3s0SWmsm7o2BdnyS8Z9Z64NFBUbn6ziBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^22 || ^24 || >=26" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/package-manager-detector": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", + "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz", + "integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-drop-indicator": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/prosemirror-drop-indicator/-/prosemirror-drop-indicator-0.1.4.tgz", + "integrity": "sha512-YaRB1pZmU5GCorPVWbc9dbhbwqr4iMBO/AjPu4BTKHCUzxEDUXje2dUyoxOHib/z4uyPUZTJz64h7mHDJZeSzA==", + "license": "MIT", + "dependencies": { + "@ocavue/utils": "^1.6.0", + "prosemirror-model": "^1.25.7", + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.41.8" + }, + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz", + "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz", + "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.12", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz", + "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-safari-ime-span": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/prosemirror-safari-ime-span/-/prosemirror-safari-ime-span-1.0.2.tgz", + "integrity": "sha512-QJqD8s1zE/CuK56kDsUhndh5hiHh/gFnAuPOA9ytva2s85/ZEt2tNWeALTJN48DtWghSKOmiBsvVn2OlnJ5H2w==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.4.3", + "prosemirror-view": "^1.33.8" + }, + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-search": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prosemirror-search/-/prosemirror-search-1.1.1.tgz", + "integrity": "sha512-1dbxbF1YYlP9mYJnFbfdOb9+RTdFHeZ/GwgEyHD4jg9szA8EVitMCnfXUBYLmFun500FMZBkRvcV0gp5BpLw4Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0", + "prosemirror-state": "^1.4.3", + "prosemirror-view": "^1.33.6" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz", + "integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.6", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz", + "integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.12", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/rehype-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", + "integrity": "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-inline-links": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/remark-inline-links/-/remark-inline-links-7.0.0.tgz", + "integrity": "sha512-4uj1pPM+F495ySZhTIB6ay2oSkTsKgmYaKk/q5HIdhX2fuyLEegpjWa0VdJRJ01sgOqAFo7MBKdDUejIYBMVMQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-definitions": "^6.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/apps/ely-mark/web/package.json b/apps/ely-mark/web/package.json new file mode 100644 index 0000000..7cb964c --- /dev/null +++ b/apps/ely-mark/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "ely-mark-content", + "private": true, + "type": "module", + "dependencies": { + "@codemirror/commands": "6.11.1", + "@codemirror/lang-markdown": "6.5.2", + "@codemirror/language": "6.12.4", + "@codemirror/language-data": "6.5.2", + "@codemirror/search": "6.7.2", + "@codemirror/state": "6.7.6", + "@codemirror/view": "6.43.13", + "@lezer/highlight": "1.2.5", + "@milkdown/kit": "7.22.2", + "codemirror": "6.0.2", + "hast-util-from-dom": "5.0.1", + "hast-util-to-text": "4.0.2", + "katex": "0.19.0", + "mermaid": "12.1.0", + "prosemirror-search": "1.1.1", + "rehype-katex": "7.0.1", + "rehype-stringify": "10.0.1", + "remark-gfm": "4.0.1", + "remark-math": "6.0.0", + "remark-parse": "11.0.0", + "remark-rehype": "11.1.2", + "unified": "11.0.5", + "unist-util-visit": "5.1.0", + "vfile": "6.0.3" + }, + "devDependencies": { + "esbuild": "0.28.2" + } +} diff --git a/apps/ely-mark/web/src/bridge.js b/apps/ely-mark/web/src/bridge.js new file mode 100644 index 0000000..7570bf6 --- /dev/null +++ b/apps/ely-mark/web/src/bridge.js @@ -0,0 +1,10 @@ +/** One request to the app, as a promise. */ +export function ask(request) { + return new Promise((resolve, reject) => { + window.elyQuery({ + request: JSON.stringify(request), + onSuccess: resolve, + onFailure: (code, message) => reject(new Error(`${code}: ${message}`)), + }) + }) +} diff --git a/apps/ely-mark/web/src/code.js b/apps/ely-mark/web/src/code.js new file mode 100644 index 0000000..e905f75 --- /dev/null +++ b/apps/ely-mark/web/src/code.js @@ -0,0 +1,44 @@ +// Code blocks in CodeMirror; LaTeX and Mermaid preview. +import { basicSetup, EditorView } from 'codemirror' +import { EditorState } from '@codemirror/state' +import { keymap } from '@codemirror/view' +import { indentWithTab } from '@codemirror/commands' +import { languages } from '@codemirror/language-data' +import { codeBlockConfig } from '@milkdown/kit/component/code-block' +import { theme } from './theme.js' +import { icons } from './icons.js' +import { render } from './math.js' + +/** Previews for one editor's code blocks. */ +function previews(owner) { + return (language, source, apply) => { + const kind = source.length === 0 ? '' : language.toLowerCase() + switch (kind) { + case 'latex': + return render(source, true, document.createElement('div')) + case 'mermaid': + return owner.show(source, apply) + default: + return null + } + } +} + +/** Milkdown's code block config; read-only in preview. */ +export function code(texts, editable, owner) { + const locked = editable ? [] : [EditorState.readOnly.of(true), EditorView.editable.of(false)] + return (ctx) => + ctx.update(codeBlockConfig.key, (prev) => ({ + ...prev, + ...icons, + extensions: [basicSetup, keymap.of([indentWithTab]), theme, ...locked], + languages, + searchPlaceholder: texts.searchLanguage, + noResultText: texts.noResult, + copyText: texts.copy, + previewLabel: texts.preview, + previewLoading: texts.loading, + previewToggleButton: (only) => (only ? texts.edit : texts.hide), + renderPreview: previews(owner), + })) +} diff --git a/apps/ely-mark/web/src/diagram.js b/apps/ely-mark/web/src/diagram.js new file mode 100644 index 0000000..67b232d --- /dev/null +++ b/apps/ely-mark/web/src/diagram.js @@ -0,0 +1,34 @@ +// Mermaid, loaded on first use; only saved source renders. +import mermaid from 'mermaid' + +let count = 0 + +/** The theme from the page's Ely tokens. */ +function variables() { + const style = getComputedStyle(document.documentElement) + const token = (name) => style.getPropertyValue(`--ely-${name}`).trim() + return { + background: token('bg'), + primaryColor: token('surface'), + primaryTextColor: token('fg'), + primaryBorderColor: token('border'), + lineColor: token('fg-muted'), + textColor: token('fg'), + fontFamily: token('font'), + } +} + +/** `source` as SVG; `paper` draws Mermaid's light theme for export. */ +window.elyDiagram = async (source, look) => { + const paper = look === 'paper' + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + suppressErrorRendering: true, + theme: paper ? 'default' : 'base', + themeVariables: paper ? {} : variables(), + }) + count += 1 + const { svg } = await mermaid.render(`ely-diagram-${count}`, source) + return svg +} diff --git a/apps/ely-mark/web/src/diagrams.js b/apps/ely-mark/web/src/diagrams.js new file mode 100644 index 0000000..6707fa0 --- /dev/null +++ b/apps/ely-mark/web/src/diagrams.js @@ -0,0 +1,90 @@ +// Mermaid previews; each editor owns its records. +import { failure } from './failure.js' + +// Beside this script; the page's base is the document's folder. +const here = document.currentScript.src + +/** The Mermaid bundle, added once as a classic script. */ +let loading +export function bundle() { + loading ??= new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = new URL('diagram.js', here).href + script.onload = () => resolve(window.elyDiagram) + script.onerror = () => reject(new Error('diagram.js did not load')) + document.head.append(script) + }) + return loading +} + +let queue = Promise.resolve() +let count = 0 +const owners = new Set() + +// New tokens mean a new theme; every owner redraws. +new MutationObserver(() => { + for (const owner of owners) owner.redraw() +}).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }) + +/** A block's diagram, by its id on the page. */ +const box = (id, inner) => `
${inner}
` +const placed = (id) => document.querySelector(`[data-diagram="${id}"]`) !== null +// After the code block's next render. +const settle = () => new Promise((done) => setTimeout(done)) + +/** One editor's diagrams; a result lands only on its placeholder. */ +export function diagrams() { + const records = new Map() + + /** Drops diagrams whose placeholder has left the page. */ + const sweep = () => { + for (const [id, record] of records) { + if (record.settled && !placed(id)) records.delete(id) + } + } + + /** Each draw has a turn; a later draw voids earlier. */ + const draw = (id, record) => { + const turn = ++record.turn + const live = () => records.get(id) === record && record.turn === turn && placed(id) + queue = queue.then(async () => { + await settle() + if (!live()) return + try { + const render = await bundle() + if (!live()) return + const svg = await render(record.source) + await settle() + if (live()) record.apply(box(id, svg)) + } catch (error) { + await settle() + if (!live()) return + records.delete(id) + record.apply(failure(record.source, error.message)) + } + }) + } + + const owner = { + /** The placeholder for `source`; its diagram follows. */ + show(source, apply) { + sweep() + const id = `${++count}` + const record = { source, apply, settled: false, turn: 0 } + records.set(id, record) + settle().then(() => (record.settled = true)) + draw(id, record) + return box(id, '') + }, + redraw() { + sweep() + for (const [id, record] of records) draw(id, record) + }, + release() { + records.clear() + owners.delete(owner) + }, + } + owners.add(owner) + return owner +} diff --git a/apps/ely-mark/web/src/diff.js b/apps/ely-mark/web/src/diff.js new file mode 100644 index 0000000..850136a --- /dev/null +++ b/apps/ely-mark/web/src/diff.js @@ -0,0 +1,53 @@ +// Myers' diff over two lists. + +/** Equal pairs between `a` and `b`, in order. */ +export function common(a, b, same) { + const n = a.length + const m = b.length + const max = n + m + const reach = new Map([[1, 0]]) + const trace = [] + for (let d = 0; d <= max; d++) { + trace.push(new Map(reach)) + for (let k = -d; k <= d; k += 2) { + const down = k === -d || (k !== d && reach.get(k - 1) < reach.get(k + 1)) + let x = down ? reach.get(k + 1) : reach.get(k - 1) + 1 + let y = x - k + while (x < n && y < m && same(a[x], b[y])) { + x += 1 + y += 1 + } + reach.set(k, x) + if (x >= n && y >= m) return walk(trace, n, m) + } + } + throw new Error('diff did not end') +} + +/** Back through the trace: each equal pair, in order. */ +function walk(trace, n, m) { + const pairs = [] + let x = n + let y = m + for (let d = trace.length - 1; d > 0; d--) { + const reach = trace[d] + const k = x - y + const down = k === -d || (k !== d && reach.get(k - 1) < reach.get(k + 1)) + const from = down ? k + 1 : k - 1 + const fromX = reach.get(from) + const fromY = fromX - from + while (x > fromX + (down ? 0 : 1) && y > fromY + (down ? 1 : 0)) { + x -= 1 + y -= 1 + pairs.push([x, y]) + } + x = fromX + y = fromY + } + while (x > 0 && y > 0) { + x -= 1 + y -= 1 + pairs.push([x, y]) + } + return pairs.reverse() +} diff --git a/apps/ely-mark/web/src/export.css b/apps/ely-mark/web/src/export.css new file mode 100644 index 0000000..4ed2eea --- /dev/null +++ b/apps/ely-mark/web/src/export.css @@ -0,0 +1,72 @@ +/* Exports: KaTeX with its fonts inline, on light paper. */ +@import 'katex/dist/katex.min.css'; + +:root { + color-scheme: light; +} + +body { + margin: 0 auto; + max-width: 48rem; + padding: 2rem 1.5rem; + background: #fff; + color: #1f2328; + font: 16px / 1.6 -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif; +} + +a { + color: #0969da; +} + +pre, +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.875em; +} + +pre { + overflow-x: auto; + padding: 0.75rem 1rem; + border-radius: 6px; + background: #f6f8fa; +} + +blockquote { + margin: 0; + padding: 0 1rem; + border-left: 3px solid #d0d7de; + color: #59636e; +} + +table { + border-collapse: collapse; +} + +th, +td { + padding: 0.25rem 0.75rem; + border: 1px solid #d0d7de; +} + +img, +figure.diagram svg { + max-width: 100%; +} + +figure.diagram { + margin: 1rem 0; + text-align: center; +} + +@media print { + body { + max-width: none; + padding: 0; + } + + pre, + table, + figure { + break-inside: avoid; + } +} diff --git a/apps/ely-mark/web/src/failure.js b/apps/ely-mark/web/src/failure.js new file mode 100644 index 0000000..8668b73 --- /dev/null +++ b/apps/ely-mark/web/src/failure.js @@ -0,0 +1,11 @@ +/** The source as saved, and why it did not render. */ +export function failure(source, message, at) { + const box = document.createElement('div') + box.className = 'ely-failure' + const why = document.createElement('p') + why.textContent = at === undefined ? message : `${message} (${at})` + const code = document.createElement('pre') + code.textContent = source + box.append(why, code) + return box +} diff --git a/apps/ely-mark/web/src/find.js b/apps/ely-mark/web/src/find.js new file mode 100644 index 0000000..7d154b0 --- /dev/null +++ b/apps/ely-mark/web/src/find.js @@ -0,0 +1,138 @@ +// Find and replace in either editor (MARK-06). +import { + SearchQuery as TextQuery, + findNext as nextText, + findPrevious as previousText, + replaceAll as allText, + replaceNext as oneText, + setSearchQuery, +} from '@codemirror/search' +import { + SearchQuery as DocQuery, + findNext as nextDoc, + findPrev as previousDoc, + replaceAll as allDoc, + replaceNext as oneDoc, + setSearchState, +} from 'prosemirror-search' +import { TextSelection } from '@milkdown/kit/prose/state' + +/** Why a pattern fails under the editors' `gmu` flags. */ +function broken(spec) { + if (!spec.regexp || !spec.search) return null + try { + new RegExp(spec.search, 'gmu') + return null + } catch (error) { + return error.message + } +} + +/** Whether two specs find the same hits. */ +export function sameQuery(a, b) { + return ['search', 'caseSensitive', 'regexp', 'wholeWord'].every((key) => a[key] === b[key]) +} + +/** One character's width at `pos`: two for a surrogate pair. */ +function width(doc, pos) { + const next = doc.textBetween(pos, Math.min(pos + 2, doc.content.size), '\n', '\n') + return next.codePointAt(0) > 0xffff ? 2 : 1 +} + +/** Where the selection stands among `ranges`. */ +function standing(ranges, from, to) { + const at = ranges.findIndex((range) => range.from === from && range.to === to) + return { current: at < 0 ? null : at, total: ranges.length } +} + +/** Find in Milkdown's view; `prosemirror-search` is installed there. */ +export function docFinder(view) { + let spec = { search: '' } + const query = () => new DocQuery(spec) + const ranges = () => { + const found = [] + const asked = query() + if (!asked.valid) return found + const { doc } = view.state + for (let at = 0; ;) { + const hit = asked.findNext(view.state, at) + // Mid-pair, a Unicode pattern answers before `at`. + if (!hit || hit.from < at) return found + found.push(hit) + at = hit.to > hit.from ? hit.to : hit.to + width(doc, hit.to) + } + } + const status = () => { + const error = broken(spec) + if (error) return { error } + const { from, to } = view.state.selection + return standing(ranges(), from, to) + } + const run = (command) => command(view.state, view.dispatch) + return { + seek(next) { + spec = next + const { from } = view.state.selection + const tr = setSearchState(view.state.tr, query()) + // From the hit's start: the hit stays while typing. + tr.setSelection(TextSelection.near(tr.doc.resolve(from))) + view.dispatch(tr) + if (query().valid) run(nextDoc) + return status() + }, + step(forward) { + if (query().valid) run(forward ? nextDoc : previousDoc) + return status() + }, + replace(all) { + if (query().valid) run(all ? allDoc : oneDoc) + return status() + }, + clear() { + spec = { search: '' } + view.dispatch(setSearchState(view.state.tr, query())) + }, + } +} + +/** Find in CodeMirror; its `search()` extension is installed there. */ +export function textFinder(view) { + let spec = { search: '' } + const valid = () => new TextQuery(spec).valid + const ranges = () => { + const found = [] + const asked = new TextQuery(spec) + if (!asked.valid) return found + const cursor = asked.getCursor(view.state) + for (let next = cursor.next(); !next.done; next = cursor.next()) found.push(next.value) + return found + } + const status = () => { + const error = broken(spec) + if (error) return { error } + const { from, to } = view.state.selection.main + return standing(ranges(), from, to) + } + return { + seek(next) { + spec = next + const selection = { anchor: view.state.selection.main.from } + view.dispatch({ effects: setSearchQuery.of(new TextQuery(spec)), selection }) + // CodeMirror opens its own panel for invalid queries. + if (valid()) nextText(view) + return status() + }, + step(forward) { + if (valid()) (forward ? nextText : previousText)(view) + return status() + }, + replace(all) { + if (valid()) (all ? allText : oneText)(view) + return status() + }, + clear() { + spec = { search: '' } + view.dispatch({ effects: setSearchQuery.of(new TextQuery(spec)) }) + }, + } +} diff --git a/apps/ely-mark/web/src/icons.js b/apps/ely-mark/web/src/icons.js new file mode 100644 index 0000000..68ab6ac --- /dev/null +++ b/apps/ely-mark/web/src/icons.js @@ -0,0 +1,12 @@ +// Code block icons, drawn in the text color. +const icon = (path) => + `${path}` + +export const icons = { + expandIcon: icon(''), + searchIcon: icon(''), + clearSearchIcon: icon(''), + copyIcon: icon( + '', + ), +} diff --git a/apps/ely-mark/web/src/image.js b/apps/ely-mark/web/src/image.js new file mode 100644 index 0000000..74a740b --- /dev/null +++ b/apps/ely-mark/web/src/image.js @@ -0,0 +1,14 @@ +// Untitled images: remark gives null, the schema wants text. +import { imageSchema } from '@milkdown/kit/preset/commonmark' + +export const image = imageSchema.extendSchema((prev) => (ctx) => { + const base = prev(ctx) + return { + ...base, + parseMarkdown: { + match: base.parseMarkdown.match, + runner: (state, node, type) => + state.addNode(type, { src: node.url, alt: node.alt ?? '', title: node.title ?? '' }), + }, + } +}) diff --git a/apps/ely-mark/web/src/look.js b/apps/ely-mark/web/src/look.js new file mode 100644 index 0000000..298c103 --- /dev/null +++ b/apps/ely-mark/web/src/look.js @@ -0,0 +1,22 @@ +// The writing view: focus, typewriter, width, font, size (MARK-09). + +let typewriter = false + +/** Applies `look`; the Markdown never changes. */ +export function apply(look) { + const root = document.documentElement + root.dataset.focus = look.focus + root.dataset.width = look.width + root.dataset.font = look.font + root.dataset.typewriter = look.typewriter + typewriter = look.typewriter + const sheet = + document.getElementById('ely-look') ?? + document.head.appendChild(Object.assign(document.createElement('style'), { id: 'ely-look' })) + sheet.textContent = `:root { --ely-text-scale: ${look.size / 100}; }` +} + +/** Typewriter scrolling: the caret's line stays mid-view. */ +export function follow(editor) { + if (typewriter) editor?.center?.() +} diff --git a/apps/ely-mark/web/src/main.css b/apps/ely-mark/web/src/main.css new file mode 100644 index 0000000..a09adf6 --- /dev/null +++ b/apps/ely-mark/web/src/main.css @@ -0,0 +1,320 @@ +@import 'katex/dist/katex.min.css'; +@import '@milkdown/kit/prose/view/style/prosemirror.css'; + +html, +body { + margin: 0; + height: 100%; + background: var(--ely-bg); + color: var(--ely-fg); + font: calc(16px * var(--ely-font-scale, 1) * var(--ely-text-scale, 1)) / 1.6 var(--ely-font), + sans-serif; +} + +::selection { + background: var(--ely-selection); +} + +a { + color: var(--ely-link); +} + +#document { + box-sizing: border-box; + max-width: 48rem; + margin: 0 auto; + padding: 2rem 1.5rem; +} + +:root[data-width='narrow'] #document { + max-width: 36rem; +} + +:root[data-width='wide'] #document { + max-width: 64rem; +} + +:root[data-font='serif'] #document:not([data-mode='source']) { + font-family: ui-serif, Georgia, serif; +} + +:root[data-font='mono'] #document:not([data-mode='source']) { + font-family: var(--ely-mono), monospace; +} + +/* Typewriter: room to center the first and last lines. */ +:root[data-typewriter='true'] #document:not([data-mode='source']) { + padding-block: 50vh; +} + +:root[data-typewriter='true'] .cm-content { + padding-block: 50vh; +} + +/* Focus mode: the caret's block, or line, stays bright. */ +:root[data-focus='true'] .milkdown .ProseMirror > :not(.ely-current), +:root[data-focus='true'] .cm-line:not(.cm-activeLine) { + opacity: 0.35; +} + +@media (prefers-reduced-motion: no-preference) { + .milkdown .ProseMirror > *, + .cm-line { + transition: opacity 150ms; + } +} + +#document[data-mode='source'] { + max-width: none; + height: 100%; + padding: 0; +} + +#document[data-mode='source'] > div, +#document[data-mode='source'] .cm-editor { + height: 100%; +} + +.milkdown .ProseMirror { + outline: none; +} + +.milkdown blockquote { + margin: 0; + padding-left: 1rem; + border-left: 3px solid var(--ely-border); + color: var(--ely-fg-muted); +} + +.milkdown code { + font-family: var(--ely-mono), monospace; + font-size: 0.875em; + background: var(--ely-sunken); + border-radius: 4px; + padding: 0 0.25em; +} + +.milkdown table { + border-collapse: collapse; +} + +.milkdown th, +.milkdown td { + border: 1px solid var(--ely-border); + padding: 0.25rem 0.75rem; +} + +.milkdown li > p, +.milkdown th > p, +.milkdown td > p { + margin: 0.25em 0; +} + +.milkdown li[data-item-type='task'] { + display: flex; + gap: 0.5em; + list-style: none; +} + +.milkdown li[data-item-type='task']::before { + content: '☐ '; +} + +.milkdown li[data-item-type='task'][data-checked='true']::before { + content: '☑ '; +} + +.milkdown hr { + border: none; + border-top: 1px solid var(--ely-border); +} + +.milkdown img { + max-width: 100%; +} + +.milkdown-code-block { + display: block; + position: relative; + margin: 0.5rem 0; + padding: 0.5rem 1rem 1rem; + background: var(--ely-surface); + border: 1px solid var(--ely-border); + border-radius: 6px; +} + +.milkdown-code-block.selected { + outline: 1px solid var(--ely-focus); +} + +.milkdown-code-block .hidden { + display: none !important; +} + +.milkdown-code-block .tools { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + font-size: 0.75rem; +} + +.milkdown-code-block button, +.milkdown-code-block .language-button { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border: 1px solid var(--ely-border); + border-radius: 4px; + background: var(--ely-sunken); + color: var(--ely-fg-muted); + font: inherit; + cursor: pointer; +} + +[data-mode='preview'] .milkdown-code-block .language-button { + pointer-events: none; +} + +.milkdown-code-block .tools-button-group { + display: flex; + gap: 4px; +} + +.milkdown-code-block button:hover, +.milkdown-code-block .language-button:hover { + background: var(--ely-hover); +} + +.milkdown-code-block .tools svg, +.milkdown-code-block .language-picker svg { + width: 14px; + height: 14px; +} + +.ely-diagram svg { + max-width: 100%; + height: auto; +} + +.milkdown-code-block .language-picker { + position: absolute; + z-index: 1; + padding-top: 4px; +} + +.milkdown-code-block .list-wrapper { + width: 15rem; + padding-top: 0.5rem; + background: var(--ely-surface); + border: 1px solid var(--ely-border); + border-radius: 6px; +} + +.milkdown-code-block .search-box { + display: flex; + align-items: center; + gap: 6px; + margin: 0 0.5rem 0.5rem; + padding: 4px 8px; + border: 1px solid var(--ely-border); + border-radius: 4px; +} + +.milkdown-code-block .search-box input { + width: 100%; + border: none; + outline: none; + background: transparent; + color: var(--ely-fg); + font: inherit; +} + +.milkdown-code-block .search-icon { + display: none; +} + +.milkdown-code-block .language-list { + max-height: 20rem; + overflow-y: auto; + margin: 0; + padding: 0; +} + +.milkdown-code-block .language-list-item { + display: flex; + gap: 8px; + padding: 2px 1rem; + cursor: pointer; +} + +.milkdown-code-block .language-list-item:hover, +.milkdown-code-block .language-list-item:focus-visible { + outline: none; + background: var(--ely-hover); +} + +.milkdown-code-block .language-list-item.no-result { + cursor: default; + color: var(--ely-fg-subtle); +} + +.milkdown-code-block .preview-panel .preview-divider { + height: 1px; + margin: 0.5rem 0; + background: var(--ely-border); +} + +.milkdown-code-block .preview-panel .preview-label { + margin: 0.5rem 0; + font-size: 0.75rem; + color: var(--ely-fg-muted); +} + +.milkdown-code-block .preview { + overflow-x: auto; + text-align: center; +} + +.ely-failure { + text-align: left; + color: var(--ely-danger); +} + +.ely-failure pre { + margin: 0; + white-space: pre-wrap; + color: var(--ely-fg); + font-family: var(--ely-mono), monospace; +} + +.ely-front { + margin: 0 0 1rem; + padding: 0.5rem 1rem; + border: 1px solid var(--ely-border); + border-radius: 6px; + color: var(--ely-fg-muted); + font-family: var(--ely-mono), monospace; + white-space: pre-wrap; +} + +.ely-raw { + margin: 0.5rem 0; + padding: 0.25rem 0.5rem; + background: var(--ely-sunken); + border-radius: 4px; + font-family: var(--ely-mono), monospace; + font-size: 0.875em; + white-space: pre-wrap; +} + +.ProseMirror-search-match, +.cm-searchMatch { + background: var(--ely-selection); +} + +.ProseMirror-active-search-match, +.cm-searchMatch-selected { + outline: 1px solid var(--ely-focus); +} diff --git a/apps/ely-mark/web/src/main.js b/apps/ely-mark/web/src/main.js new file mode 100644 index 0000000..b3d2d70 --- /dev/null +++ b/apps/ely-mark/web/src/main.js @@ -0,0 +1,305 @@ +// The page the app drives: one document, three modes. +import { visual } from './visual.js' +import { source } from './source.js' +import { ask } from './bridge.js' +import { sameQuery } from './find.js' +import { apply, follow } from './look.js' +import { html, text } from './render.js' + +let texts = {} +let latest = 0 +let version = 0 +// The app's count of page loads; reports carry it. +let generation = 0 +let timer +let pending = null +/** The editor shown. */ +let current = null +// A mode asked for while an editor builds. +let asked = null +// A place to show once the editor is built. +let revealing = null +// The search in force, kept across modes. +let seeking = null +/** The file's texts where a mode with edits ended (§14.3). */ +let times = [] +let at = 0 + +/** YAML front matter, kept out of the visual editor. */ +function split(text) { + const front = /^---\n[\s\S]*?\n(?:---|\.\.\.)[ \t]*(?:\n|$)/.exec(text) + return front ? [front[0], text.slice(front[0].length)] : ['', text] +} + +const tell = (said) => ask(said).catch((error) => console.error(`ely: ${error.message}`)) + +/** Sends the waiting text now. */ +function flush() { + clearTimeout(timer) + if (pending) { + tell(pending()) + outline() + } + pending = null +} + +/** The shown editor's headings, for the app's outline. */ +function outline() { + if (current) tell({ kind: 'outline', generation, items: current.outline() }) +} + +/** A new text: told now, its words once typing rests. */ +function revised(text) { + version += 1 + const now = version + tell({ kind: 'changed', version: now, generation }) + clearTimeout(timer) + pending = () => ({ kind: 'text', version: now, generation, text: text() }) + timer = setTimeout(flush, 300) +} + +/** An edit: later texts in the timeline are gone. */ +function changed(text) { + times.length = at + 1 + revised(text) +} + +/** An editor for `mode`; visual falls back to source. */ +async function build(text, mode) { + const holder = document.createElement('div') + holder.hidden = true + document.getElementById('document').append(holder) + const entry = { mode, holder, fallback: false } + if (mode !== 'source') { + const [front, body] = split(text) + if (front) { + const raw = document.createElement('pre') + raw.className = 'ely-front' + raw.textContent = front + holder.append(raw) + } + const place = document.createElement('div') + holder.append(place) + const edited = (write) => changed(() => front + write()) + const made = await visual(place, body, mode === 'visual', texts, edited, edge) + if (made) { + // Lines count from the file's top, front matter included. + const above = front ? front.split('\n').length - 1 : 0 + const line = (n, query) => { + if (n > above) return made.line(n - above, query) + holder.firstChild.scrollIntoView({ block: 'center' }) + return true + } + const redone = () => { + const ahead = made.redone() + return ahead === null ? null : front + ahead + } + const where = () => { + const { line, column } = made.where() + return { line: above + line, column } + } + const at = (n, column) => (n > above ? made.at(n - above, column) : line(n, '')) + return { ...entry, ...made, text: () => front + made.text(), redone, line, where, at } + } + // Blocks the editor cannot map open as source. + holder.replaceChildren() + Object.assign(entry, { mode: 'source', fallback: true }) + } + return { ...entry, ...source(holder, text, changed, edge) } +} + +function drop(entry) { + entry.close() + entry.holder.remove() +} + +/** Shows `text` as `want`; the newest request alone lands. */ +async function show(text, want) { + const turn = ++latest + asked = null + if (current) drop(current) + current = null + const entry = await build(text, want) + if (turn !== latest) return drop(entry) + current = entry + entry.holder.hidden = false + document.getElementById('document').dataset.mode = entry.mode + entry.focus() + await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback }) + // A newer request owns what is queued now. + if (turn !== latest) return + outline() + if (seeking) found(entry.find.seek(seeking)) + const place = revealing + revealing = null + if (place) reveal(place) + const next = asked + asked = null + if (next && next !== entry.mode) return mode(next) +} + +/** Undo or redo past an editor's own history. */ +function edge(direction) { + const to = direction === 'undo' ? at - 1 : at + 1 + if (!current || to < 0 || to >= times.length) return false + flush() + at = to + const text = times[at] + revised(() => text) + show(text, current.mode) + return true +} + +/** Relative links and images resolve under `href`. */ +function base(href) { + const link = + document.querySelector('base') ?? document.head.appendChild(document.createElement('base')) + link.href = href +} + +/** Puts the caret on `place.line`, the page at `place.scroll`. */ +function settle(place) { + if (!current || !place) return + current.at(place.line, place.column) + document.scrollingElement.scrollTop = place.scroll +} + +/** A document, after each page load; `place` restores a session. */ +async function open(text, want, words, load, folder, place) { + texts = words + generation = load + times = [text] + at = 0 + base(folder) + await show(text, want) + settle(place) +} + +/** Changed on disk: the caret and scroll stay near. */ +async function refresh(text) { + const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop } + clearTimeout(timer) + pending = null + times = [text] + at = 0 + await show(text, current?.mode ?? 'visual') + settle(place) +} + +/** `text` in place of the editor's, as one edit. */ +function adopt(text) { + if (current) flush() + times.length = at + 1 + times.push(text) + at += 1 + revised(() => text) + return show(text, current?.mode ?? 'visual') +} + +let placing +/** The caret's line and the scroll, once they rest. */ +function placed() { + clearTimeout(placing) + placing = setTimeout(() => { + if (!current) return + const scroll = document.scrollingElement.scrollTop + tell({ kind: 'place', generation, ...current.where(), scroll }) + }, 500) +} +document.addEventListener('selectionchange', () => { + placed() + follow(current) +}) +window.addEventListener('scroll', placed, { passive: true }) + +/** Another mode for the same text; edits are sent first. */ +function mode(next) { + if (!current) { + asked = next + return + } + const text = current.text() + const ahead = current.redone?.() ?? null + flush() + // Leaving a mode with edits marks the timeline. + if (text !== times[at]) { + times.length = at + 1 + times.push(text) + at += 1 + } + // Undone steps stay ahead, for redo. + if (ahead !== null && ahead !== text) { + times.length = at + 1 + times.push(ahead) + } + return show(text, next) +} + +/** Shows `{ heading }` or `{ line, query }`. */ +function reveal(place) { + if (!current) { + revealing = place + return + } + const shown = + 'heading' in place ? current.heading(place.heading) : current.line(place.line, place.query) + if (shown === false) console.warn(`ely: nothing at ${JSON.stringify(place)}`) +} + +/** The app's find box hears where the search stands. */ +const found = (status) => tell({ kind: 'found', generation, ...status }) + +/** Searches for `spec`: search, caseSensitive, regexp, wholeWord, replace. */ +function find(spec) { + seeking = spec + if (current) found(current.find.seek(spec)) +} + +/** The box's spec rules; a new query starts afresh. */ +function step({ spec, forward }) { + const fresh = !seeking || !sameQuery(seeking, spec) + find(spec) + if (current && !fresh) found(current.find.step(forward)) +} + +function replace({ spec, all }) { + find(spec) + if (current && current.mode !== 'preview') found(current.find.replace(all)) +} + +function unfind() { + seeking = null + current?.find.clear() +} + +/** The writing view's settings (MARK-09). */ +function look(settings) { + apply(settings) + follow(current) +} + +/** The whole text as `kind`: html or text, tagged `ask`. */ +async function render({ kind, ask: asked }) { + try { + if (!current) throw new Error('no document is shown') + const [, body] = split(current.text()) + const result = kind === 'text' ? { text: await text(body) } : await html(body) + tell({ kind: 'rendered', generation, ask: asked, ...result }) + } catch (error) { + tell({ kind: 'rendered', generation, ask: asked, error: error.message }) + } +} + +/** A table step; the app hears whether it ran. */ +function table(op) { + const done = current?.table?.(op) ?? false + tell({ kind: 'table', generation, op, done }) +} + +/** Image links at the caret; preview takes none. */ +function insert(images) { + const done = current?.insert?.(images) ?? false + tell({ kind: 'inserted', generation, done }) +} + +window.ely = { open, refresh, adopt, look, render, mode, base, reveal, find, step, replace, unfind, table, insert } diff --git a/apps/ely-mark/web/src/math.js b/apps/ely-mark/web/src/math.js new file mode 100644 index 0000000..b096d07 --- /dev/null +++ b/apps/ely-mark/web/src/math.js @@ -0,0 +1,100 @@ +// KaTeX math, from Milkdown Crepe's latex feature. +import katex from 'katex' +import remarkMath from 'remark-math' +import { visit } from 'unist-util-visit' +import { $nodeSchema, $remark } from '@milkdown/kit/utils' +import { codeBlockSchema } from '@milkdown/kit/preset/commonmark' +import { failure } from './failure.js' + +/** Saved source as HTML; a bad one shows where. */ +export function render(source, display, dom) { + try { + katex.render(source, dom, { displayMode: display, throwOnError: true }) + } catch (error) { + const at = error instanceof katex.ParseError ? error.position : undefined + dom.replaceChildren(failure(source, error.message, at)) + } + return dom +} + +// remark-math, plus each inline formula's `$` count from the source. +const remark = $remark('remarkMath', () => function (options) { + remarkMath.call(this, options) + return (tree, file) => { + const text = String(file.value) + visit(tree, 'inlineMath', (node) => { + let at = node.position?.start.offset ?? 0 + let fence = 0 + while (text[at++] === '$') fence++ + node.data = { ...node.data, fence: Math.max(fence, 1) } + }) + } +}) + +// Block math becomes a code block in language LaTeX. +const blocks = $remark( + 'remarkMathBlock', + () => () => (tree) => + visit(tree, 'math', (node, index, parent) => { + parent.children.splice(index, 1, { + type: 'code', + lang: 'LaTeX', + value: node.value, + position: node.position, + }) + }), +) + +const block = codeBlockSchema.extendSchema((prev) => (ctx) => { + const base = prev(ctx) + return { + ...base, + toMarkdown: { + match: base.toMarkdown.match, + runner: (state, node) => { + if ((node.attrs.language ?? '').toLowerCase() !== 'latex') { + return base.toMarkdown.runner(state, node) + } + state.addNode('math', undefined, node.content.firstChild?.text || '') + }, + }, + } +}) + +const inline = $nodeSchema('math_inline', () => ({ + group: 'inline', + inline: true, + atom: true, + attrs: { value: { default: '' }, fence: { default: 1 } }, + parseDOM: [ + { + tag: 'span[data-type="math_inline"]', + getAttrs: (dom) => ({ value: dom.dataset.value ?? '', fence: Number(dom.dataset.fence ?? 1) }), + }, + ], + toDOM: (node) => { + const dom = document.createElement('span') + dom.dataset.type = 'math_inline' + dom.dataset.value = node.attrs.value + dom.dataset.fence = node.attrs.fence + // `$$…$$` displays, as GitHub shows it. + return render(node.attrs.value, node.attrs.fence > 1, dom) + }, + parseMarkdown: { + match: (node) => node.type === 'inlineMath', + runner: (state, node, type) => + state.addNode(type, { value: node.value, fence: node.data?.fence ?? 1 }), + }, + toMarkdown: { + match: (node) => node.type.name === 'math_inline', + runner: (state, node) => { + const { value, fence } = node.attrs + if (fence === 1) return state.addNode('inlineMath', undefined, value) + // The stringifier knows one `$`; the source's own count stays. + const dollars = '$'.repeat(fence) + state.addNode('html', undefined, `${dollars}${value}${dollars}`) + }, + }, +})) + +export const math = [remark, blocks, block, inline].flat() diff --git a/apps/ely-mark/web/src/preserve.js b/apps/ely-mark/web/src/preserve.js new file mode 100644 index 0000000..21d4e11 --- /dev/null +++ b/apps/ely-mark/web/src/preserve.js @@ -0,0 +1,144 @@ +// Blocks Milkdown cannot write back stay raw text (§14.3). +import { editorViewCtx, remarkCtx, schemaCtx, serializerCtx } from '@milkdown/kit/core' +import { Fragment } from '@milkdown/kit/prose/model' +import { Selection } from '@milkdown/kit/prose/state' +import { $nodeSchema } from '@milkdown/kit/utils' +import { common } from './diff.js' + +/** Source shown and written as it is. */ +export const raw = $nodeSchema('ely_raw', () => ({ + group: 'block', + content: 'text*', + marks: '', + code: true, + defining: true, + isolating: true, + parseDOM: [{ tag: 'pre.ely-raw', preserveWhitespace: 'full' }], + toDOM: () => ['pre', { class: 'ely-raw' }, 0], + parseMarkdown: { match: () => false, runner: () => {} }, + toMarkdown: { + match: (node) => node.type.name === 'ely_raw', + runner: (state, node) => state.addNode('html', undefined, node.textContent), + }, +})) + +/** Each block's span, as Milkdown reads it; null if unplaced. */ +function spans(ctx, text) { + const remark = ctx.get(remarkCtx) + const tree = remark.runSync(remark.parse(text), text) + const at = tree.children.map( + (node) => node.position && [node.position.start.offset, node.position.end.offset], + ) + return at.every(Boolean) ? at : null +} + +/** The text in `from`..`to` past its outer whitespace. */ +function inner(text, from, to) { + const piece = text.slice(from, to) + const start = piece.search(/\S/) + if (start < 0) return null + return [from + start, from + piece.trimEnd().length] +} + +/** Raw blocks in place; edits then rewrite only their blocks. */ +export function faithful(ctx, original) { + const view = ctx.get(editorViewCtx) + const parsed = view.state.doc + const at = spans(ctx, original) + // No blocks: Milkdown still holds one empty paragraph. + const blank = at?.length === 0 && parsed.childCount === 1 && parsed.textContent === '' + if (!at || (at.length !== parsed.childCount && !blank)) return null + const schema = ctx.get(schemaCtx) + const serializer = ctx.get(serializerCtx) + const written = (nodes) => serializer(schema.topNodeType.create(null, nodes)).replace(/\n+$/, '') + const rawOf = ([from, to]) => { + const text = original.slice(from, to) + return schema.nodes.ely_raw.create(null, text ? schema.text(text) : null) + } + const nodes = [] + const places = [] + // Text Milkdown drops between blocks, as raw blocks. + const gap = (from, to) => { + const place = inner(original, from, to) + if (!place) return + nodes.push(rawOf(place)) + places.push(place) + } + let last = 0 + if (!blank) + parsed.forEach((node, _, index) => { + gap(last, at[index][0]) + const exact = written([node]) === original.slice(...at[index]) + nodes.push(exact ? node : rawOf(at[index])) + places.push(at[index]) + last = at[index][1] + }) + gap(last, original.length) + if (nodes.length) { + const swap = view.state.tr.replaceWith(0, parsed.content.size, Fragment.from(nodes)) + swap.setSelection(Selection.atStart(swap.doc)) + view.dispatch(swap.setMeta('addToHistory', false)) + } + const first = view.state.doc + const before = [] + first.forEach((node) => before.push(node)) + /** Where each fresh block sits in `text` from `at`. */ + const found = (nodes, text, at, spans) => { + let from = 0 + for (const node of nodes) { + const own = written([node]) + const place = text.indexOf(own, from) + spans.push(place < 0 ? null : [at + place, at + place + own.length]) + if (place >= 0) from = place + own.length + } + } + // The text and each block's span in it. + return (doc) => { + if (doc.eq(first)) return { text: original, spans: places } + const after = [] + doc.forEach((node) => after.push(node)) + if (!places.length) { + const fresh = written(after) + const spans = [] + found(after, fresh, original.length, spans) + return { text: `${original}${fresh}\n`, spans } + } + const pairs = common(before, after, (x, y) => x === y || x.eq(y)) + pairs.push([before.length, after.length]) + let out = '' + let cursor = 0 + let [i, j] = [0, 0] + const spans = [] + for (const [x, y] of pairs) { + if (x > i || y > j) { + const fresh = y > j ? written(after.slice(j, y)) : '' + let start, end, text + if (x > i && fresh) { + ;[start, end, text] = [places[i][0], places[x - 1][1], fresh] + } else if (x > i && i > 0) { + // Removed blocks take the gap before them. + ;[start, end, text] = [places[i - 1][1], places[x - 1][1], ''] + } else if (x > i && x < places.length) { + ;[start, end, text] = [places[0][0], places[x][0], ''] + } else if (x > i) { + ;[start, end, text] = [places[0][0], places[x - 1][1], ''] + } else if (i > 0) { + ;[start, end, text] = [places[i - 1][1], places[i - 1][1], `\n\n${fresh}`] + } else { + ;[start, end, text] = [places[0][0], places[0][0], `${fresh}\n\n`] + } + out += original.slice(cursor, start) + found(after.slice(j, y), text, out.length, spans) + out += text + cursor = end + } + // Unchanged blocks keep their bytes, shifted. + if (x < places.length) { + const shift = out.length - cursor + spans.push([places[x][0] + shift, places[x][1] + shift]) + } + ;[i, j] = [x + 1, y + 1] + } + return { text: out + original.slice(cursor), spans } + } +} diff --git a/apps/ely-mark/web/src/render.js b/apps/ely-mark/web/src/render.js new file mode 100644 index 0000000..edc0eae --- /dev/null +++ b/apps/ely-mark/web/src/render.js @@ -0,0 +1,110 @@ +// The whole document as one page or as text (MARK-11). +import { unified } from 'unified' +import remarkParse from 'remark-parse' +import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' +import remarkRehype from 'remark-rehype' +import rehypeKatex from 'rehype-katex' +import { fromDom } from 'hast-util-from-dom' +import rehypeStringify from 'rehype-stringify' +import { toText } from 'hast-util-to-text' +import { visit } from 'unist-util-visit' +import { VFile } from 'vfile' +import { bundle } from './diagrams.js' + +const markdown = () => unified().use(remarkParse).use(remarkGfm).use(remarkMath) + +/** Mermaid drawn for paper; a broken one stays code. */ +async function draw(tree) { + const blocks = [] + visit(tree, 'code', (node) => { + if (node.lang === 'mermaid') blocks.push(node) + }) + const failed = [] + if (!blocks.length) return failed + const paint = await bundle() + for (const node of blocks) { + try { + const svg = await paint(node.value, 'paper') + Object.assign(node, { type: 'html', value: `
${svg}
` }) + } catch (error) { + // Lines only: Mermaid's message quotes the document. + console.warn(`ely: a diagram did not draw: ${error.name}`) + failed.push(node.position.start.line) + } + } + return failed +} + +/** A `srcset`'s first URL, split as the spec does. */ +function first(srcset) { + const url = srcset.replace(/^[\s,]+/, '').split(/\s/)[0] + return url.replace(/,+$/, '') +} + +/** One `src` per image, swapped for the app's mark. */ +function marked(html) { + const doc = new DOMParser().parseFromString(html, 'text/html') + const token = `ely-image-${crypto.randomUUID()}-` + // Responsive sources go: the page carries one file per image. + for (const source of doc.querySelectorAll('picture > source')) source.remove() + const images = [] + for (const image of doc.querySelectorAll('img')) { + const srcset = image.getAttribute('srcset') + image.removeAttribute('srcset') + image.removeAttribute('sizes') + const src = image.getAttribute('src') || (srcset && first(srcset)) + if (!src) continue + images.push(src) + image.setAttribute('src', token + (images.length - 1)) + } + return { html: doc.body.innerHTML, images, token } +} + +/** KaTeX's failures: line and its message, never logged. */ +function formulas(file) { + return file.messages + .filter((message) => message.source === 'rehype-katex') + .map((message) => ({ + line: message.line, + error: message.cause?.rawMessage ?? message.cause?.name ?? message.reason + })) +} + +/** `body` as HTML, images marked; the failed diagrams and formulas. */ +export async function html(body) { + const tree = markdown().parse(body) + displayed(tree, body) + const failed = await draw(tree) + const page = unified() + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeKatex) + .use(rehypeStringify, { allowDangerousHtml: true }) + const file = new VFile() + const done = page.stringify(await page.run(tree, file), file) + return { ...marked(done), failed, formulas: formulas(file) } +} + +/** `$$…$$` inline displays, as in the editor. */ +function displayed(tree, body) { + visit(tree, 'inlineMath', (node) => { + const at = node.position?.start.offset ?? 0 + if (body.startsWith('$$', at)) { + node.data.hProperties = { className: ['language-math', 'math-display'] } + } + }) +} + +/** `body` as plain text: blocks on lines, math as TeX. */ +export async function text(body) { + const page = unified().use(remarkRehype, { allowDangerousHtml: true }) + const hast = await page.run(markdown().parse(body)) + // Raw HTML alone goes through the DOM; blocks keep lines. + visit(hast, 'raw', (node, index, parent) => { + const parsed = new DOMParser().parseFromString(node.value, 'text/html').body + const shown = fromDom(parsed).children + parent.children.splice(index, 1, ...shown) + return index + shown.length + }) + return toText(hast) +} diff --git a/apps/ely-mark/web/src/source.js b/apps/ely-mark/web/src/source.js new file mode 100644 index 0000000..aed9443 --- /dev/null +++ b/apps/ely-mark/web/src/source.js @@ -0,0 +1,109 @@ +// CodeMirror 6: the Markdown source. +import { basicSetup, EditorView } from 'codemirror' +import { Prec } from '@codemirror/state' +import { indentWithTab, redo, redoDepth, undoDepth } from '@codemirror/commands' +import { keymap } from '@codemirror/view' +import { markdown as language } from '@codemirror/lang-markdown' +import { languages } from '@codemirror/language-data' +import { ensureSyntaxTree } from '@codemirror/language' +import { theme } from './theme.js' +import { search } from '@codemirror/search' +import { textFinder } from './find.js' + +/** Alt text that cannot close its brackets. */ +const escape = (alt) => alt.replace(/[\\[\]]/g, '\\$&') + +export function source(root, markdown, changed, edge) { + const view = new EditorView({ + doc: markdown, + extensions: [ + basicSetup, + language({ codeLanguages: languages }), + EditorView.lineWrapping, + theme, + search(), + // Tab indents; it never leaves the editor. + keymap.of([indentWithTab]), + // Past their history, undo and redo reach the page. + Prec.low( + keymap.of([ + { key: 'Mod-z', run: (view) => undoDepth(view.state) === 0 && edge('undo') }, + { key: 'Mod-Shift-z', run: (view) => redoDepth(view.state) === 0 && edge('redo') }, + { key: 'Mod-y', run: (view) => redoDepth(view.state) === 0 && edge('redo') }, + ]), + ), + EditorView.updateListener.of((update) => { + if (update.docChanged) changed(() => update.state.doc.toString()) + }), + ], + parent: root, + }) + // ATX and setext headings from the parse, with their starts. + const headings = () => { + const found = [] + const tree = ensureSyntaxTree(view.state, view.state.doc.length, 200) + tree?.iterate({ + enter: ({ name, from, to }) => { + const level = /^(?:ATX|Setext)Heading(\d)$/.exec(name)?.[1] + if (!level) return + const head = view.state.doc.sliceString(from, to).split('\n')[0] + const title = head.replace(/^#{1,6}[ \t]*/, '').replace(/[ \t]+#+[ \t]*$/, '') + found.push({ level: Number(level), title: title.trim(), from }) + return false + }, + }) + return found + } + const reveal = (from) => { + view.dispatch({ selection: { anchor: from }, scrollIntoView: true }) + view.focus() + } + return { + // Images at the caret, as Markdown. + insert: (images) => { + const text = images.map(({ src, alt }) => `![${escape(alt)}](${src})`).join('\n') + view.dispatch(view.state.replaceSelection(text)) + view.focus() + return true + }, + find: textFinder(view), + outline: () => headings().map(({ level, title }) => ({ level, title })), + heading: (key) => { + const found = headings()[key] + if (found) reveal(found.from) + return Boolean(found) + }, + line: (n, query) => { + if (n < 1 || n > view.state.doc.lines) return false + const line = view.state.doc.line(n) + const column = line.text.toLowerCase().indexOf(query.toLowerCase()) + reveal(line.from + Math.max(0, column)) + return true + }, + close: () => view.destroy(), + center: () => { + const { head } = view.state.selection.main + view.dispatch({ effects: EditorView.scrollIntoView(head, { y: 'center' }) }) + }, + where: () => { + const { head } = view.state.selection.main + const line = view.state.doc.lineAt(head) + return { line: line.number, column: head - line.from } + }, + at: (n, column) => { + if (n < 1 || n > view.state.doc.lines) return false + const line = view.state.doc.line(n) + reveal(line.from + Math.min(column, line.length)) + return true + }, + text: () => view.state.doc.toString(), + // The text once every undone step is redone. + redone: () => { + let state = view.state + if (!redoDepth(state)) return null + while (redoDepth(state)) redo({ state, dispatch: (tr) => (state = tr.state) }) + return state.doc.toString() + }, + focus: () => view.focus(), + } +} diff --git a/apps/ely-mark/web/src/style.js b/apps/ely-mark/web/src/style.js new file mode 100644 index 0000000..914a85b --- /dev/null +++ b/apps/ely-mark/web/src/style.js @@ -0,0 +1,41 @@ +// The document's own markers, so blocks write back unchanged. +import { unified } from 'unified' +import remarkParse from 'remark-parse' +import { visit } from 'unist-util-visit' + +/** remark-stringify options most of `text` already uses. */ +export function style(text) { + const votes = {} + const vote = (key, value) => { + votes[key] ??= new Map() + votes[key].set(value, (votes[key].get(value) ?? 0) + 1) + } + visit(unified().use(remarkParse).parse(text), (node) => { + const at = node.position?.start.offset + if (at === undefined) return + const head = text.slice(at, at + 12) + switch (node.type) { + case 'listItem': { + const ordered = /^\d+([.)])/.exec(head) + if (ordered) vote('bulletOrdered', ordered[1]) + else if (/^[*+-]/.test(head)) vote('bullet', head[0]) + break + } + case 'emphasis': + case 'strong': + vote(node.type, head[0]) + break + case 'code': + if (/^[`~]/.test(head)) vote('fence', head[0]) + break + case 'thematicBreak': + vote('rule', head[0]) + break + case 'heading': + if (node.depth <= 2) vote('setext', head[0] !== '#') + break + } + }) + const most = (key) => [...votes[key].entries()].sort((a, b) => b[1] - a[1])[0][0] + return Object.fromEntries(Object.keys(votes).map((key) => [key, most(key)])) +} diff --git a/apps/ely-mark/web/src/theme.js b/apps/ely-mark/web/src/theme.js new file mode 100644 index 0000000..d9305f2 --- /dev/null +++ b/apps/ely-mark/web/src/theme.js @@ -0,0 +1,42 @@ +// CodeMirror colors from the page's Ely tokens. +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' +import { EditorView } from '@codemirror/view' +import { tags } from '@lezer/highlight' + +const token = (name) => `var(--ely-${name})` + +const colors = HighlightStyle.define([ + { tag: [tags.keyword, tags.operatorKeyword, tags.modifier], color: token('keyword') }, + { tag: [tags.string, tags.regexp, tags.special(tags.string)], color: token('string') }, + { tag: [tags.number, tags.bool, tags.null, tags.atom], color: token('number') }, + { tag: [tags.comment, tags.meta], color: token('comment') }, + { + tag: [tags.function(tags.variableName), tags.function(tags.propertyName)], + color: token('function'), + }, + { tag: [tags.typeName, tags.className, tags.namespace], color: token('type') }, + { tag: [tags.tagName, tags.heading], color: token('tag'), fontWeight: 'bold' }, + { tag: [tags.attributeName, tags.propertyName], color: token('attribute') }, + { + tag: [tags.punctuation, tags.bracket, tags.processingInstruction], + color: token('punctuation'), + }, + { tag: [tags.link, tags.url], color: token('link') }, + { tag: tags.emphasis, fontStyle: 'italic' }, + { tag: tags.strong, fontWeight: 'bold' }, + { tag: tags.strikethrough, textDecoration: 'line-through' }, +]) + +const frame = EditorView.theme({ + '&': { color: token('fg'), backgroundColor: 'transparent' }, + '.cm-content': { caretColor: token('fg'), fontFamily: 'var(--ely-mono), monospace' }, + '.cm-cursor': { borderLeftColor: token('fg') }, + '.cm-gutters': { backgroundColor: 'transparent', color: token('fg-subtle'), border: 'none' }, + '.cm-activeLine, .cm-activeLineGutter': { backgroundColor: token('hover') }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground': { + backgroundColor: token('selection'), + }, + '&.cm-focused': { outline: 'none' }, +}) + +export const theme = [frame, syntaxHighlighting(colors)] diff --git a/apps/ely-mark/web/src/visual.js b/apps/ely-mark/web/src/visual.js new file mode 100644 index 0000000..ec08f40 --- /dev/null +++ b/apps/ely-mark/web/src/visual.js @@ -0,0 +1,313 @@ +// Milkdown: visual editing, or read-only preview. +import { + Editor, + defaultValueCtx, + remarkStringifyOptionsCtx, + editorViewCtx, + editorViewOptionsCtx, + parserCtx, + remarkCtx, + rootCtx, +} from '@milkdown/kit/core' +import { commonmark } from '@milkdown/kit/preset/commonmark' +import { gfm } from '@milkdown/kit/preset/gfm' +import { history } from '@milkdown/kit/plugin/history' +import { codeBlockComponent } from '@milkdown/kit/component/code-block' +import { Plugin, TextSelection } from '@milkdown/kit/prose/state' +import { Decoration, DecorationSet } from '@milkdown/kit/prose/view' +import { keymap } from '@milkdown/kit/prose/keymap' +import { liftListItem, sinkListItem } from '@milkdown/kit/prose/schema-list' +import { redo, redoDepth, undoDepth } from '@milkdown/kit/prose/history' +import { $prose, callCommand } from '@milkdown/kit/utils' +import { insertTableCommand } from '@milkdown/kit/preset/gfm' +import { + addColumnAfter, + addColumnBefore, + addRowAfter, + addRowBefore, + deleteColumn, + deleteRow, +} from '@milkdown/kit/prose/tables' +import { visit } from 'unist-util-visit' +import { image } from './image.js' +import { math } from './math.js' +import { code } from './code.js' +import { diagrams } from './diagrams.js' +import { faithful, raw } from './preserve.js' +import { docFinder } from './find.js' +import { search } from 'prosemirror-search' +import { style } from './style.js' + +/** An editor over `markdown`; null when blocks cannot map. */ +export async function visual(root, markdown, editable, texts, changed, edge) { + const owner = diagrams() + let write = () => ({ text: markdown, spans: [] }) + // Setting up raw blocks is no edit. + let ready = false + const watch = $prose( + () => + new Plugin({ + view: () => ({ + update: (view, before) => { + if (ready && !view.state.doc.eq(before.doc)) changed(() => write(view.state.doc).text) + }, + }), + }), + ) + // Focus mode dims all blocks but the caret's. + const current = $prose( + () => + new Plugin({ + props: { + decorations: (state) => { + const { $from } = state.selection + if ($from.depth < 1) return null + const start = $from.before(1) + const block = Decoration.node(start, start + $from.node(1).nodeSize, { + class: 'ely-current', + }) + return DecorationSet.create(state.doc, [block]) + }, + }, + }), + ) + // Past their history, undo and redo reach the page. + const ends = $prose(() => + keymap({ + 'Mod-z': (state) => undoDepth(state) === 0 && edge('undo'), + 'Mod-Shift-z': (state) => redoDepth(state) === 0 && edge('redo'), + 'Mod-y': (state) => redoDepth(state) === 0 && edge('redo'), + // Tab moves list items; it never leaves the editor. + Tab: (state, dispatch) => sinkListItem(state.schema.nodes.list_item)(state, dispatch) || true, + 'Shift-Tab': (state, dispatch) => + liftListItem(state.schema.nodes.list_item)(state, dispatch) || true, + }), + ) + const made = Editor.make() + .config((ctx) => { + ctx.set(rootCtx, root) + ctx.set(defaultValueCtx, markdown) + ctx.update(remarkStringifyOptionsCtx, (prev) => ({ ...prev, ...style(markdown) })) + ctx.update(editorViewOptionsCtx, (prev) => ({ ...prev, editable: () => editable })) + }) + .use(commonmark) + .use(gfm) + .use(history) + .use(image) + .use(math) + .use(raw) + .config(code(texts, editable, owner)) + .use(codeBlockComponent) + .use(watch) + .use(current) + .use(ends) + .use($prose(() => search())) + .create() + const editor = await made.catch((error) => { + owner.release() + throw error + }) + const view = editor.action((ctx) => ctx.get(editorViewCtx)) + const close = () => { + editor.destroy() + owner.release() + } + const keep = editor.action((ctx) => faithful(ctx, markdown)) + if (!keep) { + close() + return null + } + write = keep + ready = true + // The text once every undone step is redone. + const redone = () => { + let state = view.state + if (!redoDepth(state)) return null + while (redoDepth(state)) redo(state, (tr) => (state = state.apply(tr))) + return write(state.doc).text + } + // Headings, raw ones too, by the title their source gives. + const [remark, parse] = editor.action((ctx) => [ctx.get(remarkCtx), ctx.get(parserCtx)]) + /** An mdast node's words. */ + const words = (node) => node.value ?? (node.children ?? []).map(words).join('') + // Headings; inside raw blocks, as remark reads their source. + const headings = () => { + const found = [] + view.state.doc.descendants((node, pos) => { + if (node.type.name === 'heading') + found.push({ level: node.attrs.level, title: node.textContent, pos }) + if (node.type.name !== 'ely_raw') return true + visit(remark.parse(node.textContent), 'heading', (heading) => { + const start = (heading.children[0] ?? heading).position.start.offset + found.push({ level: heading.depth, title: words(heading), pos, at: pos + 1 + start }) + }) + return false + }) + return found + } + // `exact`: the caret at `pos`; else in that block. + const reveal = (pos, exact) => { + if (editable) { + const doc = view.state.doc + const place = exact + ? TextSelection.create(doc, pos) + : TextSelection.near(doc.resolve(pos + 1)) + view.dispatch(view.state.tr.setSelection(place).scrollIntoView()) + return view.focus() + } + const { node } = view.domAtPos(pos + 1) + ;(node.nodeType === 1 ? node : node.parentElement).scrollIntoView({ block: 'center' }) + } + /** `node`'s text, and where a character of it sits. */ + const reading = (node, start) => { + const chunks = [] + // Text and line breaks, as `textContent` reads them. + node.descendants((child, at) => { + const words = child.isText + ? child.text + : child.isLeaf + ? child.type.spec.leafText?.(child) + : null + if (words) chunks.push({ text: words.toLowerCase(), at: start + 1 + at }) + }) + const at = (offset) => { + for (const [index, chunk] of chunks.entries()) { + const last = index === chunks.length - 1 + if (offset < chunk.text.length || (last && offset === chunk.text.length)) + return chunk.at + offset + offset -= chunk.text.length + } + return null + } + return { joined: chunks.map((chunk) => chunk.text).join(''), at } + } + // Non-overlapping hits, the same rule as `split`. + const hits = (text, needle) => text.split(needle).length - 1 + /** The block holding file line `n`: its index and start. */ + const blockOf = (n) => { + const { text, spans } = write(view.state.doc) + let offset = 0 + for (let k = 1; k < n && offset >= 0; k++) offset = text.indexOf('\n', offset) + 1 + if (n < 1 || offset < 0) return null + const index = spans.findIndex((span) => span && span[1] >= offset) + if (index < 0) return null + let pos = 0 + for (let k = 0; k < index; k++) pos += view.state.doc.child(k).nodeSize + return { text, spans, offset, index, pos } + } + /** Line `n`: the query's place in its block. */ + const line = (n, query) => { + const block = blockOf(n) + if (!block) return false + const { text, spans, offset, index, pos } = block + const needle = query.toLowerCase() + const node = view.state.doc.child(index) + // Text before and through the line, as shown. + const shown = (end) => { + const source = text.slice(spans[index][0], Math.max(spans[index][0], end)) + return (node.type.name === 'ely_raw' ? source : parse(source).textContent).toLowerCase() + } + const ends = text.indexOf('\n', offset) + const before = shown(offset) + const through = shown(ends < 0 ? text.length : ends) + const { joined, at } = reading(node, pos) + const k = needle ? hits(before, needle) : 0 + let found = -1 + if (needle && hits(through, needle) > k) { + let from = 0 + for (let seen = 0; seen <= k; seen++) { + found = joined.indexOf(needle, from) + if (found < 0) break + from = found + needle.length + } + } + // A hit hidden, as in a link: the line's start. + const place = found >= 0 ? at(found) : at(before.length) + reveal(place ?? pos, place !== null) + return true + } + /** The caret: its block's file line, and how far in. */ + const where = () => { + const { text, spans } = write(view.state.doc) + const { from } = view.state.selection + let [start, pos] = [0, 0] + view.state.doc.forEach((node, offset, index) => { + if (offset <= from && spans[index]) [start, pos] = [spans[index][0], offset] + }) + return { line: text.slice(0, start).split('\n').length, column: from - pos } + } + /** The caret back where `where` read it. */ + const at = (n, column) => { + const block = blockOf(n) + if (!block) return false + const size = view.state.doc.child(block.index).nodeSize + reveal(block.pos + Math.min(Math.max(column, 1), size - 1) - 1, false) + return true + } + // Rows and columns; false when the caret is outside tables. + const tables = { + 'row-above': addRowBefore, + 'row-below': addRowAfter, + 'column-left': addColumnBefore, + 'column-right': addColumnAfter, + 'delete-row': deleteRow, + 'delete-column': deleteColumn, + } + const tablesIn = (doc) => { + const found = [] + doc.descendants((node, pos) => { + if (node.type.name === 'table') found.push({ node, pos }) + }) + return found + } + // Milkdown may leave the caret before the new table. + const newTable = () => { + const old = new Set(tablesIn(view.state.doc).map(({ node }) => node)) + editor.action(callCommand(insertTableCommand.key, { row: 3, col: 3 })) + const made = tablesIn(view.state.doc).find(({ node }) => !old.has(node)) + if (!made) throw new Error('the inserted table is missing') + const cell = TextSelection.near(view.state.doc.resolve(made.pos + 1)) + view.dispatch(view.state.tr.setSelection(cell).scrollIntoView()) + return true + } + const table = (op) => { + if (!editable) return false + const done = op === 'insert' ? newTable() : tables[op](view.state, view.dispatch) + view.focus() + return done + } + /** Images at the caret: `{ src, alt }` each. */ + const insert = (images) => { + if (!editable) return false + const image = view.state.schema.nodes.image + let tr = view.state.tr + for (const { src, alt } of images) + tr = tr.replaceSelectionWith(image.create({ src, alt, title: '' }), false) + view.dispatch(tr.scrollIntoView()) + view.focus() + return true + } + return { + insert, + table, + find: docFinder(view), + close, + text: () => write(view.state.doc).text, + redone, + focus: () => editable && view.focus(), + outline: () => headings().map(({ level, title }) => ({ level, title })), + heading: (key) => { + const found = headings()[key] + if (found) reveal(found.at ?? found.pos, found.at !== undefined) + return Boolean(found) + }, + line, + where, + at, + // The caret's line to the middle of the view. + center: () => { + const { top } = view.coordsAtPos(view.state.selection.head) + window.scrollBy(0, top - window.innerHeight / 2) + }, + } +} diff --git a/apps/ely-pdf/Cargo.toml b/apps/ely-pdf/Cargo.toml new file mode 100644 index 0000000..b729c56 --- /dev/null +++ b/apps/ely-pdf/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "ely-pdf" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +ely-files.workspace = true +ely-gpui-component.workspace = true +ely-office.workspace = true +ely-pdf-engine.workspace = true +ely-platform.workspace = true +ely-ui.workspace = true +gpui.workspace = true +image.workspace = true +log.workspace = true + +[dev-dependencies] +ely-ui = { workspace = true, features = ["test-support"] } +ely-pdf-engine = { workspace = true, features = ["test-support"] } +ely-gpui-component = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +tempfile.workspace = true + +[lints] +workspace = true diff --git a/apps/ely-pdf/src/annotate.rs b/apps/ely-pdf/src/annotate.rs new file mode 100644 index 0000000..2fec6cb --- /dev/null +++ b/apps/ely-pdf/src/annotate.rs @@ -0,0 +1,424 @@ +//! Annotation and form tools on the page (PDF-05, PDF-06). + +use std::collections::HashMap; + +use ely_gpui_component::theme::ActiveTheme as _; +use ely_pdf_engine::{FieldKind, Marks, NewAnnot, Shape}; +use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, canvas, div, point, px}; + +use crate::content::{Aim, Tool}; +use crate::layout::Placed; +use crate::pages::{rect_on, to_view}; +use crate::view::PdfView; + +/// How far a click may move, in window pixels. +const CLICK: f32 = 3.0; +/// Pages read for their annotations at once. +const BATCH: usize = 20; + +/// What the note panel works on. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Pick { + Annot { page: usize, id: u64 }, + Field { page: usize, id: u64 }, + NewNote { page: usize, at: (f32, f32) }, + NewStamp { page: usize, at: (f32, f32) }, + NewField { page: usize, rect: [f32; 4], kind: FieldKind }, + Link { page: usize, index: usize }, + NewLink { page: usize, rect: [f32; 4] }, +} + +/// A shape being drawn, in page points. +#[derive(Clone, Debug, PartialEq)] +pub struct Sketch { + pub page: usize, + pub points: Vec<(f32, f32)>, +} + +impl Sketch { + /// The box from the first point to the last. + pub fn rect(&self) -> [f32; 4] { + let (a, b) = (self.points[0], self.points[self.points.len() - 1]); + [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)] + } +} + +/// Annotations and fields by page. +pub type Notes = HashMap; + +fn inside(rect: &[f32; 4], (x, y): (f32, f32)) -> bool { + x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3] +} + +/// A box around `at`, kept on the page. +pub fn around((x, y): (f32, f32), (w, h): (f32, f32), size: (f32, f32)) -> [f32; 4] { + let left = (x - w / 2.0).clamp(0.0, (size.0 - w).max(0.0)); + let top = (y - h / 2.0).clamp(0.0, (size.1 - h).max(0.0)); + [left, top, left + w, top + h] +} + +/// Picked text as one box per line, by page. +pub fn line_boxes(chars: &[ely_pdf_engine::Char]) -> Vec<[f32; 4]> { + let mut boxes: Vec<(i32, [f32; 4])> = Vec::new(); + for c in chars { + match boxes.last_mut() { + Some((line, b)) if *line == c.line => { + *b = [b[0].min(c.rect[0]), b[1].min(c.rect[1]), b[2].max(c.rect[2]), b[3].max(c.rect[3])]; + } + _ => boxes.push((c.line, c.rect)), + } + } + boxes.into_iter().map(|(_, b)| b).collect() +} + +impl PdfView { + /// Reads the annotations of up to a batch of `pages`. + pub fn ask_notes(&mut self, pages: impl IntoIterator, cx: &mut Context) { + if self.reading_notes { + return; + } + let wanted: Vec = pages.into_iter().filter(|page| !self.notes.contains_key(page)).take(BATCH).collect(); + if wanted.is_empty() { + return; + } + self.reading_notes = true; + let (doc, version) = (self.doc, self.version); + self.off( + move |worker| { + (wanted.into_iter()) + .map(|page| Ok((page, worker.marks(doc, page)?, worker.links(doc, page)?))) + .collect::>>() + }, + move |view, read, cx| { + view.reading_notes = false; + if view.version != version { + return cx.notify(); + } + match read { + Ok(read) => { + for (page, marks, links) in read { + view.notes.insert(page, marks); + view.links.insert(page, links); + } + } + Err(error) => view.fail("reading annotations", &error, cx), + } + cx.notify(); + }, + cx, + ); + } + + /// Reads every field name in the form, once an edit. + pub fn ask_names(&mut self, cx: &mut Context) { + if self.form_names.is_some() || self.reading_names { + return; + } + self.reading_names = true; + let (doc, version) = (self.doc, self.version); + self.off( + move |worker| worker.field_names(doc), + move |view, read, cx| { + view.reading_names = false; + if view.version != version { + return cx.notify(); + } + match read { + Ok(names) => view.form_names = Some(names), + Err(error) => view.fail("reading field names", &error, cx), + } + cx.notify(); + }, + cx, + ); + } + + /// Ends a text pick: marks it, or clicks. + pub fn finish_select(&mut self, at: Point, cx: &mut Context) { + self.select_to(at, cx); + let Some(selection) = self.selection else { + return; + }; + // A hand that shakes a little still clicks. + let moved = self.pressed.take().map(|from| f32::from((at.x - from.x).abs() + (at.y - from.y).abs())); + let still = moved.is_some_and(|moved| moved < CLICK); + match self.tool { + Tool::Read if still => { + self.selection = None; + self.click(selection.anchor, cx) + } + Tool::Highlight | Tool::Underline | Tool::StrikeOut | Tool::RedactText if !still => { + self.marking = Some(selection); + self.mark_pending(cx) + } + _ => {} + } + } + + /// A click fills a field or picks an annotation. + fn click(&mut self, (page, at): (usize, (f32, f32)), cx: &mut Context) { + let Some(marks) = self.notes.get(&page) else { + return; + }; + let field = marks.fields.iter().rev().find(|f| inside(&f.rect, at) && !f.readonly).cloned(); + let annot = marks.annots.iter().rev().find(|a| inside(&a.rect, at)).map(|a| a.id); + self.aim = match (field, annot) { + (Some(field), _) => match field.kind { + FieldKind::Checkbox | FieldKind::Radio => { + let value = if field.checked && field.kind == FieldKind::Checkbox { "Off".to_string() } else { field.on }; + if !(field.checked && field.kind == FieldKind::Radio) { + self.settled_edit("filling a field", cx, move |worker, doc| worker.fill(doc, page, field.id, &value)); + } + None + } + FieldKind::Text | FieldKind::Choice | FieldKind::List => Some(Aim::Note(Pick::Field { page, id: field.id })), + FieldKind::Signature | FieldKind::Button => None, + }, + (None, Some(id)) => Some(Aim::Note(Pick::Annot { page, id })), + (None, None) => { + let link = self.links.get(&page).and_then(|links| links.iter().rev().find(|l| inside(&l.rect, at))).cloned(); + if let Some(link) = link { + self.follow(&link, cx); + } + None + } + }; + cx.notify(); + } + + /// Queues `op` unless an edit is pending. + pub fn settled_edit( + &mut self, + what: &'static str, + cx: &mut Context, + op: impl FnOnce(&mut ely_pdf_engine::Worker, ely_pdf_engine::Doc) -> std::io::Result + Send + 'static, + ) { + // A queued edit may move what the press meant. + if !self.settled() { + let text = crate::viewer::Viewer::text(cx, "pdf.object.changed", &[]); + cx.emit(crate::view::Failed(text.to_string())); + return cx.notify(); + } + self.edit(what, op, cx); + } + + /// Marks the pending pick once its pages' text is read. + pub fn mark_pending(&mut self, cx: &mut Context) { + let Some(selection) = self.marking else { + return; + }; + let (first, last) = (selection.anchor.0.min(selection.head.0), selection.anchor.0.max(selection.head.0)); + let missing: Vec = (first..=last).filter(|page| !self.text.contains_key(page)).collect(); + if !missing.is_empty() { + for page in missing { + self.page_text(page, cx); + } + return; + } + self.marking = None; + self.mark_selection(selection, cx); + } + + /// The picked text becomes a highlight, underline or strike. + fn mark_selection(&mut self, selection: crate::select::Selection, cx: &mut Context) { + let Some(span) = selection.span(|page| self.text.get(&page).map(|chars| chars.as_slice())) else { + return; + }; + let mut annots = Vec::new(); + for page in span.from.0..=span.to.0 { + let Some(chars) = self.text.get(&page) else { + return; + }; + let Some(range) = span.on(page, chars.len()).filter(|range| !range.is_empty()) else { + continue; + }; + let boxes = line_boxes(&chars[range]); + if self.tool == Tool::RedactText { + self.mark_regions(page, boxes, None, cx); + continue; + } + let shape = match self.tool { + Tool::Highlight => Shape::Highlight(boxes), + Tool::Underline => Shape::Underline(boxes), + _ => Shape::StrikeOut(boxes), + }; + annots.push(NewAnnot { page, shape, rgb: None, contents: String::new(), author: String::new() }); + } + self.selection = None; + self.annotate("marking text", annots, cx); + } + + /// Adds `annots` signed by the account's name. + pub fn annotate(&mut self, what: &'static str, mut annots: Vec, cx: &mut Context) { + if annots.is_empty() { + return; + } + self.settled_edit(what, cx, move |worker, doc| { + let name = ely_platform::user::name()?; + annots.iter_mut().for_each(|annot| annot.author = name.clone()); + worker.annotate(doc, &annots) + }); + } + + /// A press with a drawing or form tool. + pub fn sketch_from(&mut self, at: Point, cx: &mut Context) { + let Some((page, point)) = self.page_at(at) else { + return; + }; + self.held = self.aim; + self.grip(page); + let hit = self.notes.get(&page).and_then(|marks| marks.fields.iter().rev().find(|f| inside(&f.rect, point))); + (self.aim, self.sketch) = match (self.tool, hit) { + (Tool::Note, _) => (Some(Aim::Note(Pick::NewNote { page, at: point })), None), + (Tool::Stamp, _) => (Some(Aim::Note(Pick::NewStamp { page, at: point })), None), + (Tool::Field(_), Some(field)) => (Some(Aim::Note(Pick::Field { page, id: field.id })), None), + (Tool::Sign, Some(field)) if field.kind == FieldKind::Signature => { + let object = u32::try_from(field.id).ok(); + self.sign_at = Some(crate::sign::SignAt { page, object, rect: field.rect, version: self.version }); + (None, None) + } + (Tool::Link, _) if let Some(index) = self.links.get(&page).and_then(|l| l.iter().rposition(|l| inside(&l.rect, point))) => { + (Some(Aim::Note(Pick::Link { page, index })), None) + } + _ => (None, Some(Sketch { page, points: vec![point] })), + }; + cx.notify(); + } + + pub fn sketch_to(&mut self, at: Point, cx: &mut Context) { + let Some((page, point)) = self.held_at(at) else { + return; + }; + let ink = self.tool == Tool::Ink; + if let Some(sketch) = self.sketch.as_mut().filter(|sketch| sketch.page == page) { + match ink || sketch.points.len() < 2 { + true => sketch.points.push(point), + false => *sketch.points.last_mut().expect("two points") = point, + } + cx.notify(); + } + } + + /// Ends a drawing: a shape lands, a field asks. + pub fn sketch_end(&mut self, cx: &mut Context) { + let Some(sketch) = self.sketch.take() else { + return; + }; + let (page, rect) = (sketch.page, sketch.rect()); + let small = rect[2] - rect[0] < 4.0 || rect[3] - rect[1] < 4.0; + let first = sketch.points[0]; + let shape = match self.tool { + Tool::Square if !small => Shape::Square(rect), + Tool::Circle if !small => Shape::Circle(rect), + Tool::Line if (rect[2] - rect[0]).max(rect[3] - rect[1]) >= 4.0 => { + let last = sketch.points[sketch.points.len() - 1]; + Shape::Line([first.0, first.1, last.0, last.1]) + } + Tool::Ink if sketch.points.len() > 1 => Shape::Ink(vec![sketch.points]), + Tool::RedactArea => { + let click = small.then_some(first); + return self.mark_regions(page, if small { Vec::new() } else { vec![rect] }, click, cx); + } + Tool::PlaceSignature => return self.place_stamp(page, rect, small, cx), + Tool::Sign => { + let rect = if small { [first.0, first.1, first.0 + 200.0, first.1 + 60.0] } else { rect }; + self.sign_at = Some(crate::sign::SignAt { page, object: None, rect, version: self.version }); + return cx.notify(); + } + Tool::Link => { + let rect = if small { [first.0, first.1, first.0 + 120.0, first.1 + 20.0] } else { rect }; + self.aim = Some(Aim::Note(Pick::NewLink { page, rect })); + return cx.notify(); + } + Tool::Field(kind) => { + let rect = match (small, kind) { + (false, _) => rect, + (true, FieldKind::Checkbox | FieldKind::Radio) => [first.0, first.1, first.0 + 14.0, first.1 + 14.0], + (true, _) => [first.0, first.1, first.0 + 150.0, first.1 + 20.0], + }; + self.aim = Some(Aim::Note(Pick::NewField { page, rect, kind })); + return cx.notify(); + } + _ => return cx.notify(), + }; + let annot = NewAnnot { page, shape, rgb: None, contents: String::new(), author: String::new() }; + self.annotate("drawing", vec![annot], cx); + } + + /// The aimed annotation or field, and a shape being drawn. + pub fn note_marks(&self, place: &Placed, scale: f32, cx: &App) -> Vec { + let color = cx.theme().colors.focus; + let size = self.sizes[place.page]; + let outline = |rect: [f32; 4]| { + let (x, y, w, h) = rect_on(rect, size, self.rotate, scale); + div().absolute().left(px(x)).top(px(y)).w(px(w)).h(px(h)).border_1().border_color(color) + }; + let mut marks = Vec::new(); + let aimed = match self.aim { + Some(Aim::Note(Pick::Annot { page, id })) if page == place.page => { + self.notes.get(&page).and_then(|m| m.annots.iter().find(|a| a.id == id)).map(|a| a.rect) + } + Some(Aim::Note(Pick::Field { page, id })) if page == place.page => { + self.notes.get(&page).and_then(|m| m.fields.iter().find(|f| f.id == id)).map(|f| f.rect) + } + Some(Aim::Note(Pick::NewField { page, rect, .. } | Pick::NewLink { page, rect })) if page == place.page => Some(rect), + Some(Aim::Note(Pick::Link { page, index })) if page == place.page => { + self.links.get(&page).and_then(|links| links.get(index)).map(|l| l.rect) + } + _ => None, + }; + marks.extend(aimed.map(|rect| outline(rect).into_any_element())); + let Some(sketch) = self.sketch.as_ref().filter(|sketch| sketch.page == place.page) else { + return marks; + }; + match self.tool { + Tool::Square | Tool::Field(_) | Tool::Link | Tool::RedactArea | Tool::Sign | Tool::PlaceSignature => marks.push(outline(sketch.rect()).into_any_element()), + Tool::Circle => marks.push(outline(sketch.rect()).rounded_full().into_any_element()), + _ => { + let points: Vec> = (sketch.points.iter()) + .map(|p| to_view(*p, size, self.rotate)) + .map(|(x, y)| point(px(x * scale), px(y * scale))) + .collect(); + marks.push(stroke(points, color).into_any_element()); + } + } + marks + } +} + +/// A line through `points`, over the page. +fn stroke(points: Vec>, color: gpui::Hsla) -> impl IntoElement { + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let mut path = gpui::PathBuilder::stroke(px(2.0)); + for (at, p) in points.iter().enumerate() { + let p = bounds.origin + *p; + if at == 0 { path.move_to(p) } else { path.line_to(p) } + } + match path.build() { + Ok(path) => window.paint_path(path, color), + Err(error) => log::error!("pdf: a stroke would not build: {error}"), + } + }, + ) + .absolute() + .top_0() + .left_0() + .size_full() +} + +#[cfg(test)] +mod tests { + use super::*; + use ely_pdf_engine::Char; + + #[test] + fn picked_text_marks_one_box_a_line_and_boxes_stay_on_the_page() { + let c = |line, x: f32, y: f32| Char { line, c: 'a', rect: [x, y, x + 10.0, y + 12.0] }; + let chars = [c(0, 0.0, 0.0), c(0, 10.0, 1.0), c(1, 0.0, 20.0)]; + assert_eq!(line_boxes(&chars), [[0.0, 0.0, 20.0, 13.0], [0.0, 20.0, 10.0, 32.0]]); + assert_eq!(around((5.0, 5.0), (20.0, 10.0), (100.0, 100.0)), [0.0, 0.0, 20.0, 10.0]); + assert_eq!(around((50.0, 50.0), (20.0, 10.0), (100.0, 100.0)), [40.0, 45.0, 60.0, 55.0]); + } +} diff --git a/apps/ely-pdf/src/ask.rs b/apps/ely-pdf/src/ask.rs new file mode 100644 index 0000000..52a23bc --- /dev/null +++ b/apps/ely-pdf/src/ask.rs @@ -0,0 +1,286 @@ +//! Crop, extract and split (PDF-02). + +use std::{ + io::{self, Write as _}, + path::{Path, PathBuf}, +}; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::NumberInput, + overlays::Dialog as Card, + primitives::Severity, +}; +use ely_office::{Closing as _, Saving as _, Tabs as _, in_window}; +use gpui::{AnyElement, App, Context, Entity, IntoElement, ParentElement as _, TaskExt as _, Window}; + +use crate::viewer::Viewer; + +/// Points in a millimetre. +const MM: f32 = 72.0 / 25.4; + +/// The viewer's own dialogs. +pub enum Ask { + /// Pages to crop, margins in mm: top, right, bottom, left. + Crop { tab: u64, pages: Vec, margins: [f32; 4] }, + /// Pages in each file. + Split { tab: u64, every: usize }, +} + +/// Files of `every` pages from `count`: (first, last) each. +pub fn parts(count: usize, every: usize) -> Vec<(usize, usize)> { + (0..count).step_by(every.max(1)).map(|first| (first, (first + every).min(count) - 1)).collect() +} + +/// Margins as seen through a view turned `rotate` degrees, unturned. +pub fn unturn(seen: [f32; 4], rotate: i32) -> [f32; 4] { + let turns = (rotate.rem_euclid(360) / 90) as usize; + std::array::from_fn(|side| seen[(side + turns) % 4]) +} + +/// A file name's stem. +fn stem(name: &str) -> &str { + name.rsplit_once('.').map_or(name, |(stem, _)| stem) +} + +impl Viewer { + pub fn ask_crop(&mut self, _: &mut Window, cx: &mut Context) { + let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else { + return self.no_document(cx); + }; + let pages = view.read(cx).chosen_pages(); + self.ask = Some(Ask::Crop { tab, pages, margins: [0.0; 4] }); + cx.notify(); + } + + pub fn ask_split(&mut self, _: &mut Window, cx: &mut Context) { + let Some(tab) = self.core.active else { + return self.no_document(cx); + }; + self.ask = Some(Ask::Split { tab, every: 1 }); + cx.notify(); + } + + pub(crate) fn crop(&mut self, cx: &mut Context) { + let Some(Ask::Crop { tab, pages, margins }) = self.ask.take() else { + return; + }; + let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else { + return; + }; + let turned = unturn(margins, view.read(cx).rotate); + let points = turned.map(|side| side * MM); + view.update(cx, |view, cx| { + view.edit("cropping pages", move |worker, doc| worker.crop_pages(doc, &pages, points), cx) + }); + cx.notify(); + } + + /// Asks where, then writes the chosen pages there. + pub fn extract(&mut self, cx: &mut Context) { + let (Some(tab), Some(view)) = (self.active_tab(), self.active_view()) else { + return self.no_document(cx); + }; + let (id, pages) = (tab.id, view.read(cx).chosen_pages()); + let Some(dir) = ely_office::folder(tab) else { + return self.panel_failed("no documents folder", cx); + }; + let name = format!("{}-{}.pdf", stem(&tab.name), pages[0] + 1); + let chosen = cx.prompt_for_new_path(&dir, Some(&name)); + cx.spawn(async move |this, cx| { + let chosen = match chosen.await { + Ok(Ok(path)) => Ok(path), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + in_window(&this, cx, |viewer, _, cx| match chosen { + Ok(Some(path)) => viewer.write_pages(id, pages, path, cx), + Ok(None) => log::info!("pdf: the extract panel was cancelled"), + Err(error) => viewer.panel_failed(&error, cx), + }) + }) + .detach_and_log_err(cx); + } + + /// Writes `pages` to `target` once tab `id` settles. + pub(crate) fn write_pages(&mut self, id: u64, pages: Vec, target: PathBuf, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |viewer, _, cx| viewer.write_pages(id, pages, target, cx)); + } + let Some(tab) = self.tab(id) else { + return; + }; + let (name, opened) = (ely_office::files::file_name(&target), target.clone()); + match self.holder(&target) { + Ok(None) => {} + Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &name)]), Severity::Danger, cx), + Err(error) => return self.failed_write(&name, &error, cx), + } + let (work, open, revision) = (tab.work.join("extract.pdf"), tab.session.path().map(PathBuf::from), tab.session.revision()); + log::info!("pdf: extracts {} pages", pages.len()); + self.read_in( + id, + move |worker, doc| { + worker.get()?.extract(*doc, &pages, &work)?; + let bytes = std::fs::read(&work)?; + std::fs::remove_file(&work)?; + ely_files::export(&target, open.as_deref(), &bytes, revision).map(|_| ()) + }, + move |viewer, done, _, cx| match done { + Ok(()) => viewer.toast_output(Self::text(cx, "pdf.extract.done", &[("name", &name)]), opened.clone(), cx), + Err(error) => viewer.failed_write(&name, &error, cx), + }, + cx, + ); + } + + fn failed_write(&self, name: &str, error: &io::Error, cx: &mut App) { + if error.kind() == io::ErrorKind::PermissionDenied { + return self.failed(error, cx); + } + let text = Self::text(cx, "office.error.save", &[("name", name), ("error", &error.to_string())]); + self.toast(text, Severity::Danger, cx); + } + + /// Asks for a folder, then writes the parts there. + fn split(&mut self, cx: &mut Context) { + let Some(Ask::Split { tab, every }) = self.ask.take() else { + return; + }; + cx.notify(); + self.pick(true, cx, move |viewer, folder, cx| viewer.write_parts(tab, every, folder, cx)); + } + + pub(crate) fn write_parts(&mut self, id: u64, every: usize, folder: PathBuf, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |viewer, _, cx| viewer.write_parts(id, every, folder, cx)); + } + let (Some(tab), Some(view)) = (self.tab(id), self.tab(id).map(|tab| tab.view.clone())) else { + return; + }; + let parts = parts(view.read(cx).sizes.len(), every); + let stem = stem(&tab.name).to_string(); + let targets: Vec = (1..=parts.len()).map(|n| folder.join(format!("{stem}-{n}.pdf"))).collect(); + let opened = folder.clone(); + let work = tab.work.join("part.pdf"); + log::info!("pdf: splits into {} files", parts.len()); + self.read_in( + id, + move |worker, doc| { + let claimed = ely_files::claim_all(&targets)?; + let written = (parts.iter().zip(claimed)).try_for_each(|((first, last), file)| { + let pages: Vec = (*first..=*last).collect(); + worker.get()?.extract(*doc, &pages, &work)?; + copy_into(&work, file) + }); + // A failure leaves none of this run's files. + if let Err(error) = &written + && let Err(left) = targets.iter().try_for_each(std::fs::remove_file) + { + return Err(io::Error::other(format!("{error}; then {left}"))); + } + written.map(|()| targets.len()) + }, + move |viewer, done, _, cx| match done { + Ok(count) => viewer.toast_output(Self::text(cx, "pdf.split.done", &[("count", &count.to_string())]), opened.clone(), cx), + Err(error) => viewer.failed_write(&stem, &error, cx), + }, + cx, + ); + } + + /// The open dialog of the viewer's own, if any. + pub fn ask_element(&self, cx: &mut Context) -> Option { + let this = cx.entity(); + let label = |key: &str| Self::text(cx, key, &[]); + let dismiss = { + let this = this.clone(); + move |_: &mut Window, cx: &mut App| { + this.update(cx, |viewer, cx| { + viewer.ask = None; + cx.notify(); + }) + } + }; + let card = match self.ask.as_ref()? { + Ask::Crop { pages, margins, .. } => { + let count = pages.len().to_string(); + let title = Self::text(cx, "pdf.crop.title", &[("count", &count)]); + let sides = ["top", "right", "bottom", "left"]; + let rows = sides.iter().enumerate().fold(DescriptionList::new(), |rows, (at, side)| { + let name = label(&format!("pdf.crop.{side}")); + rows.item(name.clone(), margin(&this, at, margins[at], name)) + }); + let go = label("pdf.crop.go"); + Card::new("pdf-crop", title, dismiss).child(rows).action(ely_office::cancel(cx)).action(move |_| { + Button::new("pdf-crop-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.crop(cx))) + }) + } + Ask::Split { every, .. } => { + let changed = this.clone(); + let field = NumberInput::new("pdf-split-every", *every as f64) + .range(1.0, 100_000.0) + .label(label("pdf.split.every")) + .on_commit(move |value, _, cx| { + changed.update(cx, |viewer, _| { + if let Some(Ask::Split { every, .. }) = viewer.ask.as_mut() { + *every = value.round() as usize; + } + }) + }); + let rows = DescriptionList::new().item(label("pdf.split.every"), field); + let go = label("pdf.split.go"); + Card::new("pdf-split", label("pdf.split.title"), dismiss).child(rows).action(ely_office::cancel(cx)).action(move |_| { + Button::new("pdf-split-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.split(cx))) + }) + } + }; + Some(card.into_any_element()) + } +} + +/// A margin field in millimetres. +fn margin(this: &Entity, at: usize, value: f32, name: gpui::SharedString) -> NumberInput { + let this = this.clone(); + NumberInput::new(("pdf-crop", at), f64::from(value)) + .range(0.0, 1000.0) + .suffix("mm") + .label(name) + .on_commit(move |value, _, cx| { + this.update(cx, |viewer, _| { + if let Some(Ask::Crop { margins, .. }) = viewer.ask.as_mut() { + margins[at] = value as f32; + } + }) + }) +} + +/// Copies a written part into its claimed file. +pub(crate) fn copy_into(from: &Path, mut to: std::fs::File) -> io::Result<()> { + to.write_all(&std::fs::read(from)?)?; + to.sync_all()?; + std::fs::remove_file(from) +} + +#[cfg(test)] +mod tests { + #[test] + fn parts_cover_every_page_once() { + assert_eq!(super::parts(5, 2), [(0, 1), (2, 3), (4, 4)]); + assert_eq!(super::parts(3, 5), [(0, 2)]); + assert_eq!(super::parts(2, 1), [(0, 0), (1, 1)]); + } + + #[test] + fn a_turned_view_crops_the_side_it_shows() { + // Turned right, the page's left side shows on top. + assert_eq!(super::unturn([1.0, 2.0, 3.0, 4.0], 90), [2.0, 3.0, 4.0, 1.0]); + assert_eq!(super::unturn([1.0, 2.0, 3.0, 4.0], 0), [1.0, 2.0, 3.0, 4.0]); + assert_eq!(super::unturn([1.0, 2.0, 3.0, 4.0], 270), [4.0, 1.0, 2.0, 3.0]); + } +} diff --git a/apps/ely-pdf/src/attach.rs b/apps/ely-pdf/src/attach.rs new file mode 100644 index 0000000..92e86cd --- /dev/null +++ b/apps/ely-pdf/src/attach.rs @@ -0,0 +1,190 @@ +//! Attachments: listed beside the pages, saved, added, removed (PDF-07). + +use std::path::PathBuf; + +use ely_gpui_component::{ + feedback::EmptyState, + lists::{ListItem, SelectableList}, + menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem}, + primitives::{IconName, Severity}, +}; +use ely_office::{Closing as _, Saving as _, Tabs as _, in_window}; +use gpui::{ + Action, AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, + StatefulInteractiveElement as _, Styled as _, TaskExt as _, div, +}; + +use crate::document::{RemoveAttachment, SaveAttachment}; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// A size as people read it. +pub fn size(bytes: u64) -> String { + match bytes { + 0..1024 => format!("{bytes} B"), + 1024..1_048_576 => format!("{:.1} KB", bytes as f64 / 1024.0), + _ => format!("{:.1} MB", bytes as f64 / 1_048_576.0), + } +} + +impl PdfView { + /// Reads the attachments once an edit. + pub fn ask_files(&mut self, cx: &mut Context) { + if self.files.is_some() || self.reading_files { + return; + } + self.reading_files = true; + let (doc, version) = (self.doc, self.version); + self.off( + move |worker| worker.attachments(doc), + move |view, read, cx| { + view.reading_files = false; + if view.version != version { + return cx.notify(); + } + match read { + Ok(files) => view.files = Some(files), + Err(error) => view.fail("reading attachments", &error, cx), + } + cx.notify(); + }, + cx, + ); + } +} + +/// The attachments of `view`; a right press saves or removes. +pub fn list(view: &Entity, cx: &mut App) -> AnyElement { + view.update(cx, |view, cx| view.ask_files(cx)); + let read = view.read(cx); + let Some(files) = read.files.as_ref() else { + return div().into_any_element(); + }; + if files.is_empty() { + return EmptyState::new("pdf-files-none", IconName::File, Viewer::text(cx, "pdf.files.none", &[])).into_any_element(); + } + let total = files.len(); + let rows = files.iter().enumerate().fold(SelectableList::new("pdf-files"), |list, (at, file)| { + let mut about = size(file.size); + if !file.mime.is_empty() { + about = format!("{about} · {}", file.mime); + } + if let Some(page) = file.page { + about = format!("{about} · {}", Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())])); + } + let mut item = ListItem::new(("pdf-file", at), file.name.clone()).description(about).place(at + 1, total); + if !file.note.is_empty() { + item = item.detail(file.note.clone()); + } + list.row(file.key.clone(), item) + }); + let picked = view.clone(); + let rows = rows + .selected(read.file_pick.clone()) + .label(Viewer::text(cx, "pdf.side.files", &[])) + .on_change(move |keys, _, cx| { + let name = keys.first().map(|key| key.to_string()); + picked.update(cx, |view, cx| { + view.file_pick = name; + cx.notify(); + }) + }); + let item = |key: &str, action: Box| { + PopupItem::new(Viewer::text(cx, key, &[])).on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx)) + }; + let popup = Popup::new().item(item("pdf.file.save", Box::new(SaveAttachment))).item(item("pdf.file.remove", Box::new(RemoveAttachment))); + let body = div().id("pdf-files-scroll").size_full().overflow_y_scroll().child(rows); + ContextMenu::new("pdf-files-menu", popup).size_full().child(body).into_any_element() +} + +impl Viewer { + /// Asks for a file, then embeds it by name. + pub fn attach_file(&mut self, cx: &mut Context) { + let Some(view) = self.active_view() else { + return self.no_document(cx); + }; + self.pick(false, cx, move |_, path: PathBuf, cx| { + let name = ely_office::files::file_name(&path); + log::info!("pdf: attaches a file"); + view.update(cx, |view, cx| view.edit("attaching a file", move |worker, doc| worker.attach(doc, &name, &path), cx)); + }); + } + + pub fn remove_attachment(&mut self, cx: &mut Context) { + let Some(view) = self.active_view() else { + return self.no_document(cx); + }; + let Some(key) = view.read(cx).file_pick.clone() else { + return; + }; + view.update(cx, |view, cx| { + view.file_pick = None; + view.edit("removing an attachment", move |worker, doc| worker.detach(doc, &key), cx) + }); + } + + /// Asks where, then writes the picked attachment there. + pub fn save_attachment(&mut self, cx: &mut Context) { + let (Some(tab), Some(view)) = (self.active_tab(), self.active_view()) else { + return self.no_document(cx); + }; + let read = view.read(cx); + let picked = read.file_pick.as_ref().and_then(|key| read.files.as_ref()?.iter().find(|file| &file.key == key)); + let Some((key, name)) = picked.map(|file| (file.key.clone(), file.name.clone())) else { + return; + }; + let Some(dir) = ely_office::folder(tab) else { + return self.panel_failed("no documents folder", cx); + }; + let id = tab.id; + let chosen = cx.prompt_for_new_path(&dir, Some(&name)); + cx.spawn(async move |this, cx| { + let chosen = match chosen.await { + Ok(Ok(path)) => Ok(path), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + in_window(&this, cx, |viewer, _, cx| match chosen { + Ok(Some(target)) => viewer.write_attachment(id, key, target, cx), + Ok(None) => log::info!("pdf: the save panel was cancelled"), + Err(error) => viewer.panel_failed(&error, cx), + }) + }) + .detach_and_log_err(cx); + } + + pub(crate) fn write_attachment(&mut self, id: u64, key: String, target: PathBuf, cx: &mut Context) { + let Some(tab) = self.tab(id) else { + return; + }; + let (shown, opened) = (ely_office::files::file_name(&target), target.clone()); + match self.holder(&target) { + Ok(None) => {} + Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &shown)]), Severity::Danger, cx), + Err(error) => return self.failed(&error, cx), + } + let (open, revision) = (tab.session.path().map(PathBuf::from), tab.session.revision()); + self.read_in( + id, + move |worker, doc| { + let bytes = worker.get()?.attachment(*doc, &key)?; + ely_files::export(&target, open.as_deref(), &bytes, revision).map(|_| ()) + }, + move |viewer, done, _, cx| match done { + Ok(()) => viewer.toast_output(Self::text(cx, "pdf.extract.done", &[("name", &shown)]), opened.clone(), cx), + Err(error) => viewer.failed(&error, cx), + }, + cx, + ); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn sizes_read_in_their_unit() { + assert_eq!(super::size(33), "33 B"); + assert_eq!(super::size(2048), "2.0 KB"); + assert_eq!(super::size(3 * 1_048_576), "3.0 MB"); + } +} diff --git a/apps/ely-pdf/src/content.rs b/apps/ely-pdf/src/content.rs new file mode 100644 index 0000000..4ec84f3 --- /dev/null +++ b/apps/ely-pdf/src/content.rs @@ -0,0 +1,273 @@ +//! Text blocks and drawn objects, picked and dragged (PDF-03, PDF-04). + +use std::collections::HashMap; + +use ely_gpui_component::theme::ActiveTheme as _; +use ely_pdf_engine::{Drawn, TextBlock}; +use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, div, px}; + +use crate::layout::Placed; +use crate::pages::rect_on; +use crate::view::PdfView; + +/// What a press on a page does. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Tool { + #[default] + Read, + Edit, + AddText, + Highlight, + Underline, + StrikeOut, + Note, + Square, + Circle, + Line, + Ink, + Stamp, + Field(ely_pdf_engine::FieldKind), + Link, + /// Marks picked text, or drawn areas, to remove. + RedactText, + RedactArea, + /// Places a signature signed with a certificate. + Sign, + /// Places a visible signature made in its dialog. + PlaceSignature, +} + +impl Tool { + /// Presses pick text, as in reading. + pub fn selects(self) -> bool { + matches!(self, Tool::Read | Tool::Highlight | Tool::Underline | Tool::StrikeOut | Tool::RedactText) + } + + /// Presses aim at text blocks and objects. + pub fn edits(self) -> bool { + matches!(self, Tool::Edit | Tool::AddText) + } +} + +/// A page's text blocks and drawn objects. +#[derive(Clone, Debug, Default)] +pub struct Layer { + pub blocks: Vec, + pub drawn: Vec, +} + +/// What the edit panel works on. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Aim { + Text { page: usize, index: usize }, + Object { page: usize, index: usize }, + /// New text whose box starts here, in page points. + New { page: usize, at: (f32, f32) }, + /// An annotation or form field. + Note(crate::annotate::Pick), +} + +/// An object being moved, or scaled from its corner. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Drag { + pub page: usize, + pub index: usize, + pub rect: [f32; 4], + pub from: (f32, f32), + pub now: (f32, f32), + pub scale: bool, +} + +/// The corner handle's reach, in page points. +const HANDLE: f32 = 6.0; + +impl Drag { + /// The page-space change the drag makes. + pub fn change(&self) -> [f32; 6] { + let ([x0, y0, x1, y1], (dx, dy)) = (self.rect, (self.now.0 - self.from.0, self.now.1 - self.from.1)); + match self.scale { + false => [1.0, 0.0, 0.0, 1.0, dx, dy], + true => { + // Aspect kept, the top left fixed. + let s = ((x1 - x0 + dx) / (x1 - x0)).max((y1 - y0 + dy) / (y1 - y0)).max(0.05); + [s, 0.0, 0.0, s, x0 - s * x0, y0 - s * y0] + } + } + } + + /// The object's box as the drag has it now. + pub fn shown(&self) -> [f32; 4] { + let [a, _, _, d, e, f] = self.change(); + let [x0, y0, x1, y1] = self.rect; + [a * x0 + e, d * y0 + f, a * x1 + e, d * y1 + f] + } +} + +fn inside(rect: &[f32; 4], (x, y): (f32, f32)) -> bool { + x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3] +} + +impl PdfView { + /// A drag or drawing is under way. + pub fn gesturing(&self) -> bool { + self.drag.is_some() || self.sketch.is_some() + } + + /// What the side panel shows; held still mid-gesture. + pub fn panel_aim(&self) -> Option { + if self.gesturing() { self.held } else { self.aim } + } + + /// Changes the tool; what was aimed at is let go. + pub fn set_tool(&mut self, tool: Tool, cx: &mut Context) { + log::info!("pdf: the {tool:?} tool"); + (self.tool, self.aim, self.drag, self.sketch, self.selection, self.marking) = (tool, None, None, None, None, None); + cx.notify(); + } + + /// Reads the layers of `pages`, one page at a time. + pub fn ask_layers(&mut self, pages: &[usize], cx: &mut Context) { + if !self.tool.edits() || self.reading_layer { + return; + } + let Some(page) = pages.iter().copied().find(|page| !self.layers.contains_key(page)) else { + return; + }; + self.reading_layer = true; + let (doc, version) = (self.doc, self.version); + self.off( + move |worker| Ok(Layer { blocks: worker.text_blocks(doc, page)?, drawn: worker.objects(doc, page)? }), + move |view, layer, cx| { + // Cleared either way; reads from before an edit retry. + view.reading_layer = false; + if view.version != version { + return cx.notify(); + } + match layer { + Ok(layer) => { + view.layers.insert(page, layer); + } + Err(error) => view.fail("reading what the page holds", &error, cx), + } + cx.notify(); + }, + cx, + ); + } + + /// A press aims at an object, text, or new text. + pub fn press(&mut self, at: Point, cx: &mut Context) { + let Some((page, point)) = self.page_at(at) else { + return; + }; + (self.drag, self.held) = (None, self.aim); + self.grip(page); + if self.tool == Tool::AddText { + self.aim = Some(Aim::New { page, at: point }); + return cx.notify(); + } + let Some(layer) = self.layers.get(&page) else { + return; + }; + let corner = |rect: &[f32; 4]| (point.0 - rect[2]).abs() <= HANDLE && (point.1 - rect[3]).abs() <= HANDLE; + let held = match self.aim { + Some(Aim::Object { page: on, index }) if on == page => layer.drawn.get(index).filter(|d| corner(&d.rect)).map(|_| (index, true)), + _ => None, + }; + let hit = held.or_else(|| layer.drawn.iter().rposition(|d| inside(&d.rect, point)).map(|index| (index, false))); + self.aim = match hit { + Some((index, scale)) => { + let rect = layer.drawn[index].rect; + self.drag = Some(Drag { page, index, rect, from: point, now: point, scale }); + Some(Aim::Object { page, index }) + } + None => (layer.blocks.iter()) + .position(|b| b.editable && inside(&b.rect, point)) + .map(|index| Aim::Text { page, index }), + }; + cx.notify(); + } + + pub fn drag_to(&mut self, at: Point, cx: &mut Context) { + let Some((page, point)) = self.held_at(at) else { + return; + }; + if let Some(drag) = self.drag.as_mut().filter(|drag| drag.page == page) { + drag.now = point; + cx.notify(); + } + } + + /// Ends a drag; a real move is one edit. + pub fn release(&mut self, cx: &mut Context) { + let Some(drag) = self.drag.take() else { + return; + }; + if (drag.now.0 - drag.from.0).abs() < 1.0 && (drag.now.1 - drag.from.1).abs() < 1.0 { + return cx.notify(); + } + // A queued edit may renumber what was dragged. + if !self.settled() { + let text = crate::viewer::Viewer::text(cx, "pdf.object.changed", &[]); + cx.emit(crate::view::Failed(text.to_string())); + return cx.notify(); + } + let (page, index, change) = (drag.page, drag.index, drag.change()); + self.edit( + if drag.scale { "scaling an object" } else { "moving an object" }, + move |worker, doc| worker.place(doc, page, index, change), + cx, + ); + } + + /// Outlines of what edits on `place`; the aimed one bold. + pub fn edit_marks(&self, place: &Placed, scale: f32, cx: &App) -> Vec { + let Some(layer) = self.layers.get(&place.page).filter(|_| self.tool == Tool::Edit) else { + return Vec::new(); + }; + let colors = &cx.theme().colors; + let size = self.sizes[place.page]; + let outline = |rect: [f32; 4], strong: bool| { + let (x, y, w, h) = rect_on(rect, size, self.rotate, scale); + let color = if strong { colors.focus } else { colors.focus.opacity(0.35) }; + div().absolute().left(px(x)).top(px(y)).w(px(w)).h(px(h)).border_1().border_color(color).into_any_element() + }; + let aimed = |object: bool, index: usize| match self.aim { + Some(Aim::Object { page, index: at }) => object && page == place.page && at == index, + Some(Aim::Text { page, index: at }) => !object && page == place.page && at == index, + _ => false, + }; + let mut marks: Vec = (layer.blocks.iter().enumerate()) + .filter(|(_, b)| b.editable) + .map(|(index, b)| outline(b.rect, aimed(false, index))) + .collect(); + for (index, drawn) in layer.drawn.iter().enumerate() { + let dragged = self.drag.filter(|d| d.page == place.page && d.index == index); + let rect = dragged.map_or(drawn.rect, |d| d.shown()); + marks.push(outline(rect, aimed(true, index))); + if aimed(true, index) { + let (x, y, w, h) = rect_on(rect, size, self.rotate, scale); + let corner = div().absolute().left(px(x + w - 4.0)).top(px(y + h - 4.0)).size(px(8.0)).bg(colors.focus); + marks.push(corner.into_any_element()); + } + } + marks + } +} + +/// Layers by page, dropped with each edit. +pub type Layers = HashMap; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_drag_moves_or_scales_from_the_top_left() { + let mut drag = Drag { page: 0, index: 0, rect: [10.0, 20.0, 110.0, 70.0], from: (50.0, 40.0), now: (60.0, 45.0), scale: false }; + assert_eq!(drag.change(), [1.0, 0.0, 0.0, 1.0, 10.0, 5.0]); + assert_eq!(drag.shown(), [20.0, 25.0, 120.0, 75.0]); + drag = Drag { from: (110.0, 70.0), now: (210.0, 80.0), scale: true, ..drag }; + assert_eq!(drag.shown(), [10.0, 20.0, 210.0, 120.0], "twice as wide, twice as tall"); + } +} diff --git a/apps/ely-pdf/src/document.rs b/apps/ely-pdf/src/document.rs new file mode 100644 index 0000000..f66778f --- /dev/null +++ b/apps/ely-pdf/src/document.rs @@ -0,0 +1,82 @@ +//! The Document menu (PDF-07 to PDF-12). + +use gpui::{App, Context, Div, InteractiveElement as _, Menu, MenuItem, actions}; + +use crate::content::Tool; +use crate::viewer::Viewer; + +actions!( + pdf, + [ + DocumentProperties, + RecognizeText, + AddBookmark, + RenameBookmark, + DeleteBookmark, + AddLink, + AttachFile, + SaveAttachment, + RemoveAttachment, + RedactText, + RedactArea, + ClearRedactions, + ApplyRedactions, + CleanDocument, + OwnerPassword, + ProtectDocument, + SignDocument, + AddVisibleSignature, + ShowSignatures, + CloseSignatures, + ShowRepairReport + ] +); + +pub fn menu(cx: &App) -> Menu { + let label = |key: &str| Viewer::text(cx, key, &[]); + Menu::new(label("menu.document")).items([ + MenuItem::action(label("pdf.props.open"), DocumentProperties), + MenuItem::action(label("pdf.ocr.open"), RecognizeText), + MenuItem::separator(), + MenuItem::action(label("pdf.mark.add"), AddBookmark), + MenuItem::action(label("pdf.tool.link"), AddLink), + MenuItem::action(label("pdf.file.attach"), AttachFile), + MenuItem::separator(), + MenuItem::action(label("pdf.redact.text"), RedactText), + MenuItem::action(label("pdf.redact.area"), RedactArea), + MenuItem::action(label("pdf.redact.clear"), ClearRedactions), + MenuItem::action(label("pdf.redact.apply"), ApplyRedactions), + MenuItem::action(label("pdf.clean.open"), CleanDocument), + MenuItem::separator(), + MenuItem::action(label("pdf.owner.open"), OwnerPassword), + MenuItem::action(label("pdf.protect.open"), ProtectDocument), + MenuItem::action(label("pdf.sign.show"), ShowSignatures), + MenuItem::action(label("pdf.sign.open"), SignDocument), + MenuItem::action(label("pdf.visible.open"), AddVisibleSignature), + ]) +} + +/// Wires the Document actions onto the root. +pub fn on(root: Div, cx: &mut Context) -> Div { + root.on_action(cx.listener(|v, _: &DocumentProperties, window, cx| v.ask_properties(window, cx))) + .on_action(cx.listener(|v, _: &RecognizeText, window, cx| v.ask_ocr(window, cx))) + .on_action(cx.listener(|v, _: &AddBookmark, window, cx| v.ask_bookmark(true, window, cx))) + .on_action(cx.listener(|v, _: &RenameBookmark, window, cx| v.ask_bookmark(false, window, cx))) + .on_action(cx.listener(|v, _: &DeleteBookmark, _, cx| v.delete_bookmark(cx))) + .on_action(cx.listener(|v, _: &AddLink, _, cx| v.toggle_tool(Tool::Link, cx))) + .on_action(cx.listener(|v, _: &AttachFile, _, cx| v.attach_file(cx))) + .on_action(cx.listener(|v, _: &SaveAttachment, _, cx| v.save_attachment(cx))) + .on_action(cx.listener(|v, _: &RemoveAttachment, _, cx| v.remove_attachment(cx))) + .on_action(cx.listener(|v, _: &RedactText, _, cx| v.toggle_tool(Tool::RedactText, cx))) + .on_action(cx.listener(|v, _: &RedactArea, _, cx| v.toggle_tool(Tool::RedactArea, cx))) + .on_action(cx.listener(|v, _: &ClearRedactions, _, cx| v.clear_redactions(cx))) + .on_action(cx.listener(|v, _: &ApplyRedactions, _, cx| v.ask_redact(cx))) + .on_action(cx.listener(|v, _: &CleanDocument, _, cx| v.ask_clean(cx))) + .on_action(cx.listener(|v, _: &OwnerPassword, window, cx| v.ask_owner(window, cx))) + .on_action(cx.listener(|v, _: &ProtectDocument, window, cx| v.ask_protect(window, cx))) + .on_action(cx.listener(|v, _: &SignDocument, window, cx| v.ask_sign(window, cx))) + .on_action(cx.listener(|v, _: &AddVisibleSignature, window, cx| v.ask_visible(window, cx))) + .on_action(cx.listener(|v, _: &ShowSignatures, _, cx| v.show_signatures(true, cx))) + .on_action(cx.listener(|v, _: &CloseSignatures, _, cx| v.show_signatures(false, cx))) + .on_action(cx.listener(|v, _: &ShowRepairReport, _, cx| v.show_repair(true, cx))) +} diff --git a/apps/ely-pdf/src/editing.rs b/apps/ely-pdf/src/editing.rs new file mode 100644 index 0000000..e51dad0 --- /dev/null +++ b/apps/ely-pdf/src/editing.rs @@ -0,0 +1,232 @@ +//! The edit panel: text, font, size, colour; objects (PDF-03, PDF-04). + +use std::sync::Arc; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Choice, ColorPalette, Input, NumberInput, Select, TextInput}, + theme::ControlSize, +}; +use ely_office::Tabs as _; +use ely_pdf_engine::Writing; +use gpui::{ + AppContext as _, Context, Entity, Hsla, IntoElement, ParentElement as _, Render, RenderImage, SharedString, + Styled as _, WeakEntity, Window, div, rgb, +}; + +use crate::content::Aim; +use crate::viewer::Viewer; + +/// Built-in fonts by value and label key. +pub(crate) const FONTS: [(&str, &str); 8] = [ + ("original", "pdf.font.original"), + ("helvetica", "pdf.font.helvetica"), + ("times", "pdf.font.times"), + ("courier", "pdf.font.courier"), + ("cjk-sc", "pdf.font.sc"), + ("cjk-tc", "pdf.font.tc"), + ("cjk-jp", "pdf.font.jp"), + ("cjk-kr", "pdf.font.kr"), +]; + +/// A change that alters layout, shown before it is made. +pub struct Preview { + pub tab: u64, + /// The view's version when asked; any edit since voids it. + pub version: u64, + pub page: usize, + pub writing: Writing, + pub image: Arc, +} + +pub struct EditPanel { + viewer: WeakEntity, + /// The tab, view version and aim the fields show. + shown: Option<(u64, u64, Aim)>, + /// Made on the first draw, which has a window. + pub(crate) field: Option>, + pub font: SharedString, + pub size: f32, + pub rgb: u32, + /// The text box width, in points. + pub width: f32, + /// Why the last write did not happen. + pub note: Option, +} + +impl EditPanel { + pub fn new(viewer: WeakEntity) -> Self { + EditPanel { viewer, shown: None, field: None, font: "original".into(), size: 12.0, rgb: 0, width: 200.0, note: None } + } + + /// Fills the fields for what the view aims at now. + fn follow(&mut self, aimed: (u64, u64, Aim), cx: &mut Context) { + if self.shown == Some(aimed) { + return; + } + let Some(viewer) = self.viewer.upgrade() else { + return; + }; + let Some(view) = viewer.read(cx).active_view() else { + return; + }; + let view = view.read(cx); + let (text, font, size, rgb, width) = match aimed.2 { + Aim::Text { page, index } => match view.layers.get(&page).and_then(|layer| layer.blocks.get(index)) { + Some(b) => (b.text.clone(), "original", b.size, b.rgb, b.rect[2] - b.rect[0]), + None => return, + }, + Aim::New { page, at } => (String::new(), "helvetica", 12.0, 0, (view.sizes[page].0 - at.0 - 36.0).max(72.0)), + Aim::Object { .. } => (String::new(), "original", 12.0, 0, 200.0), + Aim::Note(_) => return, + }; + (self.shown, self.font, self.size, self.rgb, self.width, self.note) = (Some(aimed), font.into(), size, rgb, width, None); + if let Some(field) = &self.field { + field.update(cx, |field, cx| field.set_text(&text, cx)); + } + } + + /// The text to write for `aim` on `view`. + pub fn writing(&self, view: &crate::view::PdfView, aim: Aim, cx: &gpui::App) -> Option<(usize, Writing)> { + let text = self.field.as_ref()?.read(cx).text().to_string(); + let (page, block, expected, bx) = match aim { + Aim::Text { page, index } => { + let b = view.layers.get(&page)?.blocks.get(index)?; + (page, Some(index), b.text.clone(), [b.rect[0], b.rect[1], self.width]) + } + Aim::New { page, at } => (page, None, String::new(), [at.0, at.1, self.width]), + Aim::Object { .. } | Aim::Note(_) => return None, + }; + let writing = Writing { block, expected, text, font: self.font.to_string(), size: self.size, rgb: self.rgb, bx }; + Some((page, writing)) + } +} + +/// A row's label, as the viewer says it. +fn label(cx: &gpui::App, key: &str) -> SharedString { + Viewer::text(cx, key, &[]) +} + +impl Render for EditPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.field.is_none() { + self.field = Some(cx.new(|cx| TextInput::new(window, cx).multi_line(3, 12))); + } + let Some(viewer) = self.viewer.upgrade() else { + return div(); + }; + let Some((tab, view)) = viewer.read(cx).active_tab().map(|tab| (tab.id, tab.view.clone())) else { + return div(); + }; + let Some(aim) = view.read(cx).aim else { + return div(); + }; + self.follow((tab, view.read(cx).version, aim), cx); + let this = cx.entity(); + let buttons = div().flex().gap_2(); + let body = match aim { + Aim::Object { .. } => { + let image = matches!(aim, Aim::Object { page, index } if view.read(cx).layers.get(&page).and_then(|l| l.drawn.get(index)).is_some_and(|d| !d.form)); + let (swap, gone) = (viewer.downgrade(), viewer.downgrade()); + buttons + .children(image.then(|| { + Button::new("pdf-object-replace", label(cx, "pdf.object.replace")) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + swap.update(cx, |viewer, cx| viewer.replace_image(cx)).ok(); + }) + })) + .child( + Button::new("pdf-object-delete", label(cx, "pdf.object.delete")) + .size(ControlSize::Sm) + .variant(ButtonVariant::Danger) + .on_click(move |_, _, cx| { + gone.update(cx, |viewer, cx| viewer.delete_object(cx)).ok(); + }), + ) + .into_any_element() + } + _ => self.text_rows(&this, &viewer, aim, cx), + }; + div().p_3().flex().flex_col().gap_3().child(body) + } +} + +impl EditPanel { + fn text_rows(&self, this: &Entity, viewer: &Entity, aim: Aim, cx: &mut Context) -> gpui::AnyElement { + let set = |this: &Entity, change: fn(&mut EditPanel, f64)| { + let this = this.clone(); + move |value: f64, _: &mut Window, cx: &mut gpui::App| this.update(cx, |panel, cx| { + change(panel, value); + cx.notify(); + }) + }; + let fonts = FONTS + .iter() + .filter(|(value, _)| *value != "original" || matches!(aim, Aim::Text { .. })) + .map(|(value, key)| Choice::new(*value, label(cx, key))); + let picked = this.clone(); + let font = Select::new("pdf-text-font", fonts.collect::>()) + .selected(self.font.clone()) + .label(label(cx, "pdf.text.font")) + .on_change(move |value, _, cx| picked.update(cx, |panel, cx| { + (panel.font, panel.note) = (value.clone(), None); + cx.notify(); + })); + let size = NumberInput::new("pdf-text-size", f64::from(self.size)) + .range(1.0, 400.0) + .label(label(cx, "pdf.text.size")) + .on_commit(set(this, |panel, value| panel.size = value as f32)); + let width = NumberInput::new("pdf-text-width", f64::from(self.width)) + .range(10.0, 2000.0) + .suffix("pt") + .label(label(cx, "pdf.text.width")) + .on_commit(set(this, |panel, value| panel.width = value as f32)); + let swatches = ely_office::panel::COLORS.map(|color| (format!("#{color:06X}"), Hsla::from(rgb(color)))); + let painted = this.clone(); + let mut color = ColorPalette::new("pdf-text-color", swatches.clone()).columns(5).on_change(move |color, _, cx| { + let at = swatches.iter().position(|(_, swatch)| *swatch == color).expect("the palette hands back its own colors"); + painted.update(cx, |panel, cx| { + panel.rgb = ely_office::panel::COLORS[at]; + cx.notify(); + }) + }); + if ely_office::panel::COLORS.contains(&self.rgb) { + color = color.selected(rgb(self.rgb)); + } + let rows = DescriptionList::new() + .item(label(cx, "pdf.text.font"), font) + .item(label(cx, "pdf.text.size"), size) + .item(label(cx, "pdf.text.color"), color) + .item(label(cx, "pdf.text.width"), width); + let (apply, erase) = (viewer.downgrade(), viewer.downgrade()); + let mut buttons = div().flex().gap_2().child( + Button::new("pdf-text-apply", label(cx, "pdf.text.apply")) + .size(ControlSize::Sm) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| { + apply.update(cx, |viewer, cx| viewer.write_text(false, cx)).ok(); + }), + ); + if matches!(aim, Aim::Text { .. }) { + buttons = buttons.child( + Button::new("pdf-text-erase", label(cx, "pdf.text.erase")) + .size(ControlSize::Sm) + .variant(ButtonVariant::Danger) + .on_click(move |_, _, cx| { + erase.update(cx, |viewer, cx| viewer.write_text(true, cx)).ok(); + }), + ); + } + div() + .flex() + .flex_col() + .gap_3() + .children(self.field.as_ref().map(Input::new)) + .child(rows) + .children(self.note.clone().map(|note| div().text_sm().child(note))) + .child(buttons) + .into_any_element() + } +} diff --git a/apps/ely-pdf/src/edits.rs b/apps/ely-pdf/src/edits.rs new file mode 100644 index 0000000..86362ac --- /dev/null +++ b/apps/ely-pdf/src/edits.rs @@ -0,0 +1,149 @@ +//! Edits one at a time; the view rereads after (PDF-02). + +use std::{collections::BTreeSet, io}; + +use ely_pdf_engine::{Bookmark, Doc, Worker}; +use gpui::Context; + +use crate::engine::MEASURE; +use crate::view::{PdfView, Reported, sizes}; + +/// One edit: it answers with the history mark after it. +pub type Op = Box io::Result + Send>; + +/// A queued edit: name, pages picked after, state seen. +pub struct Queued { + what: &'static str, + op: Op, + pick: Option>, + doc: Doc, +} + +/// The document as an edit left it. +pub struct Fresh { + doc: Doc, + mark: String, + pages: usize, + first: Vec<[f32; 4]>, + marks: Vec, +} + +impl PdfView { + /// Queues `op`; `what` names it if it fails. + pub fn edit( + &mut self, + what: &'static str, + op: impl FnOnce(&mut Worker, Doc) -> io::Result + Send + 'static, + cx: &mut Context, + ) { + self.edit_picking(what, None, op, cx); + } + + /// Queues `op`; once it lands, `pick` is picked. + pub fn edit_picking( + &mut self, + what: &'static str, + pick: Option>, + op: impl FnOnce(&mut Worker, Doc) -> io::Result + Send + 'static, + cx: &mut Context, + ) { + log::info!("pdf: queues {what}"); + // Aimed at what is shown now; later states refuse it. + self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc }); + self.next_edit(cx); + } + + fn next_edit(&mut self, cx: &mut Context) { + if self.editing { + return; + } + let Some(Queued { what, op, pick, doc }) = self.queue.pop_front() else { + return; + }; + self.editing = true; + self.off( + move |worker| { + let mark = op(worker, doc)?; + let doc = doc.at(&mark)?; + let pages = worker.pages(doc)?; + let first = worker.bounds(doc, 0, pages.min(MEASURE))?; + Ok(Fresh { doc, mark, pages, first, marks: worker.outline(doc)? }) + }, + move |view, fresh, cx| { + view.editing = false; + match fresh { + Ok(fresh) => { + view.refresh(fresh, cx); + if let Some(pick) = pick { + view.anchor = pick.first().copied(); + view.picked = pick; + } + } + Err(error) => { + view.fail(what, &error, cx); + // Waiting saves and closes learn the queue moved. + cx.emit(Reported); + } + } + view.next_edit(cx); + cx.notify(); + }, + cx, + ); + } + + /// Everything read before the edit goes. + fn refresh(&mut self, fresh: Fresh, cx: &mut Context) { + self.version += 1; + self.edited += 1; + (self.doc, self.mark) = (fresh.doc, fresh.mark); + self.sizes = sizes(fresh.pages, &fresh.first); + self.marks = fresh.marks; + self.retire_tiles(); + self.failed.clear(); + self.text.clear(); + self.asking_text.clear(); + for (_, image) in self.thumbs.drain() { + self.retired.push(image); + } + self.thumbs_failed.clear(); + // Indices shift with any edit; aims start over. + (self.layers, self.aim, self.drag) = (Default::default(), None, None); + (self.notes, self.sketch, self.marking, self.form_names) = (Default::default(), None, None, None); + self.links.clear(); + (self.files, self.mark_pick) = (None, None); + self.sign_at = None; + // Page numbers may have moved under the marks. + self.redactions.clear(); + (self.found, self.chosen, self.selection) = (Vec::new(), None, None); + // Page numbers changed; a range pick starts over. + self.picked.retain(|page| *page < fresh.pages); + self.anchor = None; + self.current = self.current.min(fresh.pages - 1); + self.measure(fresh.first.len(), cx); + cx.emit(Reported); + } + + /// The file now holds history `mark`. + pub fn saved(&mut self, mark: &str, cx: &mut Context) { + self.saved = mark.to_string(); + // The file changed; read it again. + self.signatures = None; + cx.emit(Reported); + } + + /// Changed since the load or the last save. + pub fn modified(&self) -> bool { + self.mark != self.saved + } + + /// Every edit asked for has landed. + pub fn settled(&self) -> bool { + self.queue.is_empty() && !self.editing + } + + pub fn undo(&mut self, redo: bool, cx: &mut Context) { + let what = if redo { "redo" } else { "undo" }; + self.edit(what, move |worker, doc| worker.undo(doc, redo), cx); + } +} diff --git a/apps/ely-pdf/src/engine.rs b/apps/ely-pdf/src/engine.rs new file mode 100644 index 0000000..31b649c --- /dev/null +++ b/apps/ely-pdf/src/engine.rs @@ -0,0 +1,191 @@ +//! MuPDF as the shell's engine (PDF-01, PERF-01). + +use std::{ + convert::Infallible, + io, + path::Path, + sync::{Arc, Mutex}, +}; + +use ely_office::{Engine, Host, Load}; +use ely_pdf_engine::{Bookmark, Doc, Worker}; +use gpui::{App, AppContext as _, Context, Entity, FocusHandle, Window}; + +use crate::view::{Failed, PdfView, Reported}; + +/// Pages measured with the load. +pub const MEASURE: usize = 100; + +/// The worker, started on the first open. +#[derive(Default)] +pub struct Mupdf(Option); + +impl Mupdf { + /// A holder around a running worker, for tests. + #[cfg(test)] + pub fn with(worker: Worker) -> Self { + Mupdf(Some(worker)) + } + + #[cfg(test)] + pub fn started(&self) -> bool { + self.0.is_some() + } + + /// The worker, started now if need be. + pub fn get(&mut self) -> io::Result<&mut Worker> { + if self.0.is_none() { + log::info!("pdf: starts the worker"); + self.0 = Some(Worker::spawn()?); + } + Ok(self.0.as_mut().expect("just started")) + } +} + +/// A load: the page count, first sizes, bookmarks, history. +pub struct Loaded { + pub doc: Doc, + pub pages: usize, + pub first: Vec<[f32; 4]>, + pub marks: Vec, + pub mark: String, + /// `proto::may` bits. + pub allowed: u32, + /// Fields holding a signature. + pub signed: usize, + /// MuPDF's words when it had to repair the file. + pub repaired: Option, +} + +/// The MuPDF engine. +pub struct Pdf; + +/// What a tab needs; locked or empty files fail. +fn read(worker: &mut Worker, doc: Doc, locked: bool, password: &str) -> io::Result { + if locked && !worker.unlock(doc, password)? { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "a password is needed")); + } + let pages = worker.pages(doc)?; + if pages == 0 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "no pages to show")); + } + let first = worker.bounds(doc, 0, pages.min(MEASURE))?; + let (marks, mark, allowed) = (worker.outline(doc)?, worker.history(doc)?, worker.permissions(doc)?); + let (signed, repaired) = (worker.signed(doc)?, worker.repair_report(doc)?); + Ok(Loaded { doc, pages, first, marks, mark, allowed, signed, repaired }) +} + +impl Engine for Pdf { + type Worker = Mupdf; + type Doc = Doc; + type View = PdfView; + type Loaded = Loaded; + type Import = Infallible; + + fn load(worker: &mut Mupdf, from: Load) -> io::Result { + let (path, password) = match from { + Load::File(path, password) => (path, password), + Load::Import(_, never) => match *never {}, + }; + let worker = worker.get()?; + let opened = worker.open(path)?; + read(worker, opened.doc, opened.locked, password).inspect_err(|_| { + // A file not shown leaves nothing open. + if let Err(error) = worker.close(opened.doc) { + log::error!("pdf: closing a file not shown: {error}"); + } + }) + } + + fn open( + worker: &Arc>, + loaded: Loaded, + _: &mut Window, + cx: &mut App, + ) -> io::Result<(Doc, Entity, String)> { + let (doc, mark) = (loaded.doc, loaded.mark.clone()); + let view = cx.new(|cx| PdfView::new(Arc::clone(worker), loaded, cx)); + Ok((doc, view, mark)) + } + + fn risk(loaded: &Loaded) -> Option { + match (&loaded.repaired, loaded.signed) { + (Some(_), _) => Some(ely_office::Risk::Repaired), + (None, 0) => None, + (None, _) => Some(ely_office::Risk::Signed), + } + } + + fn unsound(worker: &mut Mupdf, doc: &Doc) -> io::Result> { + Ok(worker.get()?.check_pictures(*doc)?.then_some(ely_office::Risk::Repaired)) + } + + fn history(worker: &mut Mupdf, doc: &Doc) -> io::Result { + worker.get()?.history(*doc) + } + + fn write(worker: &mut Mupdf, doc: &Doc, path: &Path, format: &str) -> io::Result<()> { + match format { + "pdf" => worker.get()?.save(*doc, path), + other => Err(io::Error::new(io::ErrorKind::Unsupported, format!("writing .{other}"))), + } + } + + /// MuPDF's save writes a snapshot; the document is untouched. + fn snapshot(worker: &mut Mupdf, doc: &Doc, path: &Path, format: &str) -> io::Result<()> { + Self::write(worker, doc, path, format) + } + + fn close(worker: &mut Mupdf, doc: Doc) -> io::Result<()> { + worker.get()?.close(doc) + } + + fn retire(view: &Entity, cx: &mut App) -> ely_gpui_component::primitives::Retired { + view.update(cx, |view, _| view.retire_all()) + } + + fn ended(worker: &mut Mupdf) -> Option { + worker.0.as_mut()?.ended() + } + + /// The next load starts a new worker. + fn restart(_: ely_platform::AppId, worker: &mut Mupdf) -> io::Result<()> { + worker.0 = None; + Ok(()) + } + + fn settled(view: &PdfView) -> bool { + view.settled() + } + + fn edited(view: &PdfView) -> u64 { + view.edited + } + + fn edit_pending(view: &PdfView) -> bool { + !view.settled() + } + + fn focus(view: &PdfView) -> FocusHandle { + view.focus.clone() + } + + fn saved(view: &Entity, mark: &str, cx: &mut App) { + view.update(cx, |view, cx| view.saved(mark, cx)); + } + + fn watch>( + view: &Entity, + cx: &mut Context, + reported: impl Fn(&mut H, &Entity, bool, &mut Context) + 'static, + failed: impl Fn(&mut H, &str, &mut Context) + 'static, + ) { + cx.subscribe(view, move |host, _, error: &Failed, cx| failed(host, &error.0, cx)) + .detach(); + cx.subscribe(view, move |host, view, _: &Reported, cx| { + let modified = view.read(cx).modified(); + reported(host, &view, modified, cx) + }) + .detach(); + } +} diff --git a/apps/ely-pdf/src/keys.rs b/apps/ely-pdf/src/keys.rs new file mode 100644 index 0000000..90f5117 --- /dev/null +++ b/apps/ely-pdf/src/keys.rs @@ -0,0 +1,147 @@ +//! Menus and keys of the viewer (PDF-01). + +use ely_ui::commands; +use gpui::{ + App, Context, Div, InteractiveElement as _, KeyBinding, Menu, MenuItem, Window, actions, +}; + +use crate::layout::{ACTUAL, Mode, Zoom}; +use crate::view::PdfView; +use crate::viewer::Viewer; + +actions!( + pdf, + [ + Continuous, + SinglePage, + TwoPages, + ZoomIn, + ZoomOut, + FitWidth, + FitPage, + ActualSize, + RotateLeft, + RotateRight, + NextPage, + PreviousPage, + FirstPage, + LastPage, + CopyText, + Find + ] +); + +/// Zoom steps, in pixels a point. +const STEPS: [f32; 9] = [0.5, 0.67, 0.8, 1.0, ACTUAL, 1.6, 2.0, 3.0, 4.0]; + +pub fn bind(cx: &mut App) { + crate::organize::bind(cx); + crate::writes::bind(cx); + let at = Some(commands::CONTEXT); + cx.bind_keys([ + KeyBinding::new("secondary-=", ZoomIn, at), + KeyBinding::new("secondary--", ZoomOut, at), + KeyBinding::new("secondary-0", ActualSize, at), + KeyBinding::new("secondary-]", RotateRight, at), + KeyBinding::new("secondary-[", RotateLeft, at), + KeyBinding::new("pagedown", NextPage, at), + KeyBinding::new("pageup", PreviousPage, at), + KeyBinding::new("home", FirstPage, at), + KeyBinding::new("end", LastPage, at), + KeyBinding::new("secondary-c", CopyText, at), + KeyBinding::new("secondary-f", Find, at), + ]); +} + +pub fn menus(cx: &App) -> Vec { + let label = |key: &str| Viewer::text(cx, key, &[]); + vec![ + Menu::new(label("menu.edit")).items(crate::organize::history_items(cx).into_iter().chain([ + MenuItem::separator(), + MenuItem::action(label("pdf.copy"), CopyText), + MenuItem::action(label("pdf.find"), Find), + MenuItem::separator(), + ]).chain(crate::writes::items(cx))), + Menu::new(label("menu.view")).items([ + MenuItem::action(label("pdf.view.continuous"), Continuous), + MenuItem::action(label("pdf.view.single"), SinglePage), + MenuItem::action(label("pdf.view.two"), TwoPages), + MenuItem::separator(), + MenuItem::action(label("pdf.view.zoom_in"), ZoomIn), + MenuItem::action(label("pdf.view.zoom_out"), ZoomOut), + MenuItem::action(label("pdf.view.fit_width"), FitWidth), + MenuItem::action(label("pdf.view.fit_page"), FitPage), + MenuItem::action(label("pdf.view.actual"), ActualSize), + MenuItem::separator(), + MenuItem::action(label("pdf.view.rotate_left"), RotateLeft), + MenuItem::action(label("pdf.view.rotate_right"), RotateRight), + ]), + crate::organize::menu(cx), + ] + .into_iter() + .chain(crate::marking::menus(cx)) + .chain([crate::document::menu(cx)]) + .collect() +} + +/// The next zoom step up or down from `now`. +pub fn stepped(now: f32, up: bool) -> f32 { + match up { + true => STEPS.into_iter().find(|step| *step > now + 0.001).unwrap_or(STEPS[STEPS.len() - 1]), + false => STEPS.into_iter().rev().find(|step| *step < now - 0.001).unwrap_or(STEPS[0]), + } +} + +/// Wires every viewer action onto the root. +pub fn on(root: Div, cx: &mut Context) -> Div { + let change = |change: fn(&mut PdfView, f32)| { + move |viewer: &mut Viewer, cx: &mut Context| viewer.on_view(|view, cx| view.change(change, cx), cx) + }; + let turn = |by: isize| move |viewer: &mut Viewer, cx: &mut Context| viewer.on_view(|view, cx| view.turn(by, cx), cx); + let (continuous, single, two) = ( + change(|view, _| view.mode = Mode::Continuous), + change(|view, _| view.mode = Mode::Single), + change(|view, _| view.mode = Mode::Two), + ); + let (width, page, actual) = ( + change(|view, _| view.zoom = Zoom::Width), + change(|view, _| view.zoom = Zoom::Page), + change(|view, _| view.zoom = Zoom::Scale(ACTUAL)), + ); + let (zoom_in, zoom_out) = ( + change(|view, now| view.zoom = Zoom::Scale(stepped(now, true))), + change(|view, now| view.zoom = Zoom::Scale(stepped(now, false))), + ); + let (right, left) = ( + change(|view, _| view.rotate = (view.rotate + 90) % 360), + change(|view, _| view.rotate = (view.rotate + 270) % 360), + ); + let (next, previous, first, last) = (turn(1), turn(-1), turn(isize::MIN / 2), turn(isize::MAX / 2)); + root.on_action(cx.listener(move |v, _: &Continuous, _, cx| continuous(v, cx))) + .on_action(cx.listener(move |v, _: &SinglePage, _, cx| single(v, cx))) + .on_action(cx.listener(move |v, _: &TwoPages, _, cx| two(v, cx))) + .on_action(cx.listener(move |v, _: &FitWidth, _, cx| width(v, cx))) + .on_action(cx.listener(move |v, _: &FitPage, _, cx| page(v, cx))) + .on_action(cx.listener(move |v, _: &ActualSize, _, cx| actual(v, cx))) + .on_action(cx.listener(move |v, _: &ZoomIn, _, cx| zoom_in(v, cx))) + .on_action(cx.listener(move |v, _: &ZoomOut, _, cx| zoom_out(v, cx))) + .on_action(cx.listener(move |v, _: &RotateRight, _, cx| right(v, cx))) + .on_action(cx.listener(move |v, _: &RotateLeft, _, cx| left(v, cx))) + .on_action(cx.listener(move |v, _: &NextPage, _, cx| next(v, cx))) + .on_action(cx.listener(move |v, _: &PreviousPage, _, cx| previous(v, cx))) + .on_action(cx.listener(move |v, _: &FirstPage, _, cx| first(v, cx))) + .on_action(cx.listener(move |v, _: &LastPage, _, cx| last(v, cx))) + .on_action(cx.listener(|v, _: &CopyText, _, cx| v.on_view(|view, cx| view.copy(cx), cx))) + .on_action(cx.listener(|v: &mut Viewer, _: &Find, window: &mut Window, cx| v.open_find(window, cx))) +} + +#[cfg(test)] +mod tests { + #[test] + fn zoom_steps_go_up_and_down_and_stop_at_the_ends() { + assert_eq!(super::stepped(1.0, true), super::ACTUAL); + assert_eq!(super::stepped(1.2, false), 1.0, "from between steps"); + assert_eq!(super::stepped(4.0, true), 4.0); + assert_eq!(super::stepped(0.5, false), 0.5); + } +} diff --git a/apps/ely-pdf/src/layout.rs b/apps/ely-pdf/src/layout.rs new file mode 100644 index 0000000..7d3b911 --- /dev/null +++ b/apps/ely-pdf/src/layout.rs @@ -0,0 +1,139 @@ +//! Where pages sit for a mode, zoom and turn (PDF-01). + +/// How pages follow one another. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Continuous, + Single, + Two, +} + +/// How big pages are drawn. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Zoom { + Width, + Page, + /// Pixels a point; 96 / 72 is actual size. + Scale(f32), +} + +/// Points to pixels at actual size. +pub const ACTUAL: f32 = 96.0 / 72.0; +/// Space around and between pages, in pixels. +pub const GAP: f32 = 16.0; + +/// One page placed, in pixels from the top left. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placed { + pub page: usize, + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +/// The pages placed, the whole size, the scale used. +#[derive(Clone, Debug, PartialEq)] +pub struct Layout { + pub placed: Vec, + pub size: (f32, f32), + pub scale: f32, +} + +/// A page's size in points once turned. +pub fn turned((w, h): (f32, f32), rotate: i32) -> (f32, f32) { + match rotate.rem_euclid(360) { + 90 | 270 => (h, w), + _ => (w, h), + } +} + +/// Lays `sizes` out in `view` pixels; `current` shows alone. +pub fn layout(sizes: &[(f32, f32)], rotate: i32, (mode, zoom): (Mode, Zoom), view: (f32, f32), current: usize) -> Layout { + let sizes: Vec<(f32, f32)> = sizes.iter().map(|size| turned(*size, rotate)).collect(); + let shown: Vec = match mode { + Mode::Single => sizes.len().checked_sub(1).map(|last| current.min(last)).into_iter().collect(), + _ => (0..sizes.len()).collect(), + }; + let across = if mode == Mode::Two { 2.0 } else { 1.0 }; + // Rows of one or two pages, as wide as theirs. + let rows: Vec> = match mode { + Mode::Two => shown.chunks(2).map(<[usize]>::to_vec).collect(), + _ => shown.iter().map(|page| vec![*page]).collect(), + }; + let widest = (rows.iter()) + .map(|row| row.iter().map(|page| sizes[*page].0).sum::()) + .fold(0.0, f32::max) + .max(1.0); + let room = (view.0 - GAP * (1.0 + across)).max(1.0); + let scale = match zoom { + Zoom::Scale(scale) => scale, + Zoom::Width => room / widest, + Zoom::Page => { + let tallest = shown.iter().map(|page| sizes[*page].1).fold(0.0, f32::max).max(1.0); + (room / widest).min((view.1 - 2.0 * GAP).max(1.0) / tallest) + } + }; + let width = (widest * scale + GAP * (1.0 + across)).max(view.0); + let (mut placed, mut y) = (Vec::new(), GAP); + for row in rows { + let row_w = row.iter().map(|page| sizes[*page].0 * scale).sum::() + GAP * (row.len() as f32 - 1.0); + let row_h = row.iter().map(|page| sizes[*page].1 * scale).fold(0.0, f32::max); + let mut x = (width - row_w) / 2.0; + for page in row { + let (w, h) = (sizes[page].0 * scale, sizes[page].1 * scale); + placed.push(Placed { page, x, y: y + (row_h - h) / 2.0, w, h }); + x += w + GAP; + } + y += row_h + GAP; + } + Layout { placed, size: (width, y.max(view.1)), scale } +} + +/// Places on screen between `top` and `bottom`. +pub fn visible(layout: &Layout, top: f32, bottom: f32) -> impl Iterator { + layout.placed.iter().filter(move |place| place.y < bottom && place.y + place.h > top) +} + +#[cfg(test)] +mod tests { + use super::*; + + const LETTER: (f32, f32) = (612.0, 792.0); + + #[test] + fn continuous_fits_the_width_and_stacks_with_gaps() { + let out = layout(&[LETTER, LETTER], 0, (Mode::Continuous, Zoom::Width), (644.0, 500.0), 0); + assert_eq!(out.scale, 1.0, "612 points in 612 pixels of room"); + assert_eq!(out.placed[0], Placed { page: 0, x: 16.0, y: 16.0, w: 612.0, h: 792.0 }); + assert_eq!(out.placed[1].y, 16.0 + 792.0 + 16.0); + assert_eq!(out.size, (644.0, 16.0 + 2.0 * (792.0 + 16.0))); + } + + #[test] + fn single_shows_one_page_and_two_pairs_them() { + let pages = [LETTER, LETTER, LETTER]; + let one = layout(&pages, 0, (Mode::Single, Zoom::Scale(1.0)), (800.0, 600.0), 2); + assert_eq!(one.placed.iter().map(|p| p.page).collect::>(), [2]); + let two = layout(&pages, 0, (Mode::Two, Zoom::Scale(1.0)), (800.0, 600.0), 0); + let rows: Vec<(usize, f32)> = two.placed.iter().map(|p| (p.page, p.y)).collect(); + assert_eq!(rows, [(0, 16.0), (1, 16.0), (2, 824.0)]); + assert_eq!(two.placed[1].x - two.placed[0].x, 612.0 + 16.0, "side by side"); + } + + #[test] + fn a_quarter_turn_swaps_sides_and_page_zoom_fits_the_height() { + let out = layout(&[LETTER], 90, (Mode::Continuous, Zoom::Page), (1000.0, 644.0), 0); + assert_eq!((out.placed[0].w / out.scale, out.placed[0].h / out.scale), (792.0, 612.0)); + assert!((out.placed[0].h - 612.0).abs() < 0.01, "the height fits"); + let shown: Vec = visible(&out, 0.0, 10.0).map(|p| p.page).collect(); + assert!(shown.is_empty(), "above the first page"); + } + + #[test] + fn no_pages_lay_out_as_nothing() { + for mode in [Mode::Continuous, Mode::Single, Mode::Two] { + assert!(layout(&[], 0, (mode, Zoom::Page), (800.0, 600.0), 0).placed.is_empty()); + } + } +} diff --git a/apps/ely-pdf/src/links.rs b/apps/ely-pdf/src/links.rs new file mode 100644 index 0000000..a50fc24 --- /dev/null +++ b/apps/ely-pdf/src/links.rs @@ -0,0 +1,63 @@ +//! Links: followed, drawn, retargeted, deleted (PDF-07). + +use ely_pdf_engine::{Link, Target}; +use gpui::Context; + +use crate::view::{Failed, PdfView}; + +/// Addresses that may leave the document. +pub fn outward(uri: &str) -> bool { + ["https://", "http://", "mailto:"].iter().any(|scheme| uri.starts_with(scheme)) +} + +/// A target as typed: a page number or an address. +pub fn parse(typed: &str, pages: usize) -> Option { + let typed = typed.trim(); + match typed.parse::() { + Ok(n) => (1..=pages).contains(&n).then(|| Target::Page(n - 1, 0.0)), + Err(_) => outward(typed).then(|| Target::Web(typed.to_string())), + } +} + +/// A link's target as a person types it. +pub fn typed(link: &Link) -> String { + match link.page { + Some(page) => (page + 1).to_string(), + None => link.uri.clone(), + } +} + +impl PdfView { + /// Goes where `link` points; only web and mail leave. + pub fn follow(&mut self, link: &Link, cx: &mut Context) { + match link.page { + Some(page) => { + log::info!("pdf: follows a link to page {}", page + 1); + self.show(page, link.y, cx) + } + None if outward(&link.uri) => { + log::info!("pdf: opens a link outside"); + cx.open_url(&link.uri) + } + None => { + let text = crate::viewer::Viewer::text(cx, "pdf.link.refused", &[]); + cx.emit(Failed(text.to_string())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_target_is_a_page_in_range_or_a_web_or_mail_address() { + assert_eq!(parse(" 3 ", 5), Some(Target::Page(2, 0.0))); + assert_eq!(parse("9", 5), None); + assert_eq!(parse("0", 5), None); + assert_eq!(parse("https://a.b", 5), Some(Target::Web("https://a.b".into()))); + assert_eq!(parse("javascript:x", 5), None); + assert_eq!(parse("file:///etc", 5), None); + } +} diff --git a/apps/ely-pdf/src/main.rs b/apps/ely-pdf/src/main.rs new file mode 100644 index 0000000..11e47bd --- /dev/null +++ b/apps/ely-pdf/src/main.rs @@ -0,0 +1,39 @@ +mod annotate; +mod ask; +mod attach; +mod content; +mod document; +mod editing; +mod edits; +mod engine; +mod keys; +mod layout; +mod links; +mod marking; +mod marks; +mod notelist; +mod notes; +mod ocr; +mod organize; +mod output; +mod pages; +mod props; +mod protect; +mod redact; +mod search; +mod select; +mod side; +mod sign; +mod view; +mod visible; +mod viewer; +mod writes; + +use ely_platform::AppId; + +fn main() { + ely_ui::run(AppId::Pdf, viewer::open); +} + +#[cfg(test)] +mod tests; diff --git a/apps/ely-pdf/src/marking.rs b/apps/ely-pdf/src/marking.rs new file mode 100644 index 0000000..2991d48 --- /dev/null +++ b/apps/ely-pdf/src/marking.rs @@ -0,0 +1,176 @@ +//! Annotation and form menus, and the panel's changes (PDF-05, PDF-06). + +use ely_gpui_component::primitives::Severity; +use ely_office::Tabs as _; +use ely_pdf_engine::{FieldKind, LinkChange, NewAnnot, NewField, Shape}; +use gpui::{App, Context, Div, InteractiveElement as _, Menu, MenuItem, actions}; + +use crate::annotate::{Pick, around}; +use crate::content::Tool; +use crate::viewer::Viewer; + +actions!( + pdf, + [ + Highlight, + Underline, + StrikeOut, + AddNote, + DrawRectangle, + DrawEllipse, + DrawLine, + DrawFreehand, + AddStamp, + AddTextField, + AddCheckbox, + AddRadioButton, + AddChoice + ] +); + +/// A stamp's size, in points. +const STAMP: (f32, f32) = (190.0, 50.0); + +/// What the note panel asks for. +#[derive(Clone, Debug, PartialEq)] +pub enum Change { + Note(String), + Stamp(String), + Contents(String), + Color(u32), + Delete, + /// A field's name and options. + Design(String, Vec), + Fill(String), + /// A link target as typed. + Target(String), +} + +/// The Annotate and Form menus. +pub fn menus(cx: &App) -> [Menu; 2] { + let label = |key: &str| Viewer::text(cx, key, &[]); + [ + Menu::new(label("menu.annotate")).items([ + MenuItem::action(label("pdf.tool.highlight"), Highlight), + MenuItem::action(label("pdf.tool.underline"), Underline), + MenuItem::action(label("pdf.tool.strikeout"), StrikeOut), + MenuItem::separator(), + MenuItem::action(label("pdf.tool.note"), AddNote), + MenuItem::action(label("pdf.tool.stamp"), AddStamp), + MenuItem::separator(), + MenuItem::action(label("pdf.tool.square"), DrawRectangle), + MenuItem::action(label("pdf.tool.circle"), DrawEllipse), + MenuItem::action(label("pdf.tool.line"), DrawLine), + MenuItem::action(label("pdf.tool.ink"), DrawFreehand), + ]), + Menu::new(label("menu.form")).items([ + MenuItem::action(label("pdf.tool.field_text"), AddTextField), + MenuItem::action(label("pdf.tool.field_check"), AddCheckbox), + MenuItem::action(label("pdf.tool.field_radio"), AddRadioButton), + MenuItem::action(label("pdf.tool.field_choice"), AddChoice), + ]), + ] +} + +impl Viewer { + /// Makes `change` if tab `tab` is still at `version`. + pub fn change_note(&mut self, (tab, version, pick): (u64, u64, Pick), change: Change, cx: &mut Context) { + let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else { + let text = Self::text(cx, "pdf.object.changed", &[]); + return self.toast(text, Severity::Warning, cx); + }; + self.on_same(&view, version, cx, move |view, cx| { + let note = |page, shape, contents| NewAnnot { page, shape, rgb: None, contents, author: String::new() }; + match (pick, change) { + (Pick::NewNote { page, at }, Change::Note(text)) => view.annotate("adding a note", vec![note(page, Shape::Note(at), text)], cx), + (Pick::NewStamp { page, at }, Change::Stamp(icon)) => { + let rect = around(at, STAMP, view.sizes[page]); + view.annotate("adding a stamp", vec![note(page, Shape::Stamp(rect, icon), String::new())], cx) + } + (Pick::Annot { page, id }, Change::Contents(text)) => { + view.edit("changing an annotation", move |w, doc| w.change_annot(doc, page, id, None, Some(&text)), cx) + } + (Pick::Annot { page, id }, Change::Color(rgb)) => { + view.edit("colouring an annotation", move |w, doc| w.change_annot(doc, page, id, Some(rgb), None), cx) + } + (Pick::Annot { page, id }, Change::Delete) => view.edit("deleting an annotation", move |w, doc| w.delete_annot(doc, page, id), cx), + (Pick::Field { page, id }, Change::Delete) => view.edit("deleting a field", move |w, doc| w.delete_field(doc, page, id), cx), + (Pick::Field { page, id }, Change::Fill(value)) => view.edit("filling a field", move |w, doc| w.fill(doc, page, id, &value), cx), + (Pick::Field { page, id }, Change::Design(name, options)) => { + let Some(field) = view.notes.get(&page).and_then(|m| m.fields.iter().find(|f| f.id == id)) else { + return log::error!("pdf: a picked field is not read"); + }; + let renamed = (field.leaf() != name).then_some(name); + let choice = matches!(field.kind, FieldKind::Choice | FieldKind::List); + let listed = (choice && field.options != options).then_some(options); + if renamed.is_none() && listed.is_none() { + return log::info!("pdf: the field is as it was"); + } + view.edit( + "changing a field", + move |w, doc| w.change_field(doc, page, id, renamed.as_deref(), listed.as_deref()), + cx, + ) + } + (Pick::NewField { page, rect, kind }, Change::Design(name, options)) => { + let field = NewField { kind, name, rect, options }; + view.edit("adding a field", move |w, doc| w.add_field(doc, page, &field), cx) + } + (Pick::NewLink { page, rect }, Change::Target(typed)) => { + let Some(to) = crate::links::parse(&typed, view.sizes.len()) else { + return bad_target(cx); + }; + view.edit("adding a link", move |w, doc| w.change_link(doc, page, &LinkChange::Add { rect, to }), cx) + } + (Pick::Link { page, index }, change) => { + let Some(expect) = view.links.get(&page).and_then(|links| links.get(index)).map(|link| link.uri.clone()) else { + return log::error!("pdf: a picked link is not read"); + }; + let change = match change { + Change::Delete => LinkChange::Delete { index, expect }, + Change::Target(typed) => match crate::links::parse(&typed, view.sizes.len()) { + Some(to) => LinkChange::Edit { index, expect, to }, + None => return bad_target(cx), + }, + other => return log::error!("pdf: {other:?} does not fit a link"), + }; + view.edit("changing a link", move |w, doc| w.change_link(doc, page, &change), cx) + } + (pick, change) => log::error!("pdf: {change:?} does not fit {pick:?}"), + } + }); + } +} + +/// Says a typed link target is neither page nor address. +fn bad_target(cx: &mut Context) { + let text = Viewer::text(cx, "pdf.link.bad", &[]); + cx.emit(crate::view::Failed(text.to_string())); +} + +/// Wires the annotation and form actions onto the root. +pub fn on(root: Div, cx: &mut Context) -> Div { + let tool = |tool: Tool| move |viewer: &mut Viewer, cx: &mut Context| viewer.toggle_tool(tool, cx); + let (highlight, underline, strike, note, stamp) = + (tool(Tool::Highlight), tool(Tool::Underline), tool(Tool::StrikeOut), tool(Tool::Note), tool(Tool::Stamp)); + let (square, circle, line, ink) = (tool(Tool::Square), tool(Tool::Circle), tool(Tool::Line), tool(Tool::Ink)); + let (text, check, radio, choice) = ( + tool(Tool::Field(FieldKind::Text)), + tool(Tool::Field(FieldKind::Checkbox)), + tool(Tool::Field(FieldKind::Radio)), + tool(Tool::Field(FieldKind::Choice)), + ); + root.on_action(cx.listener(move |v, _: &Highlight, _, cx| highlight(v, cx))) + .on_action(cx.listener(move |v, _: &Underline, _, cx| underline(v, cx))) + .on_action(cx.listener(move |v, _: &StrikeOut, _, cx| strike(v, cx))) + .on_action(cx.listener(move |v, _: &AddNote, _, cx| note(v, cx))) + .on_action(cx.listener(move |v, _: &AddStamp, _, cx| stamp(v, cx))) + .on_action(cx.listener(move |v, _: &DrawRectangle, _, cx| square(v, cx))) + .on_action(cx.listener(move |v, _: &DrawEllipse, _, cx| circle(v, cx))) + .on_action(cx.listener(move |v, _: &DrawLine, _, cx| line(v, cx))) + .on_action(cx.listener(move |v, _: &DrawFreehand, _, cx| ink(v, cx))) + .on_action(cx.listener(move |v, _: &AddTextField, _, cx| text(v, cx))) + .on_action(cx.listener(move |v, _: &AddCheckbox, _, cx| check(v, cx))) + .on_action(cx.listener(move |v, _: &AddRadioButton, _, cx| radio(v, cx))) + .on_action(cx.listener(move |v, _: &AddChoice, _, cx| choice(v, cx))) +} diff --git a/apps/ely-pdf/src/marks.rs b/apps/ely-pdf/src/marks.rs new file mode 100644 index 0000000..7da742f --- /dev/null +++ b/apps/ely-pdf/src/marks.rs @@ -0,0 +1,202 @@ +//! Bookmarks: jump, add, rename, delete (PDF-01, PDF-07). + +use ely_gpui_component::{ + feedback::EmptyState, + forms::TextInput, + lists::Outline, + menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem}, + overlays::PromptDialog, + primitives::IconName, +}; +use ely_office::{Closing as _, Tabs as _}; +use ely_pdf_engine::{Bookmark, BookmarkChange}; +use gpui::{Action, AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Styled as _, Window, div}; + +use crate::document::{DeleteBookmark, RenameBookmark}; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// One bookmark: title, level, target, and where it sits. +#[derive(Clone, Debug, PartialEq)] +pub struct Flat { + pub title: String, + pub level: usize, + pub to: Option<(usize, f32)>, + /// Child indices from the top. + pub path: Vec, +} + +/// Bookmarks depth first. +pub fn flat(marks: &[Bookmark], above: &[usize], out: &mut Vec) { + for (at, mark) in marks.iter().enumerate() { + let path: Vec = above.iter().copied().chain([at]).collect(); + out.push(Flat { title: mark.title.clone(), level: path.len(), to: mark.page.map(|page| (page, mark.y)), path: path.clone() }); + flat(&mark.down, &path, out); + } +} + +/// A new bookmark's place: after the pick, else last. +pub fn place_for(marks: &[Bookmark], picked: Option<&Flat>) -> Vec { + match picked { + Some(flat) => { + let mut path = flat.path.clone(); + *path.last_mut().expect("a path has a step") += 1; + path + } + None => vec![marks.len()], + } +} + +/// A bookmark title being typed. +pub struct Naming { + pub tab: u64, + pub version: u64, + pub field: Entity, + /// None adds at `path`; Some renames the bookmark titled so. + pub expect: Option, + pub path: Vec, + pub page: usize, +} + +impl PdfView { + /// The picked bookmark, if it is still there. + pub fn picked_mark(&self) -> Option { + let mut all = Vec::new(); + flat(&self.marks, &[], &mut all); + self.mark_pick.and_then(|at| all.get(at).cloned()) + } +} + +/// The bookmarks of `view`; a press picks and jumps. +pub fn outline(view: &Entity, cx: &App) -> AnyElement { + let mut items = Vec::new(); + flat(&view.read(cx).marks, &[], &mut items); + if items.is_empty() { + return EmptyState::new("pdf-marks-none", IconName::File, Viewer::text(cx, "pdf.marks.none", &[])).into_any_element(); + } + let targets: Vec> = items.iter().map(|flat| flat.to).collect(); + let picked = view.read(cx).mark_pick; + let view = view.clone(); + let mut outline = (items.into_iter().enumerate()) + .fold(Outline::new("pdf-marks"), |outline, (at, flat)| outline.item(at.to_string(), flat.title, flat.level)) + .on_select(move |key, _, cx| { + let Ok(at) = key.parse::() else { + return; + }; + view.update(cx, |view, cx| { + view.mark_pick = Some(at); + match targets.get(at).copied().flatten() { + Some((page, y)) => view.show(page, y, cx), + None => cx.notify(), + } + }); + }); + if let Some(at) = picked { + outline = outline.current(at.to_string()); + } + let item = |key: &str, action: Box| { + PopupItem::new(Viewer::text(cx, key, &[])).on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx)) + }; + let popup = Popup::new().item(item("pdf.mark.rename", Box::new(RenameBookmark))).item(item("pdf.mark.delete", Box::new(DeleteBookmark))); + ContextMenu::new("pdf-marks-menu", popup).size_full().child(div().p_2().child(outline)).into_any_element() +} + +impl Viewer { + /// Asks a title for a new or picked bookmark. + pub fn ask_bookmark(&mut self, add: bool, window: &mut Window, cx: &mut Context) { + let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else { + return self.no_document(cx); + }; + let read = view.read(cx); + let picked = read.picked_mark(); + let (title, expect, path, page) = match (add, picked) { + (true, picked) => { + let n = (read.current + 1).to_string(); + (Self::text(cx, "pdf.page.label", &[("n", &n)]).to_string(), None, place_for(&read.marks, picked.as_ref()), read.current) + } + (false, Some(flat)) => (flat.title.clone(), Some(flat.title), flat.path, read.current), + (false, None) => return, + }; + let version = read.version; + let field = cx.new(|cx| { + let mut field = TextInput::new(window, cx); + field.set_text(title, cx); + field + }); + self.naming = Some(Naming { tab, version, field, expect, path, page }); + cx.notify(); + } + + pub(crate) fn name_bookmark(&mut self, title: String, cx: &mut Context) { + let Some(Naming { tab, version, expect, path, page, .. }) = self.naming.take() else { + return; + }; + cx.notify(); + let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else { + return; + }; + let change = match expect { + None => BookmarkChange::Add { path, title, page, y: 0.0 }, + Some(expect) => BookmarkChange::Edit { path, expect, title: Some(title), to: None }, + }; + self.on_same(&view, version, cx, |view, cx| { + view.edit("changing bookmarks", move |worker, doc| worker.change_bookmark(doc, &change), cx) + }); + } + + pub fn delete_bookmark(&mut self, cx: &mut Context) { + let Some(view) = self.active_view() else { + return self.no_document(cx); + }; + let (picked, version) = (view.read(cx).picked_mark(), view.read(cx).version); + let Some(flat) = picked else { + return; + }; + let change = BookmarkChange::Delete { path: flat.path, expect: flat.title }; + self.on_same(&view, version, cx, |view, cx| { + view.mark_pick = None; + view.edit("deleting a bookmark", move |worker, doc| worker.change_bookmark(doc, &change), cx) + }); + } + + /// The bookmark title prompt, while open. + pub fn naming_element(&self, cx: &mut Context) -> Option { + let naming = self.naming.as_ref()?; + let (closed, sent) = (cx.entity(), cx.entity()); + let key = if naming.expect.is_some() { "pdf.mark.rename" } else { "pdf.mark.add" }; + let prompt = PromptDialog::new("pdf-mark-name", Self::text(cx, key, &[]), &naming.field, move |_, cx| { + closed.update(cx, |viewer, cx| { + viewer.naming = None; + cx.notify(); + }) + }) + .label(Self::text(cx, "pdf.mark.title", &[])) + .submit(Self::text(cx, "pdf.text.apply", &[])) + .check({ + let empty = Self::text(cx, "pdf.mark.empty", &[]); + move |title| if title.trim().is_empty() { Err(empty.clone()) } else { Ok(()) } + }) + .on_submit(move |title, _, cx| sent.update(cx, |viewer, cx| viewer.name_bookmark(title.trim().to_string(), cx))); + Some(prompt.into_any_element()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mark(title: &str, page: Option, down: Vec) -> Bookmark { + Bookmark { title: title.into(), page, y: 0.0, down } + } + + #[test] + fn bookmarks_flatten_with_levels_and_paths() { + let marks = [mark("A", Some(0), vec![mark("A1", Some(1), vec![])]), mark("Web", None, vec![])]; + let mut out = Vec::new(); + flat(&marks, &[], &mut out); + let short: Vec<(&str, usize, Vec)> = out.iter().map(|f| (f.title.as_str(), f.level, f.path.clone())).collect(); + assert_eq!(short, [("A", 1, vec![0]), ("A1", 2, vec![0, 0]), ("Web", 1, vec![1])]); + assert_eq!(place_for(&marks, Some(&out[1])), [0, 1], "after the picked one"); + assert_eq!(place_for(&marks, None), [2], "else last"); + } +} diff --git a/apps/ely-pdf/src/notelist.rs b/apps/ely-pdf/src/notelist.rs new file mode 100644 index 0000000..dc29648 --- /dev/null +++ b/apps/ely-pdf/src/notelist.rs @@ -0,0 +1,86 @@ +//! The annotation list in the side panel (PDF-05). + +use ely_gpui_component::{ + feedback::EmptyState, + lists::{ListItem, SelectableList}, + primitives::IconName, +}; +use gpui::{ + AnyElement, App, Entity, InteractiveElement as _, IntoElement, ParentElement as _, StatefulInteractiveElement as _, + Styled as _, div, +}; + +use crate::annotate::Pick; +use crate::content::Aim; +use crate::notes::kind_label; +use crate::view::PdfView; +use crate::viewer::Viewer; + +fn key(page: usize, id: u64) -> String { + format!("{page}:{id}") +} + +fn parse(key: &str) -> Option<(usize, u64)> { + let (page, id) = key.split_once(':')?; + Some((page.parse().ok()?, id.parse().ok()?)) +} + +/// Every annotation, page by page; unread pages are asked. +pub fn list(view: &Entity, cx: &mut App) -> AnyElement { + let count = view.read(cx).sizes.len(); + view.update(cx, |view, cx| view.ask_notes(0..count, cx)); + let read = view.read(cx); + let selected = match read.aim { + Some(Aim::Note(Pick::Annot { page, id })) => Some(key(page, id)), + _ => None, + }; + let mut rows = Vec::new(); + for (page, marks) in (0..count).filter_map(|page| Some((page, read.notes.get(&page)?))) { + for annot in &marks.annots { + let place = Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]); + let mut item = ListItem::new(("pdf-note", annot.id as usize), kind_label(cx, &annot.kind)).detail(place); + if let Some(first) = annot.contents.lines().find(|line| !line.trim().is_empty()) { + item = item.description(first.to_string()); + } + rows.push((key(page, annot.id), item)); + } + } + let all_read = (0..count).all(|page| read.notes.contains_key(&page)); + if rows.is_empty() && all_read { + return EmptyState::new("pdf-notes-none", IconName::File, Viewer::text(cx, "pdf.notes.none", &[])).into_any_element(); + } + let total = rows.len(); + let picked = view.clone(); + let list = (rows.into_iter().enumerate()) + .fold(SelectableList::new("pdf-notes"), |list, (at, (key, item))| list.row(key, item.place(at + 1, total))) + .selected(selected) + .label(Viewer::text(cx, "pdf.side.notes", &[])) + .on_change(move |keys, _, cx| { + let Some((page, id)) = keys.first().and_then(|key| parse(key)) else { + return; + }; + picked.update(cx, |view, cx| view.pick_note(page, id, cx)) + }); + div().id("pdf-notes-scroll").size_full().overflow_y_scroll().child(list).into_any_element() +} + +impl PdfView { + /// Picks annotation `id` and shows where it is. + pub fn pick_note(&mut self, page: usize, id: u64, cx: &mut gpui::Context) { + // Gone with an edit since the list was drawn. + let Some(top) = self.notes.get(&page).and_then(|m| m.annots.iter().find(|a| a.id == id)).map(|a| a.rect[1]) else { + return; + }; + self.aim = Some(Aim::Note(Pick::Annot { page, id })); + self.show(page, top, cx); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn row_keys_name_page_and_object() { + assert_eq!(super::parse(&super::key(3, 41)), Some((3, 41))); + assert_eq!(super::parse("3"), None); + } +} diff --git a/apps/ely-pdf/src/notes.rs b/apps/ely-pdf/src/notes.rs new file mode 100644 index 0000000..307e6c8 --- /dev/null +++ b/apps/ely-pdf/src/notes.rs @@ -0,0 +1,320 @@ +//! The note panel: annotations, notes, stamps, fields (PDF-05, PDF-06). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Choice, ColorPalette, Input, Select, TextInput}, + theme::ControlSize, +}; +use ely_office::Tabs as _; +use ely_pdf_engine::{Annot, Field, FieldKind}; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, Hsla, IntoElement, ParentElement as _, Render, SharedString, + Styled as _, WeakEntity, Window, div, rgb, +}; + +use crate::annotate::Pick; +use crate::content::{Aim, Tool}; +use crate::marking::Change; +use crate::viewer::Viewer; + +/// The standard stamps, by PDF name. +pub const STAMPS: [&str; 14] = [ + "Approved", + "AsIs", + "Confidential", + "Departmental", + "Draft", + "Experimental", + "Expired", + "Final", + "ForComment", + "ForPublicRelease", + "NotApproved", + "NotForPublicRelease", + "Sold", + "TopSecret", +]; + +/// Annotation kinds with a name of their own. +const KINDS: [&str; 15] = [ + "Highlight", "Underline", "StrikeOut", "Squiggly", "Text", "FreeText", "Square", "Circle", "Line", "Polygon", + "PolyLine", "Ink", "Stamp", "Caret", "FileAttachment", +]; + +/// An annotation kind's name; unknown kinds as written. +pub fn kind_label(cx: &App, kind: &str) -> SharedString { + match KINDS.contains(&kind) { + true => Viewer::text(cx, &format!("pdf.annot.{kind}"), &[]), + false => SharedString::from(kind.to_string()), + } +} + +fn label(cx: &App, key: &str) -> SharedString { + Viewer::text(cx, key, &[]) +} + +pub struct NotePanel { + viewer: WeakEntity, + /// The tab, view version and pick the fields show. + pub shown: Option<(u64, u64, Pick)>, + /// Contents, value or name; made when first drawn. + pub(crate) text: Option>, + /// A field's name, and a choice's options, one a line. + pub(crate) name: Option>, + pub(crate) options: Option>, + pub stamp: SharedString, +} + +impl NotePanel { + pub fn new(viewer: WeakEntity) -> Self { + NotePanel { viewer, shown: None, text: None, name: None, options: None, stamp: STAMPS[0].into() } + } + + /// What the view's notes say of `pick`. + fn found(view: &crate::view::PdfView, pick: Pick) -> (Option<&Annot>, Option<&Field>) { + match pick { + Pick::Annot { page, id } => (view.notes.get(&page).and_then(|m| m.annots.iter().find(|a| a.id == id)), None), + Pick::Field { page, id } => (None, view.notes.get(&page).and_then(|m| m.fields.iter().find(|f| f.id == id))), + _ => (None, None), + } + } + + /// Fills the inputs for what the view picks now. + fn follow(&mut self, aimed: (u64, u64, Pick), view: &Entity, cx: &mut Context) { + if self.shown == Some(aimed) { + return; + } + let view = view.read(cx); + let (annot, field) = Self::found(view, aimed.2); + let designing = matches!(view.tool, Tool::Field(_)); + let (text, name, options) = match aimed.2 { + Pick::Annot { .. } => (annot.map_or_else(String::new, |a| a.contents.clone()), String::new(), String::new()), + Pick::Field { .. } => match field { + Some(f) if designing => (String::new(), f.leaf().to_string(), f.options.join("\n")), + Some(f) => (f.value.clone(), String::new(), String::new()), + None => (String::new(), String::new(), String::new()), + }, + Pick::NewField { page, kind, .. } => (String::new(), suggested(view, page, kind), String::new()), + Pick::Link { page, index } => { + let link = view.links.get(&page).and_then(|links| links.get(index)); + (String::new(), link.map_or_else(String::new, crate::links::typed), String::new()) + } + Pick::NewNote { .. } | Pick::NewStamp { .. } | Pick::NewLink { .. } => (String::new(), String::new(), String::new()), + }; + // Inputs are named for assistive tech. + let text_name = match (aimed.2, field) { + (Pick::Field { .. }, Some(f)) => SharedString::from(f.name.clone()), + _ => label(cx, "pdf.note.contents"), + }; + (self.shown, self.stamp) = (Some(aimed), STAMPS[0].into()); + let name_name = match aimed.2 { + Pick::Link { .. } | Pick::NewLink { .. } => label(cx, "pdf.link.target"), + _ => label(cx, "pdf.field.name"), + }; + let named = [text_name, name_name, label(cx, "pdf.field.options")]; + let inputs = [(&self.text, text), (&self.name, name), (&self.options, options)]; + for ((input, value), named) in inputs.into_iter().zip(named) { + if let Some(input) = input { + input.update(cx, |input, cx| { + input.set_text(value, cx); + input.set_label(named, cx); + }); + } + } + } + + /// Asks the viewer to make `change` on what is shown. + fn send(this: &Entity, change: Change, cx: &mut App) { + let (Some(shown), Some(viewer)) = (this.read(cx).shown, this.read(cx).viewer.upgrade()) else { + return; + }; + viewer.update(cx, |viewer, cx| viewer.change_note(shown, change, cx)); + } + + fn typed(input: &Option>, cx: &App) -> String { + input.as_ref().map_or_else(String::new, |input| input.read(cx).text().to_string()) + } + + fn button(id: &'static str, text: SharedString, this: &Entity, make: fn(&NotePanel, &App) -> Change) -> Button { + let this = this.clone(); + Button::new(id, text).size(ControlSize::Sm).on_click(move |_, _, cx| { + let change = make(this.read(cx), cx); + Self::send(&this, change, cx); + }) + } +} + +/// A free name for a new field. +fn suggested(view: &crate::view::PdfView, page: usize, kind: FieldKind) -> String { + let on_page = view.notes.get(&page).map_or(&[][..], |m| m.fields.as_slice()); + // A new radio joins the page's last top group. + if kind == FieldKind::Radio + && let Some(last) = on_page.iter().rev().find(|f| f.kind == FieldKind::Radio && !f.name.contains('.')) + { + return last.name.clone(); + } + let stem = match kind { + FieldKind::Checkbox => "Check", + FieldKind::Radio => "Group", + FieldKind::Choice | FieldKind::List => "Choice", + _ => "Text", + }; + let taken = view.form_names.as_deref().expect("names are read first"); + (1..).map(|n| format!("{stem}{n}")).find(|name| !taken.contains(name)).expect("a free name") +} + +impl Render for NotePanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.text.is_none() { + self.text = Some(cx.new(|cx| TextInput::new(window, cx).multi_line(3, 12))); + self.name = Some(cx.new(|cx| TextInput::new(window, cx))); + self.options = Some(cx.new(|cx| TextInput::new(window, cx).multi_line(3, 8))); + } + let Some(viewer) = self.viewer.upgrade() else { + return div(); + }; + let Some((tab, view)) = viewer.read(cx).active_tab().map(|tab| (tab.id, tab.view.clone())) else { + return div(); + }; + let Some(Aim::Note(pick)) = view.read(cx).aim else { + return div(); + }; + // A new field's name is free in the whole form. + if matches!(pick, Pick::NewField { .. }) && view.read(cx).form_names.is_none() { + view.update(cx, |view, cx| view.ask_names(cx)); + return div(); + } + self.follow((tab, view.read(cx).version, pick), &view, cx); + let this = cx.entity(); + let designing = matches!(view.read(cx).tool, Tool::Field(_)); + let (annot, field) = Self::found(view.read(cx), pick); + let (annot, field) = (annot.cloned(), field.cloned()); + let body = match (pick, annot, field) { + (Pick::Annot { .. }, Some(annot), _) => self.annot_rows(&this, &annot, cx), + (Pick::Field { .. }, _, Some(field)) if designing => self.design_rows(&this, field.kind, cx), + (Pick::Field { .. }, _, Some(field)) => self.fill_rows(&this, &field, cx), + (Pick::NewField { kind, .. }, _, _) => self.design_rows(&this, kind, cx), + (Pick::Link { .. } | Pick::NewLink { .. }, _, _) => self.link_rows(&this, matches!(pick, Pick::NewLink { .. }), cx), + (Pick::NewNote { .. }, _, _) => div() + .flex() + .flex_col() + .gap_3() + .children(self.text.as_ref().map(Input::new)) + .child(div().flex().child(Self::button("pdf-note-add", label(cx, "pdf.note.add"), &this, |p, cx| Change::Note(Self::typed(&p.text, cx))).variant(ButtonVariant::Primary))) + .into_any_element(), + (Pick::NewStamp { .. }, _, _) => { + let picked = this.clone(); + let stamps = STAMPS.map(|name| Choice::new(name, label(cx, &format!("pdf.stamp.{name}")))); + div() + .flex() + .flex_col() + .gap_3() + .child( + Select::new("pdf-stamp", stamps.to_vec()) + .selected(self.stamp.clone()) + .label(label(cx, "pdf.tool.stamp")) + .on_change(move |value, _, cx| picked.update(cx, |panel, cx| { + panel.stamp = value.clone(); + cx.notify(); + })), + ) + .child(div().flex().child(Self::button("pdf-stamp-add", label(cx, "pdf.note.add"), &this, |p, _| Change::Stamp(p.stamp.to_string())).variant(ButtonVariant::Primary))) + .into_any_element() + } + _ => div().into_any_element(), + }; + div().p_3().flex().flex_col().gap_3().child(body) + } +} + +impl NotePanel { + fn annot_rows(&self, this: &Entity, annot: &Annot, cx: &mut Context) -> AnyElement { + let swatches = ely_office::panel::COLORS.map(|color| (format!("#{color:06X}"), Hsla::from(rgb(color)))); + let painted = this.clone(); + let mut color = ColorPalette::new("pdf-note-color", swatches.clone()).columns(5).on_change(move |color, _, cx| { + let at = swatches.iter().position(|(_, swatch)| *swatch == color).expect("the palette hands back its own colors"); + Self::send(&painted, Change::Color(ely_office::panel::COLORS[at]), cx); + }); + if let Some(now) = annot.rgb.filter(|rgb| ely_office::panel::COLORS.contains(rgb)) { + color = color.selected(rgb(now)); + } + let mut rows = DescriptionList::new().item(label(cx, "pdf.note.kind"), kind_label(cx, &annot.kind)); + if !annot.author.is_empty() { + rows = rows.item(label(cx, "pdf.note.author"), annot.author.clone()); + } + div() + .flex() + .flex_col() + .gap_3() + .child(rows.item(label(cx, "pdf.text.color"), color)) + .children(self.text.as_ref().map(Input::new)) + .child(self.actions(this, ("pdf-note-apply", "pdf-note-delete"), |p, cx| Change::Contents(Self::typed(&p.text, cx)), cx)) + .into_any_element() + } + + /// Name and options of a field, new or picked. + fn design_rows(&self, this: &Entity, kind: FieldKind, cx: &mut Context) -> AnyElement { + let choice = matches!(kind, FieldKind::Choice | FieldKind::List); + let make = |p: &NotePanel, cx: &App| { + let options: Vec = Self::typed(&p.options, cx).lines().map(str::trim).filter(|o| !o.is_empty()).map(String::from).collect(); + Change::Design(Self::typed(&p.name, cx).trim().to_string(), options) + }; + let new = matches!(self.shown, Some((_, _, Pick::NewField { .. }))); + let buttons = match new { + true => div().flex().child(Self::button("pdf-field-add", label(cx, "pdf.note.add"), this, make).variant(ButtonVariant::Primary)), + false => self.actions(this, ("pdf-field-apply", "pdf-field-delete"), make, cx), + }; + let mut rows = DescriptionList::new(); + if let Some(name) = &self.name { + rows = rows.item(label(cx, "pdf.field.name"), div().w_full().child(Input::new(name))); + } + if let Some(options) = self.options.as_ref().filter(|_| choice) { + rows = rows.item(label(cx, "pdf.field.options"), div().w_full().child(Input::new(options))); + } + div().flex().flex_col().gap_3().child(rows).child(buttons).into_any_element() + } + + /// A field's value: typed, or picked from its options. + fn fill_rows(&self, this: &Entity, field: &Field, cx: &mut Context) -> AnyElement { + if field.kind == FieldKind::Text { + let apply = Self::button("pdf-fill-apply", label(cx, "pdf.text.apply"), this, |p, cx| Change::Fill(Self::typed(&p.text, cx))); + return div() + .flex() + .flex_col() + .gap_3() + .child(div().text_sm().child(field.name.clone())) + .children(self.text.as_ref().map(Input::new)) + .child(div().flex().child(apply.variant(ButtonVariant::Primary))) + .into_any_element(); + } + let choices: Vec = (field.options.iter().zip(&field.labels)).map(|(value, shown)| Choice::new(value.clone(), shown.clone())).collect(); + let picked = this.clone(); + Select::new("pdf-fill-choice", choices) + .selected(SharedString::from(field.value.clone())) + .label(SharedString::from(field.name.clone())) + .on_change(move |value, _, cx| Self::send(&picked, Change::Fill(value.to_string()), cx)) + .into_any_element() + } + + /// A link's target: a page number or an address. + fn link_rows(&self, this: &Entity, new: bool, cx: &mut Context) -> AnyElement { + let make = |p: &NotePanel, cx: &App| Change::Target(Self::typed(&p.name, cx)); + let buttons = match new { + true => div().flex().child(Self::button("pdf-link-add", label(cx, "pdf.note.add"), this, make).variant(ButtonVariant::Primary)), + false => self.actions(this, ("pdf-link-apply", "pdf-link-delete"), make, cx), + }; + let mut rows = DescriptionList::new(); + if let Some(name) = &self.name { + rows = rows.item(label(cx, "pdf.link.target"), div().w_full().child(Input::new(name))); + } + div().flex().flex_col().gap_3().child(rows).child(buttons).into_any_element() + } + + /// Apply and Delete for what is picked. + fn actions(&self, this: &Entity, (apply, delete): (&'static str, &'static str), make: fn(&NotePanel, &App) -> Change, cx: &App) -> gpui::Div { + let apply = Self::button(apply, label(cx, "pdf.text.apply"), this, make); + let delete = Self::button(delete, label(cx, "pdf.object.delete"), this, |_, _| Change::Delete); + div().flex().gap_2().child(apply.variant(ButtonVariant::Primary)).child(delete.variant(ButtonVariant::Danger)) + } +} diff --git a/apps/ely-pdf/src/ocr.rs b/apps/ely-pdf/src/ocr.rs new file mode 100644 index 0000000..008785f --- /dev/null +++ b/apps/ely-pdf/src/ocr.rs @@ -0,0 +1,286 @@ +//! OCR: asked, then read page by page (PDF-08). + +use std::path::PathBuf; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + data_display::DescriptionList, + forms::{CheckboxGroup, Choice, NumberInput, RadioGroup}, + motion::ProgressBar, + overlays::Dialog as Card, + primitives::Severity, +}; +use ely_office::{Closing as _, Tabs as _}; +use ely_pdf_engine::OcrLine; +use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Window, div}; + +use crate::viewer::Viewer; + +/// Pixels an inch Tesseract reads at. +const DPI: f32 = 300.0; +/// The languages, by Tesseract name and label key. +const LANGS: [(&str, &str); 3] = [("eng", "pdf.ocr.eng"), ("chi_sim", "pdf.ocr.sim"), ("chi_tra", "pdf.ocr.tra")]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scope { + All, + Current, + Range, +} + +/// What was asked, and the reading under way. +pub struct Ocr { + pub tab: u64, + pub scope: Scope, + /// Pages from one. + pub from: usize, + pub to: usize, + pub langs: Vec, + /// Read pages with text again, dropping Ely's layer. + pub replace: bool, + pub run: Option, +} + +pub struct Run { + /// Counts runs; a page back from another is dropped. + id: u64, + version: u64, + pages: Vec, + at: usize, + read: Vec<(usize, Vec)>, + skipped: usize, + data: PathBuf, +} + +/// The pages `scope` names, from zero. +pub fn pages(scope: Scope, current: usize, (from, to): (usize, usize), count: usize) -> Vec { + match scope { + Scope::All => (0..count).collect(), + Scope::Current => vec![current], + Scope::Range => (from.max(1) - 1..to.min(count)).collect(), + } +} + +impl Viewer { + pub fn ask_ocr(&mut self, _: &mut Window, cx: &mut Context) { + let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else { + return self.no_document(cx); + }; + let count = view.read(cx).sizes.len(); + let langs = vec!["eng".into(), "chi_sim".into()]; + self.ocr = Some(Ocr { tab, scope: Scope::All, from: 1, to: count, langs, replace: false, run: None }); + cx.notify(); + } + + pub(crate) fn start_ocr(&mut self, cx: &mut Context) { + let Some(ocr) = self.ocr.as_mut() else { + return; + }; + let Some(view) = self.core.tabs.iter().find(|tab| tab.id == ocr.tab).map(|tab| tab.view.clone()) else { + return; + }; + let data = match ely_pdf_engine::tessdata() { + Ok(data) => data, + Err(error) => return self.failed(&error, cx), + }; + let read = view.read(cx); + let pages = pages(ocr.scope, read.current, (ocr.from, ocr.to), read.sizes.len()); + if pages.is_empty() || ocr.langs.is_empty() { + return; + } + log::info!("pdf: OCR of {} pages in {} from {}", pages.len(), ocr.langs.join("+"), data.display()); + self.ocr_runs += 1; + ocr.run = Some(Run { id: self.ocr_runs, version: read.version, pages, at: 0, read: Vec::new(), skipped: 0, data }); + self.ocr_step(cx); + } + + /// Reads the next page, or lays what was read. + fn ocr_step(&mut self, cx: &mut Context) { + let Some(Ocr { tab, langs, replace, run: Some(run), .. }) = self.ocr.as_ref() else { + return; + }; + let Some(view) = self.tab(*tab).map(|tab| tab.view.clone()) else { + self.ocr = None; + return cx.notify(); + }; + if view.read(cx).version != run.version { + self.ocr = None; + let text = Self::text(cx, "pdf.object.changed", &[]); + return self.toast(text, Severity::Warning, cx); + } + let Some(page) = run.pages.get(run.at).copied() else { + return self.lay_text(cx); + }; + let (langs, replace, data, tab, id) = (langs.join("+"), *replace, run.data.clone(), *tab, run.id); + let viewer = cx.weak_entity(); + view.update(cx, |view, cx| { + let doc = view.doc; + view.off( + move |worker| match !replace && !worker.text(doc, page)?.is_empty() { + true => Ok(None), + false => worker.ocr_page(doc, page, &langs, &data, DPI).map(Some), + }, + move |_, read, cx| { + cx.defer(move |cx| { + if let Some(viewer) = viewer.upgrade() { + viewer.update(cx, |viewer, cx| viewer.ocr_read((tab, id, page), read, cx)); + } + }) + }, + cx, + ); + }); + cx.notify(); + } + + /// One page back; a cancelled or other run drops it. + pub(crate) fn ocr_read(&mut self, (tab, id, page): (u64, u64, usize), read: std::io::Result>>, cx: &mut Context) { + let Some(run) = self.ocr.as_mut().filter(|ocr| ocr.tab == tab).and_then(|ocr| ocr.run.as_mut()) else { + return log::info!("pdf: an OCR page came back after its run"); + }; + if run.id != id || run.pages.get(run.at) != Some(&page) { + return log::info!("pdf: an OCR page came back to another run"); + } + match read { + Ok(Some(lines)) => run.read.push((page, lines)), + Ok(None) => run.skipped += 1, + Err(error) => { + self.ocr = None; + return self.failed(&error, cx); + } + } + run.at += 1; + self.ocr_step(cx); + } + + /// Lays every page read in hidden text, one step. + fn lay_text(&mut self, cx: &mut Context) { + let Some(Ocr { tab, replace, run: Some(run), .. }) = self.ocr.take() else { + return; + }; + cx.notify(); + let pages: Vec<(usize, Vec)> = run.read.into_iter().filter(|(_, lines)| replace || !lines.is_empty()).collect(); + let (read, skipped) = (pages.len().to_string(), run.skipped.to_string()); + let summary = Self::text(cx, "pdf.ocr.done", &[("read", &read), ("skipped", &skipped)]); + let Some(view) = self.tab(tab).map(|tab| tab.view.clone()).filter(|_| !pages.is_empty()) else { + return self.toast(summary, Severity::Info, cx); + }; + self.on_same(&view, run.version, cx, |view, cx| { + view.edit("adding recognised text", move |worker, doc| worker.text_layer(doc, &pages, replace), cx) + }); + self.toast(summary, Severity::Success, cx); + } + + /// The OCR dialog: the ask, then the progress. + pub fn ocr_element(&self, cx: &mut Context) -> Option { + let ocr = self.ocr.as_ref()?; + let this = cx.entity(); + let label = |key: &str| Self::text(cx, key, &[]); + let closed = this.clone(); + let dismiss = move |_: &mut Window, cx: &mut App| { + closed.update(cx, |viewer, cx| { + viewer.ocr = None; + cx.notify(); + }) + }; + let card = Card::new("pdf-ocr", label("pdf.ocr.title"), dismiss); + if let Some(run) = &ocr.run { + let (n, count) = ((run.at + 1).min(run.pages.len()).to_string(), run.pages.len().to_string()); + let progress = ProgressBar::new("pdf-ocr-progress", run.at as f32 / run.pages.len() as f32); + let card = card.detail(Self::text(cx, "pdf.ocr.reading", &[("n", &n), ("count", &count)])).child(progress); + return Some(card.action(ely_office::cancel(cx)).into_any_element()); + } + let go = label("pdf.ocr.go"); + let ready = !ocr.langs.is_empty(); + Some( + card.child(self.ocr_rows(ocr, &this, cx)) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("pdf-ocr-go", go) + .variant(ButtonVariant::Primary) + .disabled(!ready) + .on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.start_ocr(cx))) + }) + .into_any_element(), + ) + } + + fn ocr_rows(&self, ocr: &Ocr, this: &gpui::Entity, cx: &mut Context) -> AnyElement { + let label = |key: &str| Self::text(cx, key, &[]); + let change = |this: &gpui::Entity, change: fn(&mut Ocr, &str)| { + let this = this.clone(); + move |value: &SharedString, _: &mut Window, cx: &mut App| { + this.update(cx, |viewer, cx| { + if let Some(ocr) = viewer.ocr.as_mut() { + change(ocr, value); + } + cx.notify(); + }) + } + }; + let scope = match ocr.scope { + Scope::All => "all", + Scope::Current => "current", + Scope::Range => "range", + }; + let scopes = SegmentedControl::new("pdf-ocr-scope", scope) + .segment("all", label("pdf.ocr.all"), None) + .segment("current", label("pdf.ocr.current"), None) + .segment("range", label("pdf.ocr.range"), None) + .on_change(change(this, |ocr, value| { + ocr.scope = match value { + "current" => Scope::Current, + "range" => Scope::Range, + _ => Scope::All, + } + })); + let langs = CheckboxGroup::new("pdf-ocr-langs", LANGS.map(|(value, key)| Choice::new(value, label(key)))) + .selected(ocr.langs.clone()) + .on_change({ + let this = this.clone(); + move |values: &[SharedString], _: &mut Window, cx: &mut App| { + this.update(cx, |viewer, cx| { + if let Some(ocr) = viewer.ocr.as_mut() { + ocr.langs = values.to_vec(); + } + cx.notify(); + }) + } + }); + let policy = RadioGroup::new("pdf-ocr-policy", [Choice::new("skip", label("pdf.ocr.skip")), Choice::new("replace", label("pdf.ocr.replace"))]) + .selected(if ocr.replace { "replace" } else { "skip" }) + .on_change(change(this, |ocr, value| ocr.replace = value == "replace")); + let mut rows = DescriptionList::new().item(label("pdf.ocr.pages"), scopes); + if ocr.scope == Scope::Range { + let bound = |id: &'static str, value: usize, set: fn(&mut Ocr, usize)| { + let this = this.clone(); + NumberInput::new(id, value as f64).range(1.0, 100_000.0).on_change(move |value, _, cx| { + this.update(cx, |viewer, cx| { + if let Some(ocr) = viewer.ocr.as_mut() { + set(ocr, value.round() as usize); + } + cx.notify(); + }) + }) + }; + let from = bound("pdf-ocr-from", ocr.from, |ocr, value| ocr.from = value).label(label("pdf.ocr.from")); + let to = bound("pdf-ocr-to", ocr.to, |ocr, value| ocr.to = value).label(label("pdf.ocr.to")); + rows = rows.item(label("pdf.ocr.from"), from).item(label("pdf.ocr.to"), to); + } + let rows = rows.item(label("pdf.ocr.langs"), langs).item(label("pdf.ocr.policy"), policy); + div().child(rows).into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_scope_names_its_pages() { + assert_eq!(pages(Scope::All, 1, (1, 1), 3), [0, 1, 2]); + assert_eq!(pages(Scope::Current, 1, (1, 1), 3), [1]); + assert_eq!(pages(Scope::Range, 0, (2, 9), 4), [1, 2, 3], "the end stops at the last page"); + assert!(pages(Scope::Range, 0, (3, 2), 4).is_empty()); + } +} diff --git a/apps/ely-pdf/src/organize.rs b/apps/ely-pdf/src/organize.rs new file mode 100644 index 0000000..b861caa --- /dev/null +++ b/apps/ely-pdf/src/organize.rs @@ -0,0 +1,177 @@ +//! Page organizing: insert, delete, move, turn, undo (PDF-02). + +use std::path::PathBuf; + +use ely_gpui_component::primitives::Severity; +use ely_office::{Closing as _, Tabs as _}; +use gpui::{App, Context, Div, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions}; + +use crate::view::PdfView; +use crate::viewer::Viewer; + +actions!( + pdf, + [ + Undo, + Redo, + InsertBlankPage, + InsertPagesFromFile, + DeletePages, + RotatePagesLeft, + RotatePagesRight, + MovePagesUp, + MovePagesDown, + MovePagesFirst, + MovePagesLast, + CropPages, + ExtractPages, + SplitDocument + ] +); + +pub fn bind(cx: &mut App) { + let at = Some(ely_ui::commands::CONTEXT); + cx.bind_keys([ + KeyBinding::new("secondary-z", Undo, at), + KeyBinding::new("secondary-shift-z", Redo, at), + KeyBinding::new("backspace", DeletePages, Some(crate::side::PAGES)), + KeyBinding::new("delete", DeletePages, Some(crate::side::PAGES)), + ]); +} + +/// The Pages menu. +pub fn menu(cx: &App) -> Menu { + let label = |key: &str| Viewer::text(cx, key, &[]); + Menu::new(label("menu.pages")).items([ + MenuItem::action(label("pdf.pages.blank"), InsertBlankPage), + MenuItem::action(label("pdf.pages.file"), InsertPagesFromFile), + MenuItem::action(label("pdf.pages.delete"), DeletePages), + MenuItem::separator(), + MenuItem::action(label("pdf.pages.rotate_left"), RotatePagesLeft), + MenuItem::action(label("pdf.pages.rotate_right"), RotatePagesRight), + MenuItem::action(label("pdf.pages.crop"), CropPages), + MenuItem::separator(), + MenuItem::action(label("pdf.pages.up"), MovePagesUp), + MenuItem::action(label("pdf.pages.down"), MovePagesDown), + MenuItem::action(label("pdf.pages.first"), MovePagesFirst), + MenuItem::action(label("pdf.pages.last"), MovePagesLast), + MenuItem::separator(), + MenuItem::action(label("pdf.pages.extract"), ExtractPages), + MenuItem::action(label("pdf.pages.split"), SplitDocument), + ]) +} + +/// The Edit menu's undo and redo. +pub fn history_items(cx: &App) -> [MenuItem; 2] { + let label = |key: &str| Viewer::text(cx, key, &[]); + [MenuItem::action(label("pdf.undo"), Undo), MenuItem::action(label("pdf.redo"), Redo)] +} + +impl PdfView { + /// The pages an organizing step acts on: picked, else current. + pub fn chosen_pages(&self) -> Vec { + match self.picked.is_empty() { + true => vec![self.current], + false => self.picked.iter().copied().collect(), + } + } + + /// Moves the chosen pages before page `to`; they stay picked. + pub fn move_to(&mut self, to: usize, cx: &mut Context) { + let pages = self.chosen_pages(); + let before = pages.iter().filter(|page| **page < to).count(); + let landed = (to - before..to - before + pages.len()).collect(); + self.edit_picking("moving pages", Some(landed), move |worker, doc| worker.move_pages(doc, &pages, to), cx); + } +} + +impl Viewer { + /// Runs `step` on the active view; with none, says so. + fn on_pages(&mut self, cx: &mut Context, step: impl FnOnce(&mut PdfView, &mut Context)) { + match self.active_view() { + Some(view) => view.update(cx, step), + None => self.no_document(cx), + } + } + + pub(crate) fn delete(&mut self, cx: &mut Context) { + let Some(view) = self.active_view() else { + return self.no_document(cx); + }; + let (pages, count) = (view.read(cx).chosen_pages(), view.read(cx).sizes.len()); + if pages.len() == count { + let text = Self::text(cx, "pdf.pages.keep_one", &[]); + return self.toast(text, Severity::Warning, cx); + } + view.update(cx, |view, cx| { + view.picked.clear(); + view.edit("deleting pages", move |worker, doc| worker.delete_pages(doc, &pages), cx); + }); + } + + fn turn_pages(&mut self, by: i32, cx: &mut Context) { + self.on_pages(cx, |view, cx| { + let pages = view.chosen_pages(); + view.edit("turning pages", move |worker, doc| worker.rotate_pages(doc, &pages, by), cx); + }); + } + + /// Moves the chosen pages by `by`, or to an end. + fn shift(&mut self, by: isize, cx: &mut Context) { + self.on_pages(cx, |view, cx| { + let (pages, count) = (view.chosen_pages(), view.sizes.len() as isize); + let to = match by { + isize::MIN => 0, + isize::MAX => count, + -1 => pages[0] as isize - 1, + _ => *pages.last().expect("one page at least") as isize + 2, + }; + if (0..=count).contains(&to) { + view.move_to(to as usize, cx); + } + }); + } + + fn blank(&mut self, cx: &mut Context) { + self.on_pages(cx, |view, cx| { + let at = view.chosen_pages().last().copied().expect("one page at least"); + let size = view.sizes[at]; + view.edit("inserting a page", move |worker, doc| worker.insert_blank(doc, at + 1, size), cx); + }); + } + + /// Asks for a PDF; its pages follow the chosen. + fn insert_file(&mut self, cx: &mut Context) { + if self.active_view().is_none() { + return self.no_document(cx); + } + self.pick(false, cx, |viewer, path: PathBuf, cx| { + viewer.on_pages(cx, |view, cx| { + let at = view.chosen_pages().last().copied().expect("one page at least") + 1; + view.edit( + "inserting pages", + move |worker, doc| worker.insert_file(doc, at, &path).map(|(_, mark)| mark), + cx, + ); + }) + }); + } +} + +/// Wires the organizing actions onto the root. +pub fn on(root: Div, cx: &mut Context) -> Div { + root.on_action(cx.listener(|v, _: &Undo, _, cx| v.on_pages(cx, |view, cx| view.undo(false, cx)))) + .on_action(cx.listener(|v, _: &Redo, _, cx| v.on_pages(cx, |view, cx| view.undo(true, cx)))) + .on_action(cx.listener(|v, _: &DeletePages, _, cx| v.delete(cx))) + .on_action(cx.listener(|v, _: &RotatePagesLeft, _, cx| v.turn_pages(-90, cx))) + .on_action(cx.listener(|v, _: &RotatePagesRight, _, cx| v.turn_pages(90, cx))) + .on_action(cx.listener(|v, _: &MovePagesUp, _, cx| v.shift(-1, cx))) + .on_action(cx.listener(|v, _: &MovePagesDown, _, cx| v.shift(1, cx))) + .on_action(cx.listener(|v, _: &MovePagesFirst, _, cx| v.shift(isize::MIN, cx))) + .on_action(cx.listener(|v, _: &MovePagesLast, _, cx| v.shift(isize::MAX, cx))) + .on_action(cx.listener(|v, _: &InsertBlankPage, _, cx| v.blank(cx))) + .on_action(cx.listener(|v, _: &InsertPagesFromFile, _, cx| v.insert_file(cx))) + .on_action(cx.listener(|v, _: &CropPages, window, cx| v.ask_crop(window, cx))) + .on_action(cx.listener(|v, _: &ExtractPages, _, cx| v.extract(cx))) + .on_action(cx.listener(|v, _: &SplitDocument, window, cx| v.ask_split(window, cx))) +} diff --git a/apps/ely-pdf/src/output.rs b/apps/ely-pdf/src/output.rs new file mode 100644 index 0000000..49b40ee --- /dev/null +++ b/apps/ely-pdf/src/output.rs @@ -0,0 +1,373 @@ +//! Export, print, and the repaired copy's report (PDF-13, PDF-14). + +use std::{io, path::PathBuf}; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + data_display::DescriptionList, + feedback::Banner, + forms::{Checkbox, NumberInput}, + overlays::Dialog as Card, + primitives::Severity, + theme::ControlSize, +}; +use ely_office::{Chrome as _, Closing as _, Saving as _, Stopping as _, Tabs as _, in_window}; +use ely_pdf_engine::Optimizing; +use ely_ui::commands::{Export, Print}; +use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, TaskExt as _, Window, div}; + +use crate::view::{PdfView, Reported}; +use crate::viewer::Viewer; + +impl PdfView { + /// A draw met damage; read what MuPDF has said. + pub fn found_damage(&mut self, cx: &mut Context) { + let doc = self.doc; + self.off( + move |worker| worker.repair_report(doc), + |view, read, cx| match read { + Ok(report) => { + (view.repair, view.damage_seen) = (report, true); + cx.emit(Reported); + } + Err(error) => view.fail("reading the damage report", &error, cx), + }, + cx, + ); + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Images, + Text, + Optimized, +} + +/// What the export dialog asks for. +pub struct Exporting { + tab: u64, + pub kind: Kind, + dpi: u32, + /// Every page, or the picked ones. + all: bool, + pub how: Optimizing, +} + +/// `stem-N.png` for each page, numbers padded alike. +pub fn image_names(stem: &str, pages: &[usize], count: usize) -> Vec { + let width = count.to_string().len(); + pages.iter().map(|page| format!("{stem}-{:0width$}.png", page + 1)).collect() +} + +fn stem(name: &str) -> String { + name.rsplit_once('.').map_or(name, |(stem, _)| stem).to_string() +} + +impl Viewer { + pub fn on_export(&mut self, _: &Export, _: &mut Window, cx: &mut Context) { + let Some(tab) = self.core.active else { + return self.no_document(cx); + }; + let how = Optimizing { compress: true, from: 300, to: 150 }; + self.exporting = Some(Exporting { tab, kind: Kind::Images, dpi: 150, all: true, how }); + cx.notify(); + } + + pub fn on_print(&mut self, _: &Print, _: &mut Window, cx: &mut Context) { + let Some(tab) = self.core.active else { + return self.no_document(cx); + }; + self.print(tab, cx); + } + + fn print(&mut self, id: u64, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |viewer, _, cx| viewer.print(id, cx)); + } + let Some(tab) = self.tab(id) else { + return; + }; + let printing = tab.work.join("print.pdf"); + let out = printing.clone(); + log::info!("pdf: prints"); + self.read_in( + id, + move |worker, doc| worker.get()?.print_copy(*doc, &out), + move |viewer, done, _, cx| match done { + Ok(()) => ely_office::print_later(printing, cx, |viewer, error, cx| viewer.panel_failed(&error, cx)), + Err(error) => viewer.failed(&error, cx), + }, + cx, + ); + } + + /// Asks where, then writes what the dialog asks for. + fn choose_export(&mut self, cx: &mut Context) { + let Some(asked) = self.exporting.take() else { + return; + }; + cx.notify(); + let (Some(tab), Some(view)) = (self.tab(asked.tab), self.tab(asked.tab).map(|tab| tab.view.clone())) else { + return; + }; + let Some(dir) = ely_office::folder(tab) else { + return self.panel_failed("no documents folder", cx); + }; + let (id, stem) = (asked.tab, stem(&tab.name)); + if asked.kind == Kind::Images { + let read = view.read(cx); + let pages = if asked.all { (0..read.sizes.len()).collect() } else { read.chosen_pages() }; + let names = image_names(&stem, &pages, read.sizes.len()); + let (dpi, version) = (asked.dpi as f32, read.version); + return self.pick(true, cx, move |viewer, folder, cx| { + let targets = names.iter().map(|name| folder.join(name)).collect(); + viewer.write_images(id, (pages, version), dpi, targets, cx) + }); + } + let name = match asked.kind { + Kind::Text => format!("{stem}.txt"), + _ => format!("{stem}-{}.pdf", Self::text(cx, "pdf.export.tail", &[])), + }; + let chosen = cx.prompt_for_new_path(&dir, Some(&name)); + cx.spawn(async move |this, cx| { + let chosen = match chosen.await { + Ok(Ok(path)) => Ok(path), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + in_window(&this, cx, |viewer, _, cx| match chosen { + Ok(Some(target)) => viewer.write_export(id, asked.kind, asked.how, target, cx), + Ok(None) => log::info!("pdf: the export panel was cancelled"), + Err(error) => viewer.panel_failed(&error, cx), + }) + }) + .detach_and_log_err(cx); + } + + /// Page numbers hold for `version` only; an edit since refuses. + pub(crate) fn write_images(&mut self, id: u64, (pages, version): (Vec, u64), dpi: f32, targets: Vec, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |viewer, _, cx| viewer.write_images(id, (pages, version), dpi, targets, cx)); + } + let Some(tab) = self.tab(id) else { + return; + }; + if tab.view.read(cx).version != version { + return self.toast(Self::text(cx, "pdf.object.changed", &[]), Severity::Warning, cx); + } + let work = tab.work.join("page.png"); + let folder = targets.first().and_then(|target| target.parent()).map(PathBuf::from); + log::info!("pdf: exports {} page images", pages.len()); + self.read_in( + id, + move |worker, doc| { + let claimed = ely_files::claim_all(&targets)?; + let written = pages.iter().zip(claimed).try_for_each(|(page, file)| { + worker.get()?.export_image(*doc, *page, dpi, &work)?; + crate::ask::copy_into(&work, file) + }); + // A failure leaves none of this run's files. + if let Err(error) = &written + && let Err(left) = targets.iter().try_for_each(std::fs::remove_file) + { + return Err(io::Error::other(format!("{error}; then {left}"))); + } + written.map(|()| targets.len()) + }, + move |viewer, done, _, cx| match done { + Ok(count) => { + let text = Self::text(cx, "pdf.export.images_done", &[("count", &count.to_string())]); + match folder.clone() { + Some(folder) => viewer.toast_output(text, folder, cx), + None => viewer.toast(text, Severity::Success, cx), + } + } + Err(error) => viewer.failed(&error, cx), + }, + cx, + ); + } + + pub(crate) fn write_export(&mut self, id: u64, kind: Kind, how: Optimizing, target: PathBuf, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |viewer, _, cx| viewer.write_export(id, kind, how, target, cx)); + } + let Some(tab) = self.tab(id) else { + return; + }; + let shown = ely_office::files::file_name(&target); + match self.holder(&target) { + Ok(None) => {} + Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &shown)]), Severity::Danger, cx), + Err(error) => return self.failed(&error, cx), + } + let (work, path, revision) = (tab.work.join("export.out"), tab.session.path().map(PathBuf::from), tab.session.revision()); + let opened = target.clone(); + log::info!("pdf: exports {}", if kind == Kind::Text { "text" } else { "an optimized copy" }); + self.read_in( + id, + move |worker, doc| { + match kind { + Kind::Text => worker.get()?.export_text(*doc, &work)?, + _ => worker.get()?.optimize(*doc, &work, how)?, + } + let bytes = std::fs::read(&work)?; + std::fs::remove_file(&work)?; + let before = path.as_deref().map(std::fs::metadata).transpose()?.map(|meta| meta.len()); + ely_files::export(&target, path.as_deref(), &bytes, revision)?; + Ok((before, bytes.len() as u64)) + }, + move |viewer, done, _, cx| match done { + Ok((Some(before), after)) if kind == Kind::Optimized => { + let sizes = [("name", shown.as_ref()), ("before", &crate::attach::size(before)), ("after", &crate::attach::size(after))]; + viewer.toast_output(Self::text(cx, "pdf.export.optimized_done", &sizes), opened.clone(), cx) + } + Ok(_) => viewer.toast_output(Self::text(cx, "pdf.export.done", &[("name", &shown)]), opened.clone(), cx), + Err(error) => viewer.failed(&error, cx), + }, + cx, + ); + } + + /// The shell's warning, with its details a press away. + pub fn file_banner(&self, cx: &mut Context) -> Option { + if let Some(stopped) = self.stopped_banner(cx) { + return Some(stopped); + } + let banner = self.risk_banner(cx)?; + let (key, action): (&str, Box) = match self.active_tab()?.risk? { + ely_office::Risk::Signed => ("pdf.sign.show", Box::new(crate::document::ShowSignatures)), + ely_office::Risk::Repaired => ("pdf.repair.show", Box::new(crate::document::ShowRepairReport)), + ely_office::Risk::Macros => return Some(banner), + }; + let label = Self::text(cx, key, &[]); + Some(banner.action(label, move |window, cx| window.dispatch_action(action.boxed_clone(), cx))) + } + + pub fn show_repair(&mut self, open: bool, cx: &mut Context) { + self.repair_open = open; + cx.notify(); + } + + /// MuPDF's words on what it found broken. + pub fn repair_element(&self, cx: &mut Context) -> Option { + let report = self.active_view().filter(|_| self.repair_open)?.read(cx).repair.clone()?; + let name = self.active_tab()?.name.clone(); + let this = cx.entity(); + let dismiss = move |_: &mut Window, cx: &mut App| this.update(cx, |viewer, cx| viewer.show_repair(false, cx)); + let lines = report.lines().map(|line| div().child(line.to_string())).collect::>(); + let done = Self::text(cx, "pdf.sign.close", &[]); + let close = move |close: ely_gpui_component::overlays::Close| { + Button::new("pdf-repair-close", done).variant(ButtonVariant::Primary).on_click(move |_, window, cx| close(window, cx)) + }; + let card = Card::new("pdf-repair", Self::text(cx, "pdf.repair.title", &[("name", &name)]), dismiss) + .detail(Self::text(cx, "pdf.repair.detail", &[])) + .child(div().text_sm().max_h(gpui::px(320.0)).overflow_hidden().children(lines)) + .action(close); + Some(card.into_any_element()) + } + + fn set_exporting(&mut self, cx: &mut Context, change: impl FnOnce(&mut Exporting)) { + if let Some(exporting) = self.exporting.as_mut() { + change(exporting); + } + cx.notify(); + } + + /// The export dialog, while open. + pub fn export_element(&self, cx: &mut Context) -> Option { + let asked = self.exporting.as_ref()?; + let optimized = (asked.kind == Kind::Optimized).then(|| self.optimize_rows(asked.how, cx)); + let this = cx.entity(); + let label = |key: &str| Self::text(cx, key, &[]); + let segments = |id: &'static str, selected: &str, choices: &[(&str, &str)], set: fn(&mut Exporting, &str)| { + let changed = this.clone(); + choices + .iter() + .fold(SegmentedControl::new(id, selected.to_string()).size(ControlSize::Sm), |control, (value, key)| { + control.segment(value.to_string(), Self::text(cx, key, &[]), None) + }) + .on_change(move |value, _, cx| { + let value = value.to_string(); + changed.update(cx, |viewer, cx| viewer.set_exporting(cx, |asked| set(asked, &value))) + }) + }; + let kind = match asked.kind { + Kind::Images => "images", + Kind::Text => "text", + Kind::Optimized => "optimized", + }; + let kinds = [("images", "pdf.export.images"), ("text", "pdf.export.text"), ("optimized", "pdf.export.optimized")]; + let mut body = div().flex().flex_col().gap_3().child(segments("pdf-export-kind", kind, &kinds, |asked, value| { + asked.kind = match value { + "text" => Kind::Text, + "optimized" => Kind::Optimized, + _ => Kind::Images, + } + })); + match asked.kind { + Kind::Images => { + let dpi = asked.dpi.to_string(); + let dpis = [("72", "pdf.export.dpi72"), ("150", "pdf.export.dpi150"), ("300", "pdf.export.dpi300")]; + let pages = if asked.all { "all" } else { "picked" }; + let rows = DescriptionList::new() + .item(label("pdf.export.resolution"), segments("pdf-export-dpi", &dpi, &dpis, |asked, value| asked.dpi = value.parse().expect("a listed dpi"))) + .item( + label("pdf.export.pages"), + segments("pdf-export-pages", pages, &[("all", "pdf.export.all"), ("picked", "pdf.export.picked")], |asked, value| asked.all = value == "all"), + ); + body = body.child(rows); + } + Kind::Text => {} + Kind::Optimized => body = body.children(optimized), + } + let closed = this.clone(); + let dismiss = move |_: &mut Window, cx: &mut App| { + closed.update(cx, |viewer, cx| { + viewer.exporting = None; + cx.notify(); + }) + }; + let go = label("pdf.export.go"); + // MuPDF only shrinks pictures; `to` must stay below `from`. + let ready = !(asked.kind == Kind::Optimized && asked.how.to > 0 && asked.how.from <= asked.how.to); + let card = Card::new("pdf-export", label("pdf.export.title"), dismiss).child(body).action(ely_office::cancel(cx)).action(move |_| { + Button::new("pdf-export-go", go) + .variant(ButtonVariant::Primary) + .disabled(!ready) + .on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.choose_export(cx))) + }); + Some(card.into_any_element()) + } + + fn optimize_rows(&self, how: Optimizing, cx: &mut Context) -> AnyElement { + let this = cx.entity(); + let set = |change: fn(&mut Optimizing, f64)| { + let this = this.clone(); + move |value: f64, _: &mut Window, cx: &mut App| this.update(cx, |viewer, cx| viewer.set_exporting(cx, |asked| change(&mut asked.how, value))) + }; + let toggled = |change: fn(&mut Optimizing, bool)| { + let this = this.clone(); + move |on: bool, _: &mut Window, cx: &mut App| this.update(cx, |viewer, cx| viewer.set_exporting(cx, |asked| change(&mut asked.how, on))) + }; + let shrink = how.to > 0; + let mut rows = div().flex().flex_col().gap_2().child( + Checkbox::new("pdf-export-compress", how.compress).label(Self::text(cx, "pdf.export.compress", &[])).on_change(toggled(|how, on| how.compress = on)), + ); + rows = rows.child( + Checkbox::new("pdf-export-shrink", shrink) + .label(Self::text(cx, "pdf.export.shrink", &[])) + .on_change(toggled(|how, on| (how.from, how.to) = if on { (300, 150) } else { (300, 0) })), + ); + if shrink { + let from = NumberInput::new("pdf-export-from", f64::from(how.from)).range(72.0, 2400.0).suffix("dpi").label(Self::text(cx, "pdf.export.above", &[])); + let to = NumberInput::new("pdf-export-to", f64::from(how.to)).range(36.0, 1200.0).suffix("dpi").label(Self::text(cx, "pdf.export.to", &[])); + let list = DescriptionList::new() + .item(Self::text(cx, "pdf.export.above", &[]), from.on_change(set(|how, value| how.from = value.round() as u32))) + .item(Self::text(cx, "pdf.export.to", &[]), to.on_change(set(|how, value| how.to = value.round() as u32))); + rows = rows.child(list); + } + rows.into_any_element() + } +} diff --git a/apps/ely-pdf/src/pages.rs b/apps/ely-pdf/src/pages.rs new file mode 100644 index 0000000..6b92151 --- /dev/null +++ b/apps/ely-pdf/src/pages.rs @@ -0,0 +1,327 @@ +//! Only the pages on screen, drawn in tiles (PDF-01, PERF-05). + +use std::{collections::HashSet, sync::Arc}; + +use ely_gpui_component::theme::ActiveTheme as _; +use gpui::{ + AnyElement, Context, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, + Pixels, Point, RenderImage, StatefulInteractiveElement as _, Styled as _, Window, canvas, div, + img, point, px, +}; +use image::{Frame, ImageBuffer, Rgba}; + +use crate::layout::{GAP, Layout, Mode, Placed, layout, visible}; +use crate::view::{PdfView, Reported}; + +/// A tile's side in device pixels. +pub const TILE: i32 = 512; + +/// A page tile at one scale, turn and theme. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TileKey { + pub page: usize, + /// Device pixels a point, times a thousand. + pub scale: u32, + pub rotate: i32, + pub dark: bool, + pub tx: i32, + pub ty: i32, +} + +/// A page point in its turned frame, still in points. +pub fn to_view((x, y): (f32, f32), (w, h): (f32, f32), rotate: i32) -> (f32, f32) { + match rotate.rem_euclid(360) { + 90 => (h - y, x), + 180 => (w - x, h - y), + 270 => (y, w - x), + _ => (x, y), + } +} + +/// The page point under a turned-frame point. +pub fn from_view((x, y): (f32, f32), (w, h): (f32, f32), rotate: i32) -> (f32, f32) { + match rotate.rem_euclid(360) { + 90 => (y, h - x), + 180 => (w - x, h - y), + 270 => (w - y, x), + _ => (x, y), + } +} + +/// A page rectangle on screen, relative to its placed page. +pub fn rect_on(rect: [f32; 4], size: (f32, f32), rotate: i32, scale: f32) -> (f32, f32, f32, f32) { + let a = to_view((rect[0], rect[1]), size, rotate); + let b = to_view((rect[2], rect[3]), size, rotate); + let (x0, y0) = (a.0.min(b.0), a.1.min(b.1)); + let (x1, y1) = (a.0.max(b.0), a.1.max(b.1)); + (x0 * scale, y0 * scale, (x1 - x0) * scale, (y1 - y0) * scale) +} + +/// Tiles covering `[from, to)` view pixels of a `side`-pixel page. +fn span(from: f32, to: f32, sf: f32, side: i32) -> std::ops::RangeInclusive { + let first = (from.max(0.0) * sf).floor() as i32 / TILE; + let last = ((to * sf).ceil() as i32 - 1).min(side - 1).max(0) / TILE; + first..=last +} + +/// A tile as an image; GPUI takes BGRA. +pub(crate) fn picture((w, h): (u32, u32), bgra: Vec) -> Option> { + let image = ImageBuffer::, _>::from_raw(w, h, bgra)?; + Some(Arc::new(RenderImage::new([Frame::new(image)]))) +} + +impl PdfView { + /// The layout in the room last painted. + pub fn laid_out(&self) -> Layout { + let room = (f32::from(self.room.size.width), f32::from(self.room.size.height)); + layout(&self.sizes, self.rotate, (self.mode, self.zoom), room, self.current) + } + + /// A window point as (page, point on it), if any. + pub fn page_at(&self, at: Point) -> Option<(usize, (f32, f32))> { + let laid = self.laid_out(); + let offset = self.scroll.offset(); + let x = f32::from(at.x - self.room.origin.x - offset.x); + let y = f32::from(at.y - self.room.origin.y - offset.y); + let place = laid.placed.iter().find(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h)?; + let local = ((x - place.x) / laid.scale, (y - place.y) / laid.scale); + Some((place.page, from_view(local, self.sizes[place.page], self.rotate))) + } + + /// Freezes `page`'s place in the window for a gesture. + pub fn grip(&mut self, page: usize) { + let (laid, offset) = (self.laid_out(), self.scroll.offset()); + self.grip = laid.placed.iter().find(|p| p.page == page).map(|place| { + let origin = point(self.room.origin.x + offset.x + px(place.x), self.room.origin.y + offset.y + px(place.y)); + (page, origin, laid.scale) + }); + } + + /// A window point on the gripped page, as pressed. + pub fn held_at(&self, at: Point) -> Option<(usize, (f32, f32))> { + let (page, origin, scale) = self.grip?; + let local = (f32::from(at.x - origin.x) / scale, f32::from(at.y - origin.y) / scale); + Some((page, from_view(local, self.sizes[page], self.rotate))) + } + + /// Pages in view and their tiles; missing ones asked. + pub fn page_view(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let (sf, dark) = (window.scale_factor(), cx.theme().is_dark()); + let (sunken, paper) = { + let colors = &cx.theme().colors; + (colors.sunken, if dark { colors.surface } else { colors.paper }) + }; + let laid = self.laid_out(); + let (room_w, room_h) = (f32::from(self.room.size.width), f32::from(self.room.size.height)); + if let Some((x, y)) = self.go_to.take() { + self.scroll.set_offset(point(px(-x), px(-y))); + } + let (left, top) = (-f32::from(self.scroll.offset().x), -f32::from(self.scroll.offset().y)); + let shown: Vec = visible(&laid, top, top + room_h) + .filter(|p| p.x < left + room_w && p.x + p.w > left) + .copied() + .collect(); + // The current page: first showing more than a sliver. + if let Some(first) = shown.iter().find(|p| p.y + p.h > top + GAP * 3.0) { + let paired = shown.iter().any(|p| p.page == self.current) && first.page / 2 == self.current / 2; + let now = match self.mode { + Mode::Single => self.current, + // Either page of a pair in view may be current. + Mode::Two if paired => self.current, + _ => first.page, + }; + if now != self.current { + self.current = now; + cx.emit(Reported); + } + } + let scale = (laid.scale * sf * 1000.0).round() as u32; + let (mut wanted, mut pages, mut seen) = (Vec::new(), Vec::new(), HashSet::new()); + for place in &shown { + let (dw, dh) = ((place.w * sf).ceil() as i32, (place.h * sf).ceil() as i32); + let (ys, xs) = ( + span(top - place.y, top + room_h - place.y, sf, dh), + span(left - place.x, left + room_w - place.x, sf, dw), + ); + let mut tiles = Vec::new(); + for ty in ys { + for tx in xs.clone() { + let key = TileKey { page: place.page, scale, rotate: self.rotate, dark, tx, ty }; + let rect = [tx * TILE, ty * TILE, TILE.min(dw - tx * TILE), TILE.min(dh - ty * TILE)]; + seen.insert(key); + match self.tiles.get(&key) { + Some(image) => tiles.push( + img(image.clone()) + .absolute() + .left(px(rect[0] as f32 / sf)) + .top(px(rect[1] as f32 / sf)) + .w(px(rect[2] as f32 / sf)) + .h(px(rect[3] as f32 / sf)) + .into_any_element(), + ), + None if !self.failed.contains(&key) => wanted.push((key, rect)), + None => {} + } + } + } + pages.push((*place, tiles)); + } + // Tiles out of view go. + let retired = &mut self.retired; + self.tiles.retain(|key, image| { + let kept = seen.contains(key); + if !kept { + retired.push(image.clone()); + } + kept + }); + let pages_shown: Vec = shown.iter().map(|place| place.page).collect(); + self.ask_layers(&pages_shown, cx); + self.ask_notes(pages_shown.iter().copied(), cx); + self.paint(wanted, cx); + let pages: Vec = pages + .into_iter() + .map(|(place, tiles)| { + let mut marks = self.page_marks(&place, laid.scale, cx); + marks.extend(self.edit_marks(&place, laid.scale, cx)); + marks.extend(self.region_marks(&place, laid.scale, cx)); + marks.extend(self.note_marks(&place, laid.scale, cx)); + div() + .absolute() + .left(px(place.x)) + .top(px(place.y)) + .w(px(place.w)) + .h(px(place.h)) + .bg(paper) + .children(tiles) + .children(marks) + .into_any_element() + }) + .collect(); + let this = cx.entity(); + let (down, moved, up) = (this.clone(), this.clone(), this.clone()); + let pages = div() + .id("pdf-pages") + .size_full() + .overflow_scroll() + .track_scroll(&self.scroll) + .bg(sunken) + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + down.update(cx, |view, cx| { + window.focus(&view.focus, cx); + match view.tool { + tool if tool.selects() => view.select_from(event.position, cx), + tool if tool.edits() => view.press(event.position, cx), + _ => view.sketch_from(event.position, cx), + } + }) + }) + .on_mouse_move(move |event, _, cx| { + if event.dragging() { + moved.update(cx, |view, cx| match view.tool { + tool if tool.selects() => view.select_to(event.position, cx), + tool if tool.edits() => view.drag_to(event.position, cx), + _ => view.sketch_to(event.position, cx), + }) + } + }) + .on_mouse_up(MouseButton::Left, move |event, _, cx| { + up.update(cx, |view, cx| match view.tool { + tool if tool.selects() => view.finish_select(event.position, cx), + tool if tool.edits() => view.release(cx), + _ => view.sketch_end(cx), + }) + }) + .on_scroll_wheel(cx.listener(|_, _, _, cx| cx.notify())) + .child(div().relative().w(px(laid.size.0)).h(px(laid.size.1)).children(pages)); + // Measures the view itself, which does not scroll. + let measure = canvas( + move |bounds, _, cx| { + this.update(cx, |view, cx| { + if view.room != bounds { + view.room = bounds; + cx.notify(); + } + }) + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(); + div().relative().size_full().child(pages).child(measure).into_any_element() + } + + /// Asks for the first missing tile, one at a time. + fn paint(&mut self, wanted: Vec<(TileKey, [i32; 4])>, cx: &mut Context) { + let Some((key, rect)) = wanted.first().copied() else { + return; + }; + if self.painting { + return; + } + self.painting = true; + let (doc, scale, version) = (self.doc, key.scale as f32 / 1000.0, self.version); + self.off( + move |worker| worker.render(doc, key.page, (scale, key.rotate), rect, key.dark), + move |view, painted, cx| { + view.painting = false; + if view.version != version { + log::info!("pdf: a tile from before an edit is dropped"); + return cx.notify(); + } + let painted = painted.map(|tile| { + if tile.damaged { + view.found_damage(cx); + } + tile.bgra + }); + match painted.map(|bgra| picture((rect[2] as u32, rect[3] as u32), bgra)) { + Ok(Some(image)) => { + log::debug!("pdf: a tile of page {} drawn", key.page); + if let Some(old) = view.tiles.insert(key, image) { + view.retired.push(old); + } + } + Ok(None) => log::error!("pdf: a tile came back the wrong size"), + Err(error) if ely_pdf_engine::is_stale(&error) => log::info!("pdf: a tile from before an edit is dropped"), + Err(error) => { + // A failed tile is not asked again; once said. + let first = view.failed.is_empty(); + view.failed.insert(key); + if first { + view.fail("drawing a page", &error, cx); + } + } + } + cx.notify(); + }, + cx, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn turning_and_back_lands_on_the_same_point() { + let size = (612.0, 792.0); + for rotate in [0, 90, 180, 270, -90] { + let point = (100.0, 50.0); + let there = to_view(point, size, rotate); + assert_eq!(from_view(there, size, rotate), point, "{rotate}"); + } + assert_eq!(to_view((0.0, 0.0), size, 90), (792.0, 0.0), "the top left goes to the right"); + assert_eq!(rect_on([0.0, 0.0, 10.0, 20.0], size, 90, 2.0), (1544.0, 0.0, 40.0, 20.0)); + } + + #[test] + fn a_view_edge_on_a_tile_edge_takes_no_tile_beyond() { + assert_eq!(span(0.0, 512.0, 1.0, 2000), 0..=0); + assert_eq!(span(256.0, 1024.0, 2.0, 4000), 1..=3); + assert_eq!(span(0.0, 9000.0, 1.0, 2000), 0..=3, "the page ends first"); + } +} diff --git a/apps/ely-pdf/src/props.rs b/apps/ely-pdf/src/props.rs new file mode 100644 index 0000000..9d22ab7 --- /dev/null +++ b/apps/ely-pdf/src/props.rs @@ -0,0 +1,152 @@ +//! The document properties dialog (PDF-07). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Input, TextInput}, + overlays::Dialog as Card, +}; +use ely_office::{Closing as _, Tabs as _}; +use ely_pdf_engine::Properties; +use gpui::{AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, Window, div}; + +use crate::viewer::Viewer; + +/// The keys a person may change, with their label keys. +const EDITABLE: [(&str, &str); 4] = + [("Title", "pdf.props.title"), ("Author", "pdf.props.author"), ("Subject", "pdf.props.subject"), ("Keywords", "pdf.props.keywords")]; + +/// The properties as read, and their fields. +pub struct Props { + pub tab: u64, + pub read: Properties, + pub fields: [Entity; 4], +} + +/// A PDF date (D:YYYYMMDDHHmmSS±HH'mm') as `YYYY-MM-DD HH:MM ±HH:MM`. +pub fn date(raw: &str) -> String { + let body = raw.trim_start_matches("D:"); + let digits: String = body.chars().take_while(char::is_ascii_digit).collect(); + if digits.len() < 12 { + return raw.to_string(); + } + let shown = format!("{}-{}-{} {}:{}", &digits[0..4], &digits[4..6], &digits[6..8], &digits[8..10], &digits[10..12]); + let zone = &body[digits.len()..]; + let hours: String = zone.chars().skip(1).filter(char::is_ascii_digit).collect(); + match zone.chars().next() { + Some('Z') => format!("{shown} UTC"), + Some(sign @ ('+' | '-')) if hours.len() == 4 => format!("{shown} {sign}{}:{}", &hours[0..2], &hours[2..4]), + _ => shown, + } +} + +fn now(read: &Properties, key: &str) -> Option { + match key { + "Title" => read.title.clone(), + "Author" => read.author.clone(), + "Subject" => read.subject.clone(), + _ => read.keywords.clone(), + } +} + +impl Viewer { + pub fn ask_properties(&mut self, _: &mut Window, cx: &mut Context) { + let Some(tab) = self.core.active else { + return self.no_document(cx); + }; + self.read_in( + tab, + |worker, doc| worker.get()?.properties(*doc), + move |viewer, read, window, cx| match read { + Ok(read) => { + let fields = EDITABLE.map(|(key, _)| { + let value = now(&read, key).unwrap_or_default(); + cx.new(|cx| { + let mut field = TextInput::new(window, cx); + field.set_text(value, cx); + field + }) + }); + viewer.props = Some(Props { tab, read, fields }); + cx.notify(); + } + Err(error) => viewer.failed(&error, cx), + }, + cx, + ); + } + + /// Writes the fields that changed, one step. + pub(crate) fn set_properties(&mut self, cx: &mut Context) { + let Some(Props { tab, read, fields }) = self.props.take() else { + return; + }; + cx.notify(); + let changed: Vec<(&'static str, String)> = (EDITABLE.iter().zip(&fields)) + .map(|((key, _), field)| (*key, field.read(cx).text().to_string())) + .filter(|(key, typed)| now(&read, key).unwrap_or_default() != *typed) + .collect(); + let Some(view) = self.tab(tab).map(|tab| tab.view.clone()).filter(|_| !changed.is_empty()) else { + return log::info!("pdf: the properties are as they were"); + }; + view.update(cx, |view, cx| { + view.edit( + "setting properties", + move |worker, doc| { + let pairs: Vec<(&str, &str)> = changed.iter().map(|(key, value)| (*key, value.as_str())).collect(); + worker.set_properties(doc, &pairs) + }, + cx, + ) + }); + } + + /// The properties dialog, while open. + pub fn props_element(&self, cx: &mut Context) -> Option { + let props = self.props.as_ref()?; + let this = cx.entity(); + let label = |key: &str| Self::text(cx, key, &[]); + let mut rows = DescriptionList::new(); + for ((_, key), field) in EDITABLE.iter().zip(&props.fields) { + rows = rows.item(label(key), div().w_full().child(Input::new(field))); + } + let shown = |value: &Option, dated: bool| -> SharedString { + match value { + Some(value) if dated => date(value).into(), + Some(value) => value.clone().into(), + None => label("pdf.props.none"), + } + }; + let read = &props.read; + rows = rows + .item(label("pdf.props.creator"), shown(&read.creator, false)) + .item(label("pdf.props.producer"), shown(&read.producer, false)) + .item(label("pdf.props.created"), shown(&read.created, true)) + .item(label("pdf.props.modified"), shown(&read.modified, true)); + let closed = this.clone(); + let dismiss = move |_: &mut Window, cx: &mut App| { + closed.update(cx, |viewer, cx| { + viewer.props = None; + cx.notify(); + }) + }; + let go = label("pdf.text.apply"); + let card = Card::new("pdf-props", label("pdf.props.heading"), dismiss).child(rows).action(ely_office::cancel(cx)).action(move |_| { + Button::new("pdf-props-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.set_properties(cx))) + }); + Some(card.into_any_element()) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn pdf_dates_read_as_day_and_minute() { + assert_eq!(super::date("D:20261006153000+08'00'"), "2026-10-06 15:30 +08:00"); + assert_eq!(super::date("D:20261006225959Z"), "2026-10-06 22:59 UTC"); + assert_eq!(super::date("D:202610061530"), "2026-10-06 15:30", "no zone given"); + assert_eq!(super::date("yesterday"), "yesterday", "as written when not a date"); + } +} diff --git a/apps/ely-pdf/src/protect.rs b/apps/ely-pdf/src/protect.rs new file mode 100644 index 0000000..22d6223 --- /dev/null +++ b/apps/ely-pdf/src/protect.rs @@ -0,0 +1,271 @@ +//! Owner access and protected copies (PDF-11). + +use std::{io, path::PathBuf}; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Checkbox, Input, TextInput}, + overlays::Dialog as Card, + primitives::Severity, +}; +use ely_office::{Closing as _, Saving as _, Tabs as _, in_window}; +use ely_pdf_engine::{Lock, proto::may}; +use gpui::{AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Styled as _, TaskExt as _, Window, div}; + +use crate::redact::beside; +use crate::viewer::Viewer; + +/// What a protected copy may allow, with label keys. +const ALLOW: [(u32, &str); 6] = [ + (may::PRINT, "pdf.protect.print"), + (may::COPY, "pdf.protect.copy"), + (may::EDIT, "pdf.protect.edit"), + (may::ANNOTATE, "pdf.protect.annotate"), + (may::FILL, "pdf.protect.fill"), + (may::ASSEMBLE, "pdf.protect.assemble"), +]; + +pub enum Secure { + Owner { tab: u64, field: Entity }, + Protect { tab: u64, on: bool, user: Entity, owner: Entity, allow: u32 }, +} + +impl Secure { + fn tab(&self) -> u64 { + match self { + Secure::Owner { tab, .. } | Secure::Protect { tab, .. } => *tab, + } + } +} + +fn masked(window: &mut Window, cx: &mut Context) -> Entity { + cx.new(|cx| TextInput::new(window, cx).masked()) +} + +impl Viewer { + /// A refusal names what is missing. + pub(crate) fn failed(&self, error: &io::Error, cx: &mut App) { + match error.kind() { + io::ErrorKind::PermissionDenied => self.toast(crate::view::restricted(&error.to_string(), cx), Severity::Warning, cx), + _ => self.panel_failed(&error.to_string(), cx), + } + } + + fn allowed(&self, cx: &App) -> Option<(u64, u32)> { + let tab = self.active_tab()?; + Some((tab.id, tab.view.read(cx).allowed)) + } + + pub fn ask_owner(&mut self, window: &mut Window, cx: &mut Context) { + let Some((tab, allowed)) = self.allowed(cx) else { + return self.no_document(cx); + }; + if allowed & may::OWNER != 0 { + return self.toast(Self::text(cx, "pdf.owner.already", &[]), Severity::Info, cx); + } + self.secure = Some(Secure::Owner { tab, field: masked(window, cx) }); + cx.notify(); + } + + pub fn ask_protect(&mut self, window: &mut Window, cx: &mut Context) { + let Some((tab, allowed)) = self.allowed(cx) else { + return self.no_document(cx); + }; + if allowed & may::OWNER == 0 { + return self.toast(crate::view::restricted("owner", cx), Severity::Warning, cx); + } + let all = ALLOW.iter().fold(0, |bits, (bit, _)| bits | bit); + let (user, owner) = (masked(window, cx), masked(window, cx)); + self.secure = Some(Secure::Protect { tab, on: true, user, owner, allow: all }); + cx.notify(); + } + + pub(crate) fn try_owner(&mut self, cx: &mut Context) { + let Some(Secure::Owner { tab, field }) = self.secure.as_ref() else { + return; + }; + let (tab, password, asked) = (*tab, field.read(cx).text().to_string(), field.clone()); + let name = self.tab(tab).map(|open| open.name.to_string()).unwrap_or_default(); + self.read_in( + tab, + move |worker, doc| { + let worker = worker.get()?; + Ok(worker.owner(*doc, &password)?.then_some(worker.permissions(*doc)?)) + }, + move |viewer, done, _, cx| match done { + Ok(Some(bits)) => { + // Only this dialog closes; a newer one stays. + if matches!(&viewer.secure, Some(Secure::Owner { field, .. }) if *field == asked) { + viewer.secure = None; + } + if let Some(open) = viewer.tab(tab) { + open.view.update(cx, |view, cx| { + view.allowed = bits; + cx.notify(); + }); + } + viewer.toast(Self::text(cx, "pdf.owner.done", &[("name", &name)]), Severity::Success, cx); + } + Ok(None) => viewer.toast(Self::text(cx, "pdf.owner.wrong", &[]), Severity::Warning, cx), + Err(error) => viewer.failed(&error, cx), + }, + cx, + ); + } + + /// The lock asked for, or None for no protection. + pub(crate) fn lock(&self, cx: &App) -> Option> { + let Some(Secure::Protect { on, user, owner, allow, .. }) = self.secure.as_ref() else { + return None; + }; + let (user, owner) = (user.read(cx).text().to_string(), owner.read(cx).text().to_string()); + match on { + false => Some(None), + true if owner.is_empty() || owner == user => None, + true => Some(Some(Lock { user, owner, allow: *allow })), + } + } + + /// Asks where the protected copy goes, then writes it. + fn choose_protected(&mut self, cx: &mut Context) { + let Some(lock) = self.lock(cx) else { + return; + }; + let Some(tab) = self.secure.take().map(|secure| secure.tab()) else { + return; + }; + cx.notify(); + let Some(open) = self.tab(tab) else { + return; + }; + let Some(dir) = ely_office::folder(open) else { + return self.panel_failed("no documents folder", cx); + }; + let tail = Self::text(cx, if lock.is_some() { "pdf.protect.tail" } else { "pdf.protect.bare" }, &[]); + let chosen = cx.prompt_for_new_path(&dir, Some(&beside(&open.name, &tail))); + cx.spawn(async move |this, cx| { + let chosen = match chosen.await { + Ok(Ok(path)) => Ok(path), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + in_window(&this, cx, |viewer, _, cx| match chosen { + Ok(Some(target)) => viewer.write_protected(tab, lock, target, cx), + Ok(None) => log::info!("pdf: the protect panel was cancelled"), + Err(error) => viewer.panel_failed(&error, cx), + }) + }) + .detach_and_log_err(cx); + } + + pub(crate) fn write_protected(&mut self, tab: u64, lock: Option, target: PathBuf, cx: &mut Context) { + if !self.settled(tab, cx) { + return self.when_settled(tab, move |viewer, _, cx| viewer.write_protected(tab, lock, target, cx)); + } + let Some(open) = self.tab(tab) else { + return; + }; + let (shown, opened) = (ely_office::files::file_name(&target), target.clone()); + match self.holder(&target) { + Ok(None) => {} + Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &shown)]), Severity::Danger, cx), + Err(error) => return self.failed(&error, cx), + } + let (work, path, revision) = (open.work.join("protect.pdf"), open.session.path().map(PathBuf::from), open.session.revision()); + log::info!("pdf: writes a {} copy", if lock.is_some() { "protected" } else { "unprotected" }); + self.read_in( + tab, + move |worker, doc| { + worker.get()?.protect(*doc, &work, lock.as_ref())?; + let bytes = std::fs::read(&work)?; + std::fs::remove_file(&work)?; + ely_files::export(&target, path.as_deref(), &bytes, revision).map(|_| ()) + }, + move |viewer, done, _, cx| match done { + Ok(()) => viewer.toast_output(Self::text(cx, "pdf.protect.done", &[("name", &shown)]), opened.clone(), cx), + Err(error) => viewer.failed(&error, cx), + }, + cx, + ); + } + + /// The owner or protect dialog, while open. + pub fn secure_element(&self, cx: &mut Context) -> Option { + let secure = self.secure.as_ref()?; + let this = cx.entity(); + let name = self.tab(secure.tab())?.name.to_string(); + let label = |key: &str| Self::text(cx, key, &[]); + let closed = this.clone(); + let dismiss = move |_: &mut Window, cx: &mut App| { + closed.update(cx, |viewer, cx| { + viewer.secure = None; + cx.notify(); + }) + }; + let card = match secure { + Secure::Owner { field, .. } => { + let go = label("pdf.owner.go"); + let rows = DescriptionList::new().item(label("pdf.owner.field"), div().w_full().child(Input::new(field))); + Card::new("pdf-owner", Self::text(cx, "pdf.owner.title", &[("name", &name)]), dismiss).child(rows).action(ely_office::cancel(cx)).action( + move |_| { + Button::new("pdf-owner-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.try_owner(cx))) + }, + ) + } + Secure::Protect { on, user, owner, allow, .. } => { + let ready = self.lock(cx).is_some(); + let go = label("pdf.removal.go"); + let detail = label(if ready { "pdf.protect.detail" } else { "pdf.protect.needs_owner" }); + let title = Self::text(cx, "pdf.protect.title", &[("name", &name)]); + let body = self.protect_rows(*on, user, owner, *allow, &this, cx); + let card = Card::new("pdf-protect", title, dismiss) + .detail(detail) + .child(body); + card.action(ely_office::cancel(cx)).action(move |_| { + Button::new("pdf-protect-go", go) + .variant(ButtonVariant::Primary) + .disabled(!ready) + .on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.choose_protected(cx))) + }) + } + }; + Some(card.into_any_element()) + } + + fn protect_rows(&self, on: bool, user: &Entity, owner: &Entity, allow: u32, this: &Entity, cx: &mut Context) -> AnyElement { + let set = |change: Box| { + let this = this.clone(); + move |value: bool, _: &mut Window, cx: &mut App| { + this.update(cx, |viewer, cx| { + if let Some(Secure::Protect { on, allow, .. }) = viewer.secure.as_mut() { + change(on, allow, value); + } + cx.notify(); + }) + } + }; + let mut body = div().flex().flex_col().gap_2().child( + Checkbox::new("pdf-protect-on", on) + .label(Self::text(cx, "pdf.protect.on", &[])) + .on_change(set(Box::new(|on, _, value| *on = value))), + ); + if on { + let rows = DescriptionList::new() + .item(Self::text(cx, "pdf.protect.user", &[]), div().w_full().child(Input::new(user))) + .item(Self::text(cx, "pdf.protect.owner", &[]), div().w_full().child(Input::new(owner))); + body = body.child(rows); + for (at, (bit, key)) in ALLOW.iter().enumerate() { + let bit = *bit; + body = body.child( + Checkbox::new(("pdf-protect-allow", at), allow & bit != 0) + .label(Self::text(cx, key, &[])) + .on_change(set(Box::new(move |_, allow, value| *allow = if value { *allow | bit } else { *allow & !bit }))), + ); + } + } + body.into_any_element() + } +} diff --git a/apps/ely-pdf/src/redact.rs b/apps/ely-pdf/src/redact.rs new file mode 100644 index 0000000..53d3225 --- /dev/null +++ b/apps/ely-pdf/src/redact.rs @@ -0,0 +1,265 @@ +//! Removal and cleaning into new files (PDF-09, PDF-10). + +use std::path::PathBuf; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + forms::Checkbox, + overlays::Dialog as Card, + primitives::Severity, + theme::ActiveTheme as _, +}; +use ely_office::{Closing as _, Saving as _, Tabs as _, in_window}; +use ely_pdf_engine::{Cleaning, Reach, Region}; +use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, TaskExt as _, Window, div, px}; + +use crate::layout::Placed; +use crate::pages::rect_on; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// A removal or cleaning, bound to its asked version. +pub enum Removal { + /// The marks as confirmed. + Redact { tab: u64, version: u64, regions: Vec }, + /// What cleaning reaches, once scanned, and what is chosen. + Clean { tab: u64, version: u64, reach: Reach, what: Cleaning }, +} + +impl Removal { + fn bound(&self) -> (u64, u64) { + match self { + Removal::Redact { tab, version, .. } | Removal::Clean { tab, version, .. } => (*tab, *version), + } + } +} + +fn inside(rect: &[f32; 4], (x, y): (f32, f32)) -> bool { + x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3] +} + +impl PdfView { + /// Marks regions; a click on one unmarks it. + pub fn mark_regions(&mut self, page: usize, boxes: Vec<[f32; 4]>, click: Option<(f32, f32)>, cx: &mut Context) { + match click { + Some(at) => self.redactions.retain(|r| !(r.page == page && inside(&r.rect, at))), + None => self.redactions.extend(boxes.into_iter().map(|rect| Region { page, rect })), + } + log::info!("pdf: {} regions marked to remove", self.redactions.len()); + cx.notify(); + } + + /// The regions on `place`, drawn as they will come out. + pub fn region_marks(&self, place: &Placed, scale: f32, cx: &App) -> Vec { + let colors = &cx.theme().colors; + let size = self.sizes[place.page]; + (self.redactions.iter().filter(|r| r.page == place.page)) + .map(|r| { + let (x, y, w, h) = rect_on(r.rect, size, self.rotate, scale); + let box_ = div().absolute().left(px(x)).top(px(y)).w(px(w)).h(px(h)); + box_.bg(gpui::black().opacity(0.8)).border_1().border_color(colors.danger).into_any_element() + }) + .collect() + } +} + +/// A name beside the open one, as `stem-tail.pdf`. +pub(crate) fn beside(name: &str, tail: &str) -> String { + let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem); + format!("{stem}-{tail}.pdf") +} + +impl Viewer { + pub fn ask_redact(&mut self, cx: &mut Context) { + let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else { + return self.no_document(cx); + }; + let read = view.read(cx); + if read.redactions.is_empty() { + let text = Self::text(cx, "pdf.redact.none", &[]); + return self.toast(text, Severity::Info, cx); + } + self.removal = Some(Removal::Redact { tab, version: read.version, regions: read.redactions.clone() }); + cx.notify(); + } + + pub fn clear_redactions(&mut self, cx: &mut Context) { + self.on_view(|view, cx| { + view.redactions.clear(); + cx.notify(); + }, cx); + } + + pub fn ask_clean(&mut self, cx: &mut Context) { + let (Some(open), Some(view)) = (self.active_tab(), self.active_view()) else { + return self.no_document(cx); + }; + let (tab, version, scratch) = (open.id, view.read(cx).version, open.work.join("scan.pdf")); + self.ask_in( + tab, + move |worker, doc| worker.get()?.clean_scan(*doc, &scratch), + move |viewer, read, _, cx| match read { + // An edit since makes the counts stale. + Ok(_) if viewer.tab(tab).is_none_or(|open| open.view.read(cx).version != version) => { + log::info!("pdf: a cleaning scan came back after an edit") + } + Ok(reach) => { + viewer.removal = Some(Removal::Clean { tab, version, reach, what: Cleaning::default() }); + cx.notify(); + } + Err(error) => viewer.failed(&error, cx), + }, + cx, + ); + } + + /// Asks where the new file goes, then writes it. + fn choose_output(&mut self, cx: &mut Context) { + let Some(removal) = self.removal.take() else { + return; + }; + cx.notify(); + let (tab, _) = removal.bound(); + let Some(open) = self.tab(tab) else { + return; + }; + let Some(dir) = ely_office::folder(open) else { + return self.panel_failed("no documents folder", cx); + }; + let tail = match removal { + Removal::Redact { .. } => Self::text(cx, "pdf.redact.tail", &[]), + Removal::Clean { .. } => Self::text(cx, "pdf.clean.tail", &[]), + }; + let chosen = cx.prompt_for_new_path(&dir, Some(&beside(&open.name, &tail))); + cx.spawn(async move |this, cx| { + let chosen = match chosen.await { + Ok(Ok(path)) => Ok(path), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + in_window(&this, cx, |viewer, _, cx| match chosen { + Ok(Some(target)) => viewer.write_removal(removal, target, cx), + Ok(None) => log::info!("pdf: the output panel was cancelled"), + Err(error) => viewer.panel_failed(&error, cx), + }) + }) + .detach_and_log_err(cx); + } + + /// Writes, checks, then hands the new file over. + pub(crate) fn write_removal(&mut self, removal: Removal, target: PathBuf, cx: &mut Context) { + let (tab, version) = removal.bound(); + let (Some(open), Some(view)) = (self.tab(tab), self.tab(tab).map(|tab| tab.view.clone())) else { + return; + }; + // What was confirmed is what is written, or nothing. + let read = view.read(cx); + let changed = match &removal { + Removal::Redact { regions, .. } => read.redactions != *regions, + Removal::Clean { .. } => false, + }; + if read.version != version || !read.settled() || changed { + let text = Self::text(cx, "pdf.object.changed", &[]); + return self.toast(text, Severity::Warning, cx); + } + let (shown, opened) = (ely_office::files::file_name(&target), target.clone()); + match self.holder(&target) { + Ok(None) => {} + Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &shown)]), Severity::Danger, cx), + Err(error) => return self.failed(&error, cx), + } + let (work, path, revision) = (open.work.join("removal.pdf"), open.session.path().map(PathBuf::from), open.session.revision()); + log::info!("pdf: writes a {} copy", if matches!(removal, Removal::Redact { .. }) { "redacted" } else { "cleaned" }); + self.read_in( + tab, + move |worker, doc| { + let report = match removal { + Removal::Redact { regions, .. } => worker.get()?.redact(*doc, ®ions, &work).map(|r| r.regions)?, + Removal::Clean { what, .. } => worker.get()?.clean(*doc, what, &work).map(|_| 0)?, + }; + let bytes = std::fs::read(&work)?; + // The work copy goes; a plain delete, not a wipe. + std::fs::remove_file(&work)?; + ely_files::export(&target, path.as_deref(), &bytes, revision)?; + Ok(report) + }, + move |viewer, done, _, cx| match done { + Ok(0) => viewer.toast_output(Self::text(cx, "pdf.clean.done", &[("name", &shown)]), opened.clone(), cx), + Ok(n) => viewer.toast_output(Self::text(cx, "pdf.redact.done", &[("name", &shown), ("count", &n.to_string())]), opened.clone(), cx), + Err(error) => viewer.failed(&error, cx), + }, + cx, + ); + } + + /// The removal or cleaning dialog, while open. + pub fn removal_element(&self, cx: &mut Context) -> Option { + let removal = self.removal.as_ref()?; + let this = cx.entity(); + let name = self.tab(removal.bound().0)?.name.to_string(); + let (title, body) = match removal { + Removal::Clean { reach, what, .. } => ("pdf.clean.title", self.clean_rows(reach, *what, &this, cx)), + Removal::Redact { regions, .. } => { + let count = regions.len().to_string(); + ("pdf.redact.title", div().child(Self::text(cx, "pdf.redact.count", &[("count", &count)])).into_any_element()) + } + }; + let title = Self::text(cx, title, &[("name", &name)]); + let label = |key: &str| Self::text(cx, key, &[]); + let closed = this.clone(); + let dismiss = move |_: &mut Window, cx: &mut App| { + closed.update(cx, |viewer, cx| { + viewer.removal = None; + cx.notify(); + }) + }; + let ready = !matches!(removal, Removal::Clean { what, .. } if *what == Cleaning::default()); + let go = label("pdf.removal.go"); + let card = Card::new("pdf-removal", title, dismiss) + .detail(label("pdf.removal.warning")) + .child(body) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("pdf-removal-go", go) + .variant(ButtonVariant::Primary) + .disabled(!ready) + .on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.choose_output(cx))) + }); + Some(card.into_any_element()) + } + + fn clean_rows(&self, reach: &Reach, what: Cleaning, this: &gpui::Entity, cx: &mut Context) -> AnyElement { + let row = |at: usize, key: &str, count: usize, on: bool, set: fn(&mut Cleaning, bool)| { + let this = this.clone(); + Checkbox::new(("pdf-clean", at), on) + .label(Self::text(cx, key, &[("count", &count.to_string())])) + .disabled(count == 0) + .on_change(move |value, _, cx| { + this.update(cx, |viewer, cx| { + if let Some(Removal::Clean { what, .. }) = viewer.removal.as_mut() { + set(what, value); + } + cx.notify(); + }) + }) + }; + div() + .flex() + .flex_col() + .gap_2() + .child(row(0, "pdf.clean.meta", reach.meta, what.meta, |w, on| w.meta = on)) + .child(row(1, "pdf.clean.annots", reach.annots, what.annots, |w, on| w.annots = on)) + .child(row(2, "pdf.clean.files", reach.files, what.files, |w, on| w.files = on)) + .child(row(3, "pdf.clean.hidden", reach.hidden + reach.scripts + reach.hidden_annots, what.hidden, |w, on| w.hidden = on)) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + #[test] + fn a_new_file_sits_beside_the_old_by_name() { + assert_eq!(super::beside("report.pdf", "redacted"), "report-redacted.pdf"); + assert_eq!(super::beside("notes", "cleaned"), "notes-cleaned.pdf"); + } +} diff --git a/apps/ely-pdf/src/search.rs b/apps/ely-pdf/src/search.rs new file mode 100644 index 0000000..357f06f --- /dev/null +++ b/apps/ely-pdf/src/search.rs @@ -0,0 +1,208 @@ +//! Find in the whole document, pages in batches (PDF-01). + +use ely_gpui_component::{ + editor::FindWidget, + forms::{InputEvent, TextInput}, + primitives::Severity, +}; +use ely_office::Tabs as _; +use gpui::{ + AnyElement, AppContext as _, Context, Entity, Focusable as _, IntoElement, ParentElement as _, + Styled as _, Subscription, Window, div, +}; + +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// Pages searched in one worker call. +const BATCH: usize = 20; + +/// The find box; its hits live in the tab's view. +pub struct Find { + /// Each find box is new; late answers check it. + pub id: u64, + pub tab: u64, + pub query: Entity, + /// Pages searched so far. + pub done: usize, + /// Counts searches; an older batch is dropped. + pub round: u64, + /// The view's version searched; an edit starts over. + pub version: u64, + _query: Subscription, +} + +/// A quad's bounding box: x0, y0, x1, y1. +pub fn bounds(quad: &[f32; 8]) -> [f32; 4] { + let xs = [quad[0], quad[2], quad[4], quad[6]]; + let ys = [quad[1], quad[3], quad[5], quad[7]]; + let min = |v: [f32; 4]| v.into_iter().fold(f32::MAX, f32::min); + let max = |v: [f32; 4]| v.into_iter().fold(f32::MIN, f32::max); + [min(xs), min(ys), max(xs), max(ys)] +} + +impl Viewer { + /// The view of tab `id`. + fn view_of(&self, id: u64) -> Option> { + self.tab(id).map(|tab| tab.view.clone()) + } + + pub fn open_find(&mut self, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.core.active else { + let text = Self::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + if let Some(find) = self.find.as_ref().filter(|find| find.tab == tab) { + return window.focus(&find.query.focus_handle(cx), cx); + } + self.drop_find(cx); + let query = cx.new(|cx| TextInput::new(window, cx)); + let subscription = cx.subscribe(&query, |viewer, _, event, cx| match event { + InputEvent::Changed => viewer.search(cx), + InputEvent::Submit => viewer.step(true, cx), + _ => {} + }); + window.focus(&query.focus_handle(cx), cx); + self.finds += 1; + let id = self.finds; + self.find = Some(Find { id, tab, query, done: 0, round: 0, version: 0, _query: subscription }); + cx.notify(); + } + + /// Closes the find box; its marks go. + pub fn drop_find(&mut self, cx: &mut Context) { + if let Some(view) = self.find.take().and_then(|find| self.view_of(find.tab)) { + view.update(cx, |view, cx| { + (view.found, view.chosen) = (Vec::new(), None); + cx.notify(); + }); + } + } + + /// Starts over for the query now typed. + pub fn search(&mut self, cx: &mut Context) { + let Some(view) = self.find.as_ref().and_then(|find| self.view_of(find.tab)) else { + return; + }; + let version = view.read(cx).version; + view.update(cx, |view, _| (view.found, view.chosen) = (Vec::new(), None)); + if let Some(find) = self.find.as_mut() { + (find.round, find.done, find.version) = (find.round + 1, 0, version); + } + self.search_more(cx); + cx.notify(); + } + + /// Searches the next batch of pages. + fn search_more(&mut self, cx: &mut Context) { + let Some(find) = self.find.as_ref() else { + return; + }; + let needle = find.query.read(cx).text().to_string(); + let (finder, id, round, from) = (find.id, find.tab, find.round, find.done); + let Some(view) = self.view_of(id) else { + return; + }; + let (doc, pages) = (view.read(cx).doc, view.read(cx).sizes.len()); + if needle.is_empty() || from >= pages { + return; + } + let to = (from + BATCH).min(pages); + let this = cx.weak_entity(); + view.update(cx, |view, cx| { + view.read( + move |worker| { + (from..to) + .map(|page| Ok(worker.search(doc, page, &needle)?.into_iter().map(move |quad| (page, quad)))) + .collect::>>() + .map(|pages| pages.into_iter().flatten().collect::>()) + }, + move |view, found, cx| { + let live = move |viewer: &Viewer| viewer.find.as_ref().is_some_and(|find| (find.id, find.round) == (finder, round)); + if !this.upgrade().is_some_and(|viewer| live(viewer.read(cx))) { + return; + } + let hits = match found { + Ok(hits) => hits, + Err(error) => return view.fail("searching", &error, cx), + }; + view.found.extend(hits); + let first = view.chosen.is_none() && !view.found.is_empty(); + cx.notify(); + // The viewer reads this view; wait until it is free. + cx.defer(move |cx| { + let Some(viewer) = this.upgrade() else { + return; + }; + viewer.update(cx, |viewer, cx| { + if !live(viewer) { + return; + } + if let Some(find) = viewer.find.as_mut() { + find.done = to; + } + if first { + viewer.step(true, cx); + } + viewer.search_more(cx); + cx.notify(); + }) + }); + }, + cx, + ) + }); + } + + /// Goes to the next or previous hit. + pub fn step(&mut self, forward: bool, cx: &mut Context) { + let Some(find) = self.find.as_ref() else { + return; + }; + let Some(view) = self.view_of(find.tab) else { + return; + }; + view.update(cx, |view, cx| { + let count = view.found.len(); + if count == 0 { + return; + } + let at = match (view.chosen, forward) { + (None, _) => 0, + (Some(at), true) => (at + 1) % count, + (Some(at), false) => (at + count - 1) % count, + }; + view.chosen = Some(at); + let (page, y) = (view.found[at].0, bounds(&view.found[at].1[0])[1]); + view.show(page, y, cx); + }); + cx.notify(); + } + + /// The find box over the pages, while open. + pub fn find_bar(&self, cx: &mut Context) -> Option { + let find = self.find.as_ref().filter(|find| Some(find.tab) == self.core.active)?; + let view = self.view_of(find.tab)?; + let (chosen, count) = (view.read(cx).chosen, view.read(cx).found.len()); + let (step, close) = (cx.entity(), cx.entity()); + let widget = FindWidget::new("pdf-find", &find.query, chosen, count) + .on_step(move |forward, _, cx| step.update(cx, |viewer, cx| viewer.step(forward, cx))) + .on_close(move |window, cx| { + close.update(cx, |viewer, cx| { + viewer.drop_find(cx); + window.focus(&view.read(cx).focus.clone(), cx); + cx.notify(); + }) + }); + Some(div().flex().justify_end().px_3().py_1().child(widget).into_any_element()) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn a_quad_bounds_its_four_corners() { + let quad = [10.0, 20.0, 30.0, 20.0, 10.0, 32.0, 30.0, 32.0]; + assert_eq!(super::bounds(&quad), [10.0, 20.0, 30.0, 32.0]); + } +} diff --git a/apps/ely-pdf/src/select.rs b/apps/ely-pdf/src/select.rs new file mode 100644 index 0000000..bdbac18 --- /dev/null +++ b/apps/ely-pdf/src/select.rs @@ -0,0 +1,224 @@ +//! Text picked with the mouse, across pages, and copied (PDF-01). + +use std::sync::Arc; + +use ely_gpui_component::theme::ActiveTheme as _; +use ely_pdf_engine::Char; +use gpui::{AnyElement, App, ClipboardItem, Context, IntoElement, Pixels, Point, Styled as _, div, px}; + +use crate::layout::Placed; +use crate::pages::rect_on; +use crate::search::bounds; +use crate::view::PdfView; + +/// Drag start and end, as (page, point in points). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Selection { + pub anchor: (usize, (f32, f32)), + pub head: (usize, (f32, f32)), +} + +/// Picked characters from one (page, index) to another. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Span { + pub from: (usize, usize), + pub to: (usize, usize), +} + +impl Selection { + /// The characters picked, once both ends' pages are read. + pub fn span<'a>(&self, text: impl Fn(usize) -> Option<&'a [Char]>) -> Option { + // A press that has not moved picks nothing. + if self.anchor == self.head { + return None; + } + let end = |(page, point): (usize, (f32, f32))| Some((page, nearest(text(page)?, point)?)); + let (a, b) = (end(self.anchor)?, end(self.head)?); + Some(Span { from: a.min(b), to: a.max(b) }) + } +} + +impl Span { + /// The picked characters of `page`, if any. + pub fn on(&self, page: usize, count: usize) -> Option> { + let ((p0, i0), (p1, i1)) = (self.from, self.to); + if page < p0 || page > p1 || count == 0 { + return None; + } + let from = if page == p0 { i0 } else { 0 }; + let to = if page == p1 { i1 + 1 } else { count }; + Some(from.min(count)..to.min(count)) + } +} + +/// The character nearest `at`, in page points. +pub fn nearest(chars: &[Char], (x, y): (f32, f32)) -> Option { + let gap = |c: &Char| { + let dx = (c.rect[0] - x).max(0.0).max(x - c.rect[2]); + let dy = (c.rect[1] - y).max(0.0).max(y - c.rect[3]); + // Lines count more than columns. + dx + dy * 4.0 + }; + (0..chars.len()).min_by(|a, b| gap(&chars[*a]).total_cmp(&gap(&chars[*b]))) +} + +/// Characters as text: a line change is a new line. +pub fn as_text(chars: &[Char]) -> String { + let mut text = String::new(); + for (at, c) in chars.iter().enumerate() { + if at > 0 && chars[at - 1].line != c.line { + text.push('\n'); + } + text.push(c.c); + } + text +} + +impl PdfView { + /// The text of `page`, asked for when missing. + pub fn page_text(&mut self, page: usize, cx: &mut Context) -> Option>> { + if let Some(text) = self.text.get(&page) { + return Some(text.clone()); + } + if self.asking_text.insert(page) { + let doc = self.doc; + self.read( + move |worker| worker.text(doc, page), + move |view, read, cx| { + view.asking_text.remove(&page); + match read { + Ok(chars) => { + view.text.insert(page, Arc::new(chars)); + view.mark_pending(cx); + cx.notify(); + } + Err(error) => { + view.marking = None; + view.fail("reading the text", &error, cx) + } + } + }, + cx, + ); + } + None + } + + /// The page point under `at`; its text is asked for. + fn point_at(&mut self, at: Point, cx: &mut Context) -> Option<(usize, (f32, f32))> { + let (page, point) = self.page_at(at)?; + self.page_text(page, cx); + Some((page, point)) + } + + pub fn select_from(&mut self, at: Point, cx: &mut Context) { + (self.marking, self.pressed) = (None, Some(at)); + self.selection = self.point_at(at, cx).map(|end| Selection { anchor: end, head: end }); + cx.notify(); + } + + pub fn select_to(&mut self, at: Point, cx: &mut Context) { + let Some(head) = self.point_at(at, cx) else { + return; + }; + if let Some(selection) = self.selection.as_mut() + && selection.head != head + { + selection.head = head; + cx.notify(); + } + } + + /// Picked text and found words drawn over a page. + pub fn page_marks(&self, place: &Placed, scale: f32, cx: &App) -> Vec { + let colors = &cx.theme().colors; + let size = self.sizes[place.page]; + let mark = |rect: [f32; 4], color| { + let (x, y, w, h) = rect_on(rect, size, self.rotate, scale); + div().absolute().left(px(x)).top(px(y)).w(px(w)).h(px(h)).bg(color).into_any_element() + }; + let mut marks = Vec::new(); + let span = self.selection.and_then(|selection| selection.span(|page| self.text.get(&page).map(|chars| chars.as_slice()))); + if let (Some(span), Some(chars)) = (span, self.text.get(&place.page)) { + for c in span.on(place.page, chars.len()).map_or(&[][..], |range| &chars[range]) { + marks.push(mark(c.rect, colors.selection)); + } + } + for (at, (_, quads)) in self.found.iter().enumerate().filter(|(_, (page, _))| *page == place.page) { + let color = match self.chosen == Some(at) { + true => colors.warning.opacity(0.5), + false => colors.warning.opacity(0.25), + }; + marks.extend(quads.iter().map(|quad| mark(bounds(quad), color))); + } + marks + } + + /// Copies the picked text, reading missing pages first. + pub fn copy(&mut self, cx: &mut Context) { + let Some(selection) = self.selection else { + return; + }; + if self.allowed & ely_pdf_engine::proto::may::COPY == 0 { + return cx.emit(crate::view::Failed(crate::view::restricted("copy", cx).to_string())); + } + let (first, last) = (selection.anchor.0.min(selection.head.0), selection.anchor.0.max(selection.head.0)); + let (doc, have) = (self.doc, self.text.clone()); + self.read( + move |worker| { + (first..=last) + .map(|page| match have.get(&page) { + Some(chars) => Ok((**chars).clone()), + None => worker.text(doc, page), + }) + .collect::>>() + }, + move |view, read, cx| match read { + Ok(pages) => { + let Some(span) = selection.span(|page| pages.get(page.checked_sub(first)?).map(Vec::as_slice)) else { + return log::info!("pdf: nothing picked to copy"); + }; + let parts: Vec = (pages.iter().enumerate()) + .filter_map(|(at, chars)| { + let range = span.on(first + at, chars.len())?; + Some(as_text(&chars[range])) + }) + .collect(); + log::info!("pdf: copies text from {} pages", parts.len()); + cx.write_to_clipboard(ClipboardItem::new_string(parts.join("\n"))); + } + Err(error) => view.fail("copying", &error, cx), + }, + cx, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line(line: i32, text: &str, y: f32) -> Vec { + (text.chars().enumerate()) + .map(|(at, c)| Char { line, c, rect: [at as f32 * 10.0, y, at as f32 * 10.0 + 10.0, y + 12.0] }) + .collect() + } + + #[test] + fn a_drag_picks_from_either_end_and_copies_lines() { + let mut chars = line(0, "Hello", 0.0); + chars.extend(line(1, "World", 20.0)); + assert_eq!(nearest(&chars, (25.0, 5.0)), Some(2), "inside the third"); + assert_eq!(nearest(&chars, (500.0, 25.0)), Some(9), "past the end of line two"); + let text = |page: usize| (page == 0).then_some(chars.as_slice()); + let backwards = Selection { anchor: (0, (25.0, 25.0)), head: (0, (25.0, 5.0)) }; + let span = backwards.span(text).expect("both ends read"); + assert_eq!(as_text(&chars[span.on(0, chars.len()).expect("on page zero")]), "llo\nWor"); + let still = Selection { anchor: (0, (25.0, 5.0)), head: (0, (25.0, 5.0)) }; + assert_eq!(still.span(text), None, "a click picks nothing"); + let unread = Selection { anchor: (0, (25.0, 5.0)), head: (1, (5.0, 5.0)) }; + assert_eq!(unread.span(text), None, "page two is not read yet"); + let across = Span { from: (1, 3), to: (3, 1) }; + assert_eq!((across.on(1, 5), across.on(2, 5), across.on(3, 5), across.on(4, 5)), (Some(3..5), Some(0..5), Some(0..2), None)); + } +} diff --git a/apps/ely-pdf/src/side.rs b/apps/ely-pdf/src/side.rs new file mode 100644 index 0000000..731c12a --- /dev/null +++ b/apps/ely-pdf/src/side.rs @@ -0,0 +1,278 @@ +//! Page pictures, and bookmarks if any (PDF-01, PDF-02). + +use std::{collections::BTreeSet, ops::Range}; + +use ely_gpui_component::{ + buttons::SegmentedControl, + menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem}, + primitives::{DragGhost, FocusRing as _, IconName}, + theme::{ActiveTheme as _, ControlSize}, +}; +use gpui::{ + Action, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, Role, + StatefulInteractiveElement as _, Styled as _, WeakEntity, Window, div, img, prelude::FluentBuilder as _, px, + uniform_list, +}; + +use crate::organize::{CropPages, DeletePages, ExtractPages, InsertBlankPage, InsertPagesFromFile, RotatePagesLeft, RotatePagesRight}; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// The key context of the page list. +pub const PAGES: &str = "PdfPages"; +/// A picture's width in pixels, and its row's height. +const WIDTH: f32 = 96.0; +const ROW: f32 = 152.0; + +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum Pane { + Pages, + Marks, + Notes, + Files, +} + +/// Pages dragged in the list. +#[derive(Clone)] +struct Dragged { + view: gpui::EntityId, + pages: BTreeSet, +} + +pub struct Side { + viewer: WeakEntity, + pub(crate) pane: Pane, + /// The view shown, watched for new pictures and edits. + watched: Option<(gpui::EntityId, gpui::Subscription)>, +} + +impl Side { + pub fn new(viewer: &Entity, cx: &mut Context) -> Self { + cx.observe(viewer, |_, _, cx| cx.notify()).detach(); + Side { viewer: viewer.downgrade(), pane: Pane::Pages, watched: None } + } + + /// Follows `view`, dropping the one followed before. + fn watch(&mut self, view: &Entity, cx: &mut Context) { + if self.watched.as_ref().is_some_and(|(id, _)| *id == view.entity_id()) { + return; + } + let watching = cx.observe(view, |_, _, cx| cx.notify()); + self.watched = Some((view.entity_id(), watching)); + } +} + +/// The page commands on a right click. +fn popup(cx: &App) -> Popup { + let item = |key: &str, action: Box| { + PopupItem::new(Viewer::text(cx, key, &[])) + .on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx)) + }; + Popup::new() + .item(item("pdf.pages.blank", Box::new(InsertBlankPage))) + .item(item("pdf.pages.file", Box::new(InsertPagesFromFile))) + .item(item("pdf.pages.delete", Box::new(DeletePages))) + .separator() + .item(item("pdf.pages.rotate_left", Box::new(RotatePagesLeft))) + .item(item("pdf.pages.rotate_right", Box::new(RotatePagesRight))) + .item(item("pdf.pages.crop", Box::new(CropPages))) + .item(item("pdf.pages.extract", Box::new(ExtractPages))) +} + +impl PdfView { + /// Picks `page` as a click with these keys does. + pub fn pick(&mut self, page: usize, (range, toggle): (bool, bool), cx: &mut Context) { + match (range, self.anchor) { + (true, Some(anchor)) => self.picked = (anchor.min(page)..=anchor.max(page)).collect(), + _ if toggle => { + if !self.picked.remove(&page) { + self.picked.insert(page); + } + self.anchor = Some(page); + } + _ => { + self.picked = BTreeSet::from([page]); + self.anchor = Some(page); + self.show(page, 0.0, cx); + } + } + cx.notify(); + } + + /// Asks for the first missing picture in `rows`. + fn ask_thumb(&mut self, rows: Range, (sf, dark): (f32, bool), cx: &mut Context) { + if self.thumbing { + return; + } + let Some(page) = rows.clone().find(|page| !self.thumbs.contains_key(&(*page, dark)) && !self.thumbs_failed.contains(page)) else { + return; + }; + let (w, h) = self.sizes[page]; + let scale = WIDTH * sf / w; + let tile = [0, 0, (w * scale).ceil() as i32, (h * scale).ceil() as i32]; + let (doc, version) = (self.doc, self.version); + self.thumbing = true; + self.off( + move |worker| worker.render(doc, page, (scale, 0), tile, dark), + move |view, painted, cx| { + view.thumbing = false; + if view.version != version { + return cx.notify(); + } + let painted = painted.map(|drawn| { + if drawn.damaged { + view.found_damage(cx); + } + drawn.bgra + }); + match painted.map(|bgra| crate::pages::picture((tile[2] as u32, tile[3] as u32), bgra)) { + Ok(Some(image)) => { + if let Some(old) = view.thumbs.insert((page, dark), image) { + view.retired.push(old); + } + } + Ok(None) => log::error!("pdf: a page picture came back the wrong size"), + Err(error) if ely_pdf_engine::is_stale(&error) => log::info!("pdf: a page picture from before an edit is dropped"), + Err(error) => { + view.thumbs_failed.insert(page); + view.fail("drawing a page picture", &error, cx); + } + } + cx.notify(); + }, + cx, + ); + } +} + +/// One row: the page number and its picture. +fn row(view: &Entity, page: usize, dark: bool, cx: &App) -> gpui::AnyElement { + let read = view.read(cx); + let colors = &cx.theme().colors; + let (w, h) = read.sizes[page]; + let picked = read.picked.contains(&page); + let picture = read.thumbs.get(&(page, dark)).cloned(); + let count = read.sizes.len(); + let label = Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]); + let (clicked, pressed, dropped) = (view.clone(), view.clone(), view.clone()); + let dragged = Dragged { + view: view.entity_id(), + pages: if picked { read.picked.clone() } else { BTreeSet::from([page]) }, + }; + let ghost = label.clone(); + let height = (h * WIDTH / w).min(ROW - 24.0); + div() + .id(("pdf-thumb", page)) + .w_full() + .h(px(ROW)) + .tab_index(0) + .focus_ring(cx) + .flex() + .flex_col() + .items_center() + .gap_1() + .pt_2() + .cursor_pointer() + .on_click(move |event, _, cx| { + let keys = event.modifiers(); + clicked.update(cx, |view, cx| view.pick(page, (keys.shift, keys.secondary()), cx)) + }) + .on_mouse_down(gpui::MouseButton::Right, move |_, _, cx| { + pressed.update(cx, |view, cx| { + if !view.picked.contains(&page) { + view.pick(page, (false, false), cx); + } + }) + }) + .on_drag(dragged, move |_, _, _, cx| DragGhost::new(ghost.clone(), None, cx)) + .on_drop(move |drag: &Dragged, _, cx| { + if drag.view != dropped.entity_id() { + return; + } + let pages = drag.pages.clone(); + dropped.update(cx, |view, cx| { + view.picked = pages; + view.move_to(page, cx); + }) + }) + .child( + div() + .w(px(WIDTH)) + .h(px(height)) + .border_1() + .border_color(if picked { colors.focus } else { colors.border }) + .bg(if dark { colors.surface } else { colors.paper }) + .when_some(picture, |frame, picture| frame.child(img(picture).size_full())), + ) + .child(div().text_xs().text_color(colors.fg_subtle).child((page + 1).to_string())) + .role(Role::ListItem) + .aria_label(label) + .aria_selected(picked) + .aria_position_in_set(page + 1) + .aria_size_of_set(count) + .into_any_element() +} + +impl Render for Side { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(view) = self.viewer.upgrade().and_then(|viewer| viewer.read(cx).active_view()) else { + return div().into_any_element(); + }; + self.watch(&view, cx); + let pane = self.pane; + let this = cx.entity(); + let value = match pane { + Pane::Pages => "pages", + Pane::Marks => "marks", + Pane::Notes => "notes", + Pane::Files => "files", + }; + let head = SegmentedControl::new("pdf-side-pane", value) + .size(ControlSize::Sm) + .segment("pages", Viewer::text(cx, "pdf.side.pages", &[]), Some(IconName::FileText)) + .segment("marks", Viewer::text(cx, "pdf.side.marks", &[]), Some(IconName::Bookmark)) + .segment("notes", Viewer::text(cx, "pdf.side.notes", &[]), Some(IconName::StickyNote)) + .segment("files", Viewer::text(cx, "pdf.side.files", &[]), Some(IconName::Paperclip)) + .on_change(move |value, _, cx| { + this.update(cx, |side, cx| { + side.pane = match value.as_ref() { + "marks" => Pane::Marks, + "notes" => Pane::Notes, + "files" => Pane::Files, + _ => Pane::Pages, + }; + cx.notify(); + }) + }); + let body = match pane { + Pane::Marks => crate::marks::outline(&view, cx).into_any_element(), + Pane::Notes => crate::notelist::list(&view, cx), + Pane::Files => crate::attach::list(&view, cx), + Pane::Pages => { + let shown = (window.scale_factor(), cx.theme().is_dark()); + let count = view.read(cx).sizes.len(); + let rows = view.clone(); + let list = uniform_list("pdf-thumbs", count, move |range, _, cx| { + rows.update(cx, |view, cx| view.ask_thumb(range.clone(), shown, cx)); + range.map(|page| row(&rows, page, shown.1, cx)).collect() + }) + .size_full(); + div() + .id("pdf-pages-list") + .size_full() + .role(Role::List) + .aria_label(Viewer::text(cx, "pdf.side.pages", &[])) + .key_context(PAGES) + .child(ContextMenu::new("pdf-thumbs-menu", popup(cx)).size_full().child(list)) + .into_any_element() + } + }; + div() + .size_full() + .flex() + .flex_col() + .child(div().p_2().child(head)) + .child(div().flex_1().min_h_0().child(body)) + .into_any_element() + } +} diff --git a/apps/ely-pdf/src/sign.rs b/apps/ely-pdf/src/sign.rs new file mode 100644 index 0000000..6f73596 --- /dev/null +++ b/apps/ely-pdf/src/sign.rs @@ -0,0 +1,337 @@ +//! Signatures: checked beside the pages, and signed copies (PDF-12, QA-15). + +use std::{io, path::PathBuf}; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + feedback::EmptyState, + forms::{Input, TextInput}, + overlays::Dialog as Card, + primitives::{IconName, Severity}, + theme::ActiveTheme as _, +}; +use ely_office::{Closing as _, Saving as _, Tabs as _, in_window}; +use ely_pdf_engine::{Digest, Signature, Signed, Signing}; +use ely_platform::trust::{self, Trust}; +use gpui::{AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, StatefulInteractiveElement as _, Styled as _, TaskExt as _, Window, div}; + +use crate::redact::beside; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// A signature field and the system's word on its certificate. +pub struct Checked { + pub field: Signature, + pub trust: Option>, +} + +/// Where a new signature goes, set by the sign tool. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SignAt { + pub page: usize, + /// An empty field's object, or a new field. + pub object: Option, + pub rect: [f32; 4], + pub version: u64, +} + +/// The certificate and words for a new signature. +pub struct Signer { + pub pfx: Option, + pub password: Entity, + pub reason: Entity, + pub location: Entity, +} + +impl PdfView { + /// Reads and checks the signatures of the file at `path`. + pub fn ask_signatures(&mut self, path: PathBuf, cx: &mut Context) { + if self.signatures.as_ref().is_some_and(|(read, _)| *read == path) || self.reading_signatures { + return; + } + self.reading_signatures = true; + let (doc, asked) = (self.doc, path.clone()); + self.off( + move |worker| { + let fields = worker.signatures(doc, &path)?; + Ok(fields.into_iter().map(|field| { + let chain = field.signed.as_ref().map(|signed| signed.certs.clone()).filter(|certs| !certs.is_empty()); + let trust = chain.map(|chain| trust::check(&chain).map_err(|error| error.to_string())); + Checked { field, trust } + })) + .map(Iterator::collect::>) + }, + move |view, read: io::Result>, cx| { + view.reading_signatures = false; + match read { + Ok(read) => view.signatures = Some((asked, read)), + Err(error) => view.fail("reading signatures", &error, cx), + } + cx.notify(); + }, + cx, + ); + } +} + +fn integrity(signed: &Signed, cx: &App) -> gpui::SharedString { + let key = match (signed.digest, signed.later, signed.broken) { + (Digest::Unreadable, ..) => return Viewer::text(cx, "pdf.sign.unreadable", &[("why", signed.error.as_deref().unwrap_or(""))]), + (Digest::Changed, ..) => "pdf.sign.changed", + (Digest::Intact, false, _) => "pdf.sign.intact", + (Digest::Intact, true, false) => "pdf.sign.later", + (Digest::Intact, true, true) => "pdf.sign.broken", + }; + Viewer::text(cx, key, &[]) +} + +fn trusted(trust: &Option>, cx: &App) -> gpui::SharedString { + match trust { + Some(Ok(Trust::Trusted)) => Viewer::text(cx, "pdf.sign.trusted", &[]), + Some(Ok(Trust::Untrusted(why))) => Viewer::text(cx, "pdf.sign.untrusted", &[("why", why)]), + Some(Err(why)) => Viewer::text(cx, "pdf.sign.unchecked", &[("why", why)]), + None => Viewer::text(cx, "pdf.sign.no_certificate", &[]), + } +} + +/// One signature's facts, each on its own row. +fn facts(checked: &Checked, at: usize, view: &Entity, cx: &App) -> AnyElement { + let (field, label) = (&checked.field, |key: &str| Viewer::text(cx, key, &[])); + let mut rows = DescriptionList::new(); + let who = match &field.signed { + None => { + rows = rows.item(label("pdf.sign.state"), label("pdf.sign.empty")); + field.name.clone() + } + Some(signed) => { + rows = rows + .item(label("pdf.sign.integrity"), integrity(signed, cx)) + .item(label("pdf.sign.trust"), trusted(&checked.trust, cx)) + .item(label("pdf.sign.revoked"), label("pdf.sign.revocation")) + .item(label("pdf.sign.when"), crate::props::date(&signed.date)); + for (key, value) in [("pdf.sign.reason", &signed.reason), ("pdf.sign.location", &signed.location)] { + if !value.is_empty() { + rows = rows.item(label(key), value.clone()); + } + } + if signed.common.is_empty() { field.name.clone() } else { signed.common.clone() } + } + }; + let go = field.page.map(|page| { + let shown = view.clone(); + Button::new(("pdf-sign-page", at), Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())])) + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| { + shown.update(cx, |view, cx| view.show(page, 0.0, cx)); + window.dispatch_action(Box::new(crate::document::CloseSignatures), cx); + }) + }); + div() + .flex() + .flex_col() + .gap_1() + .child(div().flex().items_center().justify_between().child(div().font_weight(gpui::FontWeight::SEMIBOLD).text_color(cx.theme().colors.fg).child(who)).children(go)) + .child(rows) + .into_any_element() +} + +/// The signatures of `view`: integrity, trust, revocation, apart. +pub fn list(view: &Entity, path: Option, cx: &mut App) -> AnyElement { + let Some(path) = path else { + return EmptyState::new("pdf-signs-none", IconName::File, Viewer::text(cx, "pdf.sign.none", &[])).into_any_element(); + }; + view.update(cx, |view, cx| view.ask_signatures(path.clone(), cx)); + let read = view.read(cx); + let Some(fields) = read.signatures.as_ref().filter(|(read, _)| *read == path).map(|(_, fields)| fields) else { + return div().into_any_element(); + }; + if fields.is_empty() { + return EmptyState::new("pdf-signs-none", IconName::File, Viewer::text(cx, "pdf.sign.none", &[])).into_any_element(); + } + let blocks: Vec = fields.iter().enumerate().map(|(at, checked)| facts(checked, at, view, cx)).collect(); + div().id("pdf-signs").flex().flex_col().gap_4().overflow_y_scroll().children(blocks).into_any_element() +} + +impl Viewer { + /// The signatures dialog, while open. + pub fn signs_element(&self, cx: &mut Context) -> Option { + let (view, tab) = (self.active_view().filter(|_| self.signs_open)?, self.active_tab()?); + let title = Self::text(cx, "pdf.sign.list", &[("name", &tab.name)]); + let this = cx.entity(); + let dismiss = move |_: &mut Window, cx: &mut App| this.update(cx, |viewer, cx| viewer.show_signatures(false, cx)); + let path = tab.session.path().map(PathBuf::from); + let body = div().max_h(gpui::px(480.0)).overflow_hidden().child(list(&view, path, cx)); + let done = Self::text(cx, "pdf.sign.close", &[]); + let close = move |close: ely_gpui_component::overlays::Close| { + Button::new("pdf-signs-close", done).variant(ButtonVariant::Primary).on_click(move |_, window, cx| close(window, cx)) + }; + Some(Card::new("pdf-signs", title, dismiss).child(body).action(close).into_any_element()) + } + + pub fn show_signatures(&mut self, open: bool, cx: &mut Context) { + let Some(view) = self.active_view().filter(|_| open) else { + self.signs_open = false; + return if open { self.no_document(cx) } else { cx.notify() }; + }; + // Each opening checks the file as it is now. + view.update(cx, |view, _| view.signatures = None); + self.signs_open = open; + cx.notify(); + } + + /// Turns the sign tool on with a fresh certificate form. + pub fn ask_sign(&mut self, window: &mut Window, cx: &mut Context) { + let input = |masked: bool, window: &mut Window, cx: &mut Context| { + cx.new(|cx| if masked { TextInput::new(window, cx).masked() } else { TextInput::new(window, cx) }) + }; + let (password, reason, location) = (input(true, window, cx), input(false, window, cx), input(false, window, cx)); + self.signer = Some(Signer { pfx: None, password, reason, location }); + self.toggle_tool(crate::content::Tool::Sign, cx); + } + + fn choose_certificate(&mut self, cx: &mut Context) { + self.pick(false, cx, |viewer, path, cx| { + if let Some(signer) = viewer.signer.as_mut() { + signer.pfx = Some(path); + } + cx.notify(); + }); + } + + fn cancel_sign(&mut self, cx: &mut Context) { + if let Some(view) = self.active_view() { + view.update(cx, |view, cx| { + view.sign_at = None; + cx.notify(); + }); + } + } + + /// Asks where the signed copy goes, then writes it. + fn choose_signed(&mut self, cx: &mut Context) { + let (Some(open), Some(view)) = (self.active_tab(), self.active_view()) else { + return; + }; + let Some(at) = view.read(cx).sign_at else { + return; + }; + let Some(signer) = self.signer.as_ref() else { + return; + }; + let Some(pfx) = signer.pfx.as_ref().and_then(|path| path.to_str()).map(str::to_string) else { + return; + }; + let text = |field: &Entity| field.read(cx).text().to_string(); + let how = Signing { + page: at.page, + object: at.object, + rect: at.rect, + name: String::new(), + pfx, + pfx_password: text(&signer.password), + reason: text(&signer.reason), + location: text(&signer.location), + }; + let Some(dir) = ely_office::folder(open) else { + return self.panel_failed("no documents folder", cx); + }; + let tab = open.id; + let chosen = cx.prompt_for_new_path(&dir, Some(&beside(&open.name, &Self::text(cx, "pdf.sign.tail", &[])))); + cx.spawn(async move |this, cx| { + let chosen = match chosen.await { + Ok(Ok(path)) => Ok(path), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + in_window(&this, cx, |viewer, _, cx| match chosen { + Ok(Some(target)) => viewer.write_signed(tab, at, how, target, cx), + Ok(None) => log::info!("pdf: the sign panel was cancelled"), + Err(error) => viewer.panel_failed(&error, cx), + }) + }) + .detach_and_log_err(cx); + } + + /// Signs into `target` if nothing changed since the drag. + pub(crate) fn write_signed(&mut self, tab: u64, at: SignAt, mut how: Signing, target: PathBuf, cx: &mut Context) { + let (Some(open), Some(view)) = (self.tab(tab), self.tab(tab).map(|tab| tab.view.clone())) else { + return; + }; + let read = view.read(cx); + if read.version != at.version || !read.settled() || read.sign_at != Some(at) { + return self.toast(Self::text(cx, "pdf.object.changed", &[]), Severity::Warning, cx); + } + let (shown, opened) = (ely_office::files::file_name(&target), target.clone()); + match self.holder(&target) { + Ok(None) => {} + Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &shown)]), Severity::Danger, cx), + Err(error) => return self.failed(&error, cx), + } + let (work, path, revision) = (open.work.join("signed.pdf"), open.session.path().map(PathBuf::from), open.session.revision()); + log::info!("pdf: writes a signed copy"); + let placed = view.clone(); + self.read_in( + tab, + move |worker, doc| { + let worker = worker.get()?; + if how.object.is_none() { + let names = worker.field_names(*doc)?; + how.name = (1..).map(|n| format!("Signature{n}")).find(|name| !names.contains(name)).expect("a free name"); + } + worker.sign(*doc, &work, &how)?; + let bytes = std::fs::read(&work)?; + std::fs::remove_file(&work)?; + ely_files::export(&target, path.as_deref(), &bytes, revision).map(|_| ()) + }, + move |viewer, done, _, cx| match done { + Ok(()) => { + placed.update(cx, |view, cx| { + if view.sign_at == Some(at) { + view.sign_at = None; + cx.notify(); + } + }); + viewer.toast_output(Self::text(cx, "pdf.sign.done", &[("name", &shown)]), opened.clone(), cx); + } + Err(error) => viewer.failed(&error, cx), + }, + cx, + ); + } + + /// The signing form, once a place is chosen. + pub fn sign_element(&self, cx: &mut Context) -> Option { + let signer = self.signer.as_ref()?; + self.active_view()?.read(cx).sign_at?; + let this = cx.entity(); + let label = |key: &str| Self::text(cx, key, &[]); + let chosen = signer.pfx.as_ref().map_or_else(|| label("pdf.sign.no_file"), |path| ely_office::files::file_name(path)); + let pick = this.clone(); + let rows = DescriptionList::new() + .item( + label("pdf.sign.certificate"), + div().flex().gap_2().items_center().child(div().flex_1().child(chosen)).child( + Button::new("pdf-sign-pick", label("pdf.sign.choose")) + .on_click(move |_, _, cx| pick.update(cx, |viewer, cx| viewer.choose_certificate(cx))), + ), + ) + .item(label("pdf.sign.password"), div().w_full().child(Input::new(&signer.password))) + .item(label("pdf.sign.reason"), div().w_full().child(Input::new(&signer.reason))) + .item(label("pdf.sign.location"), div().w_full().child(Input::new(&signer.location))); + let closed = this.clone(); + let dismiss = move |_: &mut Window, cx: &mut App| closed.update(cx, |viewer, cx| viewer.cancel_sign(cx)); + let (ready, go) = (signer.pfx.is_some(), label("pdf.removal.go")); + let card = Card::new("pdf-sign", label("pdf.sign.title"), dismiss) + .detail(label("pdf.sign.detail")) + .child(rows) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("pdf-sign-go", go) + .variant(ButtonVariant::Primary) + .disabled(!ready) + .on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.choose_signed(cx))) + }); + Some(card.into_any_element()) + } +} diff --git a/apps/ely-pdf/src/tests.rs b/apps/ely-pdf/src/tests.rs new file mode 100644 index 0000000..b969cdc --- /dev/null +++ b/apps/ely-pdf/src/tests.rs @@ -0,0 +1,200 @@ +//! The viewer against the real worker and generated PDFs (C11.1). + +use std::sync::{Arc, Mutex}; + +use ely_office::{Opening as _, Tabs as _}; +use ely_pdf_engine::{Worker, sample::pdf}; +use gpui::{Entity, TestAppContext, VisualTestContext}; + +use crate::engine::Mupdf; +use crate::layout::Mode; +use crate::select::Selection; +use crate::view::PdfView; +use crate::viewer::Viewer; + +mod editing; +mod input; +mod notes; +mod organize; +mod output; +mod protect; +mod removal; +mod sign; +mod structure; +mod view; +mod visible; + +/// Steps and redraws until `done`, at most ten seconds. +pub(crate) fn settle(cx: &mut VisualTestContext, done: impl Fn(&gpui::App) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline { + // Bounded: a busy loop must fail here, not hang. + for _ in 0..100 { + if !cx.executor().tick() { + break; + } + } + cx.update(|window, _| window.refresh()); + if cx.update(|_, app| done(app)) { + return; + } + } + panic!("the state never settled"); +} + +/// A few redraws with the work they start. +pub(crate) fn frames(cx: &mut VisualTestContext) { + for _ in 0..5 { + cx.update(|window, _| window.refresh()); + for _ in 0..20 { + cx.executor().tick(); + } + } +} + +pub(crate) fn file(dir: &std::path::Path, name: &str, pages: &[(f32, f32, &[&str])]) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write(&path, pdf(pages, &[])).expect("writes"); + path +} + +/// A viewer drawn in a window, `paths` opened in order. +pub(crate) fn windowed<'a>( + cx: &'a mut TestAppContext, + dir: &std::path::Path, + paths: &[std::path::PathBuf], + worker: bool, +) -> (Entity, &'a mut VisualTestContext) { + cx.update(ely_gpui_component::init_for_tests); + cx.update(|cx| { + ely_ui::settings::install_defaults(cx); + ely_ui::i18n::install(cx); + }); + cx.update(crate::keys::bind); + let held = match worker { + true => Mupdf::with(Worker::spawn().expect("the worker")), + false => Mupdf::default(), + }; + let work = dir.join("work"); + std::fs::create_dir_all(&work).expect("a work dir"); + let (viewer, vcx) = cx.add_window_view(move |window, cx| { + let mut viewer = Viewer::new(Arc::new(Mutex::new(held)), work, cx); + viewer.core.window = Some(window.window_handle()); + viewer + }); + for path in paths { + let count = vcx.update(|_, app| viewer.read(app).core.tabs.len()); + vcx.update(|_, app| viewer.update(app, |viewer, cx| viewer.open_file(path.clone(), cx))); + settle(vcx, |app| viewer.read(app).core.tabs.len() == count + 1); + } + (viewer, vcx) +} + +/// The active tab's view. +pub(crate) fn shown(cx: &mut VisualTestContext, viewer: &Entity) -> Entity { + cx.update(|_, app| viewer.read(app).active_view().expect("a tab")) +} + +/// Three pages with "alpha" on one and three, and bookmarks. +fn opened<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity, &'a mut VisualTestContext) { + let pages: &[(f32, f32, &[&str])] = &[ + (612.0, 792.0, &["alpha one", "second line"]), + (612.0, 792.0, &["middle page"]), + (612.0, 792.0, &["alpha two alpha"]), + ]; + let path = dir.join("three.pdf"); + std::fs::write(&path, pdf(pages, &[("Start", 0, &[("Inside", 1)]), ("End", 2, &[])])).expect("writes"); + windowed(cx, dir, &[path], true) +} + +#[test] +fn a_pdf_opens_with_its_pages_and_bookmarks() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx) = opened(&mut cx, dir.path()); + let view = shown(cx, &viewer); + let (sizes, marks) = cx.update(|_, app| { + let view = view.read(app); + (view.sizes.clone(), view.marks.iter().map(|m| (m.title.clone(), m.down.len())).collect::>()) + }); + assert_eq!(sizes, [(612.0, 792.0); 3]); + assert_eq!(marks, [("Start".to_string(), 1), ("End".to_string(), 0)]); +} + +#[test] +fn find_steps_through_every_hit_across_pages() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx) = opened(&mut cx, dir.path()); + let view = shown(cx, &viewer); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.open_find(window, cx))); + cx.simulate_keystrokes("a l p h a"); + settle(cx, |app| view.read(app).found.len() == 3 && viewer.read(app).find.as_ref().is_some_and(|find| find.done == 3)); + let at = |cx: &mut VisualTestContext| cx.update(|_, app| { + let view = view.read(app); + (view.chosen, view.found[view.chosen.expect("chosen")].0) + }); + assert_eq!(at(cx), (Some(0), 0), "the first hit is chosen"); + cx.simulate_keystrokes("enter"); + cx.simulate_keystrokes("enter"); + assert_eq!(at(cx), (Some(2), 2), "on to page three"); + cx.simulate_keystrokes("escape"); + assert!(cx.update(|_, app| viewer.read(app).find.is_none()), "escape closes it"); + assert!(cx.update(|_, app| view.read(app).found.is_empty()), "its marks go"); +} + +#[test] +fn a_selection_across_pages_copies_its_lines() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx) = opened(&mut cx, dir.path()); + let view = shown(cx, &viewer); + // Ends at real characters: page two's eighth, page one's seventh. + cx.update(|_, app| view.update(app, |view, cx| { + view.page_text(0, cx); + view.page_text(1, cx); + })); + settle(cx, |app| view.read(app).text.len() == 2); + let centre = |cx: &mut VisualTestContext, page: usize, at: usize| cx.update(|_, app| { + let r = view.read(app).text[&page][at].rect; + (page, ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0)) + }); + let (anchor, head) = (centre(cx, 1, 7), centre(cx, 0, 6)); + cx.update(|_, app| view.update(app, |view, cx| { + view.selection = Some(Selection { anchor, head }); + view.copy(cx); + })); + settle(cx, |app| app.read_from_clipboard().is_some()); + let text = cx.update(|_, app| app.read_from_clipboard().and_then(|item| item.text())); + assert_eq!(text.as_deref(), Some("one\nsecond line\nmiddle p"), "both ends included"); +} + +#[test] +fn a_view_change_keeps_the_page_being_read() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx) = opened(&mut cx, dir.path()); + let view = shown(cx, &viewer); + cx.update(|_, app| view.update(app, |view, cx| view.show(2, 0.0, cx))); + settle(cx, |app| view.read(app).current == 2); + cx.update(|_, app| view.update(app, |view, cx| view.change(|view, _| view.mode = Mode::Single, cx))); + let pages = cx.update(|_, app| view.read(app).laid_out().placed.iter().map(|p| p.page).collect::>()); + assert_eq!(pages, [2], "single page shows the page read"); +} + +#[test] +fn a_file_with_no_pages_is_refused_and_left_closed() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[], true); + let path = file(dir.path(), "empty.pdf", &[]); + let worker = cx.update(|_, app| viewer.read(app).core.worker.clone()); + let failed = ::load( + &mut worker.lock().expect("the lock"), + ely_office::Load::File(&path, ""), + ); + assert_eq!(failed.err().map(|error| error.kind()), Some(std::io::ErrorKind::InvalidData)); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.open_file(path.clone(), cx))); + settle(cx, |app| viewer.read(app).core.loading.is_empty()); + assert!(cx.update(|_, app| viewer.read(app).active_tab().is_none())); +} diff --git a/apps/ely-pdf/src/tests/editing.rs b/apps/ely-pdf/src/tests/editing.rs new file mode 100644 index 0000000..b5c9164 --- /dev/null +++ b/apps/ely-pdf/src/tests/editing.rs @@ -0,0 +1,341 @@ +//! Editing text and objects from the app (C11.3). + +use ely_pdf_engine::sample::{Extras, pdf_with}; +use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext}; + +use super::input::over; +use super::{frames, settle, shown, windowed}; +use crate::content::{Aim, Tool}; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// Two lines, a stamp, a picture; the edit tool on. +fn editing<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity, &'a mut VisualTestContext, Entity) { + let path = dir.join("doc.pdf"); + let extras = Extras { stamp: Some("STAMP"), picture: true, ..Extras::default() }; + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes"); + let (viewer, cx) = windowed(cx, dir, &[path], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); + // At actual points the whole page is in view. + cx.update(|_, app| view.update(app, |view, cx| { + view.zoom = crate::layout::Zoom::Scale(1.0); + view.set_tool(Tool::Edit, cx) + })); + settle(cx, |app| view.read(app).layers.contains_key(&0)); + (viewer, cx, view) +} + +/// Presses at page point `at`, with no drag. +fn click(cx: &mut VisualTestContext, view: &Entity, at: (f32, f32)) { + let point = over(cx, view, 0, at); + cx.simulate_mouse_down(point, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(point, MouseButton::Left, Modifiers::none()); +} + +/// The page's blocks once the layer is read again. +fn blocks(cx: &mut VisualTestContext, view: &Entity) -> Vec { + settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0)); + cx.update(|_, app| view.read(app).layers[&0].blocks.iter().map(|b| b.text.clone()).collect()) +} + +/// Puts `text` in the panel's field. +fn type_in(cx: &mut VisualTestContext, viewer: &Entity, text: &str) { + frames(cx); + cx.update(|_, app| { + let field = viewer.read(app).edit_panel.read(app).field.clone().expect("drawn"); + field.update(app, |field, cx| field.set_text(text, cx)); + }); +} + +#[test] +fn a_block_edits_in_its_own_font_without_a_preview() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = editing(&mut cx, dir.path()); + // Hello world's first line sits 72 points down. + click(cx, &view, (80.0, 66.0)); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. }))); + type_in(cx, &viewer, "Hi world\nSecond line"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_text(false, cx))); + let now = blocks(cx, &view); + assert!(now.iter().any(|b| b.starts_with("Hi world")) && !now.iter().any(|b| b.contains("Hello")), "{now:?}"); + assert!(cx.update(|_, app| viewer.read(app).preview.is_none()), "same font, same space: no preview"); +} + +#[test] +fn another_font_shows_the_change_first_and_a_missing_glyph_says_so() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = editing(&mut cx, dir.path()); + click(cx, &view, (80.0, 66.0)); + type_in(cx, &viewer, "你好"); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + viewer.edit_panel.update(cx, |panel, _| panel.font = "helvetica".into()); + viewer.write_text(false, cx) + })); + settle(cx, |app| viewer.read(app).edit_panel.read(app).note.is_some()); + assert!(cx.update(|_, app| view.read(app).edited) == 0, "nothing changed"); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + viewer.edit_panel.update(cx, |panel, _| panel.font = "cjk-sc".into()); + viewer.write_text(false, cx) + })); + settle(cx, |app| viewer.read(app).preview.is_some()); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + let preview = viewer.preview.take().expect("shown"); + viewer.commit_text((preview.tab, preview.version, preview.page), preview.writing, cx) + })); + assert!(blocks(cx, &view).iter().any(|b| b.contains("你好"))); +} + +#[test] +fn an_image_drags_and_scales_from_its_corner() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = editing(&mut cx, dir.path()); + let rect = |cx: &mut VisualTestContext| cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect); + let (from, to) = (over(cx, &view, 0, (122.0, 267.0)), over(cx, &view, 0, (172.0, 287.0))); + cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); + settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0)); + let moved = rect(cx); + assert!((moved[0] - 122.0).abs() < 0.5 && (moved[1] - 262.0).abs() < 0.5, "{moved:?}"); + // Edits drop the aim; aim, then pull the corner. + click(cx, &view, (150.0, 280.0)); + let (corner, out) = (over(cx, &view, 0, (222.0, 312.0)), over(cx, &view, 0, (322.0, 362.0))); + cx.simulate_mouse_down(corner, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_move(out, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(out, MouseButton::Left, Modifiers::none()); + settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0)); + let scaled = rect(cx); + assert!((scaled[2] - 322.0).abs() < 0.5 && (scaled[3] - 362.0).abs() < 0.5, "{scaled:?}"); +} + +#[test] +fn new_text_lands_where_pressed_and_objects_delete() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = editing(&mut cx, dir.path()); + cx.update(|_, app| view.update(app, |view, cx| view.set_tool(Tool::AddText, cx))); + click(cx, &view, (100.0, 150.0)); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::New { page: 0, .. }))); + type_in(cx, &viewer, "Added"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_text(false, cx))); + settle(cx, |app| view.read(app).edited == 1); + cx.update(|_, app| view.update(app, |view, cx| view.set_tool(Tool::Edit, cx))); + assert!(blocks(cx, &view).iter().any(|b| b == "Added")); + click(cx, &view, (310.0, 370.0)); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { index: 0, .. })), "the stamp"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.delete_object(cx))); + assert!(!blocks(cx, &view).iter().any(|b| b == "STAMP")); +} + +#[test] +fn escape_leaves_the_edit_tool() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = editing(&mut cx, dir.path()); + cx.update(|window, app| { + let focus = view.read(app).focus.clone(); + window.focus(&focus, app) + }); + cx.simulate_keystrokes("escape"); + assert_eq!(cx.update(|_, app| view.read(app).tool), Tool::Read); +} + +#[test] +fn a_replaced_image_stays_with_its_own_tab() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, a) = editing(&mut cx, dir.path()); + click(cx, &a, (120.0, 267.0)); + let other = super::file(dir.path(), "b.pdf", &[(612.0, 792.0, &["b"])]); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.replace_image(cx))); + cx.update(|_, app| viewer.update(app, |viewer, cx| ely_office::Opening::open_file(viewer, other.clone(), cx))); + settle(cx, |app| viewer.read(app).core.tabs.len() == 2); + let b = shown(cx, &viewer); + assert_ne!(a, b, "B is in front when the picker answers"); + let square = dir.path().join("square.pnm"); + std::fs::write(&square, b"P6\n2 2\n255\n\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff").expect("writes"); + cx.cx.simulate_path_prompt_response(move |_| Some(vec![square])); + settle(cx, |app| a.read(app).edited == 1); + assert_eq!(cx.update(|_, app| b.read(app).edited), 0); +} + +#[test] +fn a_layer_read_from_before_an_edit_is_read_again() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = editing(&mut cx, dir.path()); + // The read starts before the edit lands, returns after. + cx.update(|_, app| view.update(app, |view, cx| { + view.edit("turning", |worker, doc| worker.rotate_pages(doc, &[0], 90), cx); + view.layers.clear(); + view.ask_layers(&[0], cx); + assert!(view.reading_layer); + })); + settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0) && !view.read(app).reading_layer); +} + +#[test] +fn escape_from_the_text_field_leaves_the_tool() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = editing(&mut cx, dir.path()); + click(cx, &view, (80.0, 66.0)); + frames(cx); + cx.update(|window, app| { + let field = viewer.read(app).edit_panel.read(app).field.clone().expect("drawn"); + let focus = gpui::Focusable::focus_handle(field.read(app), app); + window.focus(&focus, app) + }); + cx.simulate_keystrokes("escape"); + assert_eq!(cx.update(|_, app| view.read(app).tool), Tool::Read); +} + +#[test] +fn a_preview_goes_once_anything_else_lands() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + // Twin pages: a stale aim would hit the wrong one. + let same: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["Hello world", "Second line"]), (612.0, 792.0, &["Hello world", "Second line"])]; + let (viewer, cx) = windowed(&mut cx, dir.path(), &[super::file(dir.path(), "twins.pdf", same)], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); + cx.update(|_, app| view.update(app, |view, cx| { + view.zoom = crate::layout::Zoom::Scale(1.0); + view.set_tool(Tool::Edit, cx) + })); + settle(cx, |app| view.read(app).layers.contains_key(&0)); + click(cx, &view, (80.0, 66.0)); + type_in(cx, &viewer, "Hi world\nSecond line"); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + viewer.edit_panel.update(cx, |panel, _| panel.font = "times".into()); + viewer.write_text(false, cx) + })); + settle(cx, |app| viewer.read(app).preview.is_some()); + let stale = cx.update(|_, app| { + let preview = viewer.read(app).preview.as_ref().expect("shown"); + ((preview.tab, preview.version, preview.page), preview.writing.clone()) + }); + cx.update(|_, app| view.update(app, |view, cx| view.edit("deleting", |worker, doc| worker.delete_pages(doc, &[0]), cx))); + settle(cx, |app| view.read(app).edited == 1 && viewer.read(app).preview.is_none()); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.commit_text(stale.0, stale.1, cx))); + frames(cx); + assert_eq!(cx.update(|_, app| view.read(app).edited), 1, "the old preview writes nothing"); +} + +#[test] +fn nothing_aimed_before_a_queued_edit_is_done() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = editing(&mut cx, dir.path()); + let version = cx.update(|_, app| view.read(app).version); + // An edit is queued; old numbers may not hold. + cx.update(|_, app| view.update(app, |view, _| view.editing = true)); + let ran = std::rc::Rc::new(std::cell::Cell::new(false)); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + let ran = ran.clone(); + viewer.on_same(&view, version, cx, move |_, _| ran.set(true)) + })); + assert!(!ran.get()); +} + +#[test] +fn a_second_delete_before_the_first_lands_does_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = editing(&mut cx, dir.path()); + click(cx, &view, (310.0, 370.0)); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + viewer.delete_object(cx); + viewer.delete_object(cx); + })); + settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0)); + assert_eq!(cx.update(|_, app| view.read(app).layers[&0].drawn.len()), 1, "the picture stays"); +} + +#[test] +fn a_drag_while_an_edit_is_queued_does_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = editing(&mut cx, dir.path()); + let (from, to) = (over(cx, &view, 0, (122.0, 267.0)), over(cx, &view, 0, (172.0, 287.0))); + cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); + cx.update(|_, app| view.update(app, |view, _| view.editing = true)); + cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); + assert!(cx.update(|_, app| view.read(app).queue.is_empty()), "nothing queued with old numbers"); +} + +#[test] +fn the_panel_waits_for_the_drag_so_the_page_stays_put() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = editing(&mut cx, dir.path()); + let wide = cx.update(|_, app| view.read(app).room.size.width); + let (from, to) = (over(cx, &view, 0, (122.0, 267.0)), over(cx, &view, 0, (140.0, 267.0))); + cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); + frames(cx); + assert_eq!(cx.update(|_, app| view.read(app).room.size.width), wide, "no panel mid-drag"); + cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); + settle(cx, |app| view.read(app).settled()); + frames(cx); + cx.update(|_, app| view.update(app, |view, cx| view.press(from, cx))); + cx.update(|_, app| view.update(app, |view, cx| view.release(cx))); + frames(cx); + assert!(cx.update(|_, app| view.read(app).room.size.width) < wide, "the panel opens after"); +} + +#[test] +fn an_open_panel_stays_open_through_a_second_drag() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = editing(&mut cx, dir.path()); + let wide = cx.update(|_, app| view.read(app).room.size.width); + click(cx, &view, (122.0, 267.0)); + // The panel slides open; wait until the view stops narrowing. + let mut narrow = wide; + for _ in 0..40 { + cx.executor().advance_clock(std::time::Duration::from_millis(50)); + frames(cx); + let now = cx.update(|_, app| view.read(app).room.size.width); + if now < wide && now == narrow { + break; + } + narrow = now; + } + assert!(narrow < wide, "the panel opened"); + // Pull the picture's corner 50 points right. + let (from, to) = (over(cx, &view, 0, (172.0, 292.0)), over(cx, &view, 0, (222.0, 292.0))); + cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); + cx.executor().advance_clock(std::time::Duration::from_millis(500)); + frames(cx); + cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); + cx.executor().advance_clock(std::time::Duration::from_millis(500)); + frames(cx); + assert_eq!(cx.update(|_, app| view.read(app).room.size.width), narrow, "the panel stays mid-drag"); + cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); + settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0)); + let rect = cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect); + assert!((rect[2] - 222.0).abs() < 1.0 && (rect[3] - 317.0).abs() < 1.0, "{rect:?}"); +} + +#[test] +fn a_drag_follows_the_hand_when_the_page_shifts_under_it() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = editing(&mut cx, dir.path()); + let from = over(cx, &view, 0, (122.0, 267.0)); + cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); + // The view shifts right, as a panel pushes it. + cx.update(|_, app| view.update(app, |view, _| view.room.origin.x += gpui::px(100.0))); + let to = gpui::point(from.x + gpui::px(50.0), from.y); + cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); + settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0)); + let rect = cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect); + assert!((rect[0] - 122.0).abs() < 0.5, "moved as far as the hand: {rect:?}"); +} diff --git a/apps/ely-pdf/src/tests/input.rs b/apps/ely-pdf/src/tests/input.rs new file mode 100644 index 0000000..0ef77f7 --- /dev/null +++ b/apps/ely-pdf/src/tests/input.rs @@ -0,0 +1,187 @@ +//! Mouse, jumps, search across tabs, failures, tiles (C11.1). + +use std::collections::HashSet; + +use ely_office::Tabs as _; +use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px}; + +use super::{file, settle, shown, windowed}; +use crate::layout::Zoom; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// The window point over page points `at` of `page`. +pub(super) fn over(cx: &mut VisualTestContext, view: &Entity, page: usize, at: (f32, f32)) -> gpui::Point { + cx.update(|_, app| { + let view = view.read(app); + let laid = view.laid_out(); + let place = laid.placed.iter().find(|p| p.page == page).expect("placed"); + let (offset, origin) = (view.scroll.offset(), view.room.origin); + point( + origin.x + offset.x + px(place.x + at.0 * laid.scale), + origin.y + offset.y + px(place.y + at.1 * laid.scale), + ) + }) +} + +#[test] +fn a_first_drag_picks_text_before_the_page_is_read() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = file(dir.path(), "one.pdf", &[(612.0, 792.0, &["alpha beta gamma"])]); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > px(100.0)); + // Glyphs sit near 72 points down; none read yet. + let (from, to) = (over(cx, &view, 0, (74.0, 62.0)), over(cx, &view, 0, (150.0, 62.0))); + cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); + cx.update(|_, app| view.update(app, |view, cx| view.copy(cx))); + settle(cx, |app| app.read_from_clipboard().is_some()); + let text = cx.update(|_, app| app.read_from_clipboard().and_then(|item| item.text())).expect("copied"); + assert!(text.starts_with("alpha"), "{text}"); +} + +#[test] +fn a_jump_makes_its_page_the_one_being_read() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"]), (612.0, 792.0, &["three"])]; + let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "three.pdf", pages)], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > px(100.0)); + cx.update(|_, app| view.update(app, |view, cx| view.show(1, 0.0, cx))); + super::frames(cx); + assert_eq!(cx.update(|_, app| view.read(app).current), 1); +} + +/// A: the word on page three only. B: three pages. +fn two_tabs<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity, &'a mut VisualTestContext, u64, u64) { + let a = file(dir, "a.pdf", &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"]), (612.0, 792.0, &["three alpha"])]); + let b = file(dir, "b.pdf", &[(612.0, 792.0, &["zzz"]), (612.0, 792.0, &["b2"]), (612.0, 792.0, &["b3"])]); + let (viewer, cx) = windowed(cx, dir, &[a, b], true); + let (first, second) = cx.update(|_, app| (viewer.read(app).core.tabs[0].id, viewer.read(app).core.tabs[1].id)); + (viewer, cx, first, second) +} + +/// Opens find on `tab` and searches `query` at once. +fn find_in(cx: &mut VisualTestContext, viewer: &Entity, tab: u64, query: &str) { + cx.update(|window, app| viewer.update(app, |viewer, cx| { + viewer.core.active = Some(tab); + viewer.open_find(window, cx); + let field = viewer.find.as_ref().expect("open").query.clone(); + field.update(cx, |field, cx| field.set_text(query, cx)); + })); +} + +fn view_of(cx: &mut VisualTestContext, viewer: &Entity, tab: u64) -> Entity { + cx.update(|_, app| viewer.read(app).tab(tab).expect("open").view.clone()) +} + +#[test] +fn a_hit_found_late_never_moves_another_tab() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, first, second) = two_tabs(&mut cx, dir.path()); + find_in(cx, &viewer, first, "alpha"); + // Before the batch comes back, the reader turns to B. + cx.update(|_, app| viewer.update(app, |viewer, _| viewer.core.active = Some(second))); + settle(cx, |app| viewer.read(app).find.as_ref().is_some_and(|find| find.done == 3)); + let (a, b) = (view_of(cx, &viewer, first), view_of(cx, &viewer, second)); + let state = cx.update(|_, app| (a.read(app).chosen, b.read(app).current)); + assert_eq!(state, (Some(0), 0), "the hit is chosen; B stays on its page"); +} + +#[test] +fn a_new_find_box_takes_no_answers_meant_for_the_old() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, first, second) = two_tabs(&mut cx, dir.path()); + find_in(cx, &viewer, first, "alpha"); + find_in(cx, &viewer, second, "zzz"); + settle(cx, |app| viewer.read(app).find.as_ref().is_some_and(|find| find.done == 3)); + super::frames(cx); + let (a, b) = (view_of(cx, &viewer, first), view_of(cx, &viewer, second)); + let pages = cx.update(|_, app| (a.read(app).found.len(), b.read(app).found.iter().map(|h| h.0).collect::>())); + assert_eq!(pages, (0, vec![0]), "only B's own hit"); +} + +#[test] +fn a_tile_that_fails_is_not_asked_again() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "one.pdf", &[(612.0, 792.0, &["x"])])], true); + let view = shown(cx, &viewer); + // The document goes away behind the viewer's back. + let (worker, doc) = cx.update(|_, app| (viewer.read(app).core.worker.clone(), view.read(app).doc)); + worker.lock().expect("the lock").get().expect("started").close(doc).expect("closes"); + cx.update(|_, app| view.update(app, |view, cx| { + view.tiles.clear(); + cx.notify(); + })); + settle(cx, |app| !view.read(app).failed.is_empty()); + // Bounded steps: a retry loop would never go quiet. + for _ in 0..10 { + super::frames(cx); + } + assert!(!cx.update(|_, app| view.read(app).painting), "no new tries, nothing waiting"); +} + +#[test] +fn tiles_scrolled_off_a_long_page_go() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "long.pdf", &[(612.0, 14400.0, &["top"])])], true); + let view = shown(cx, &viewer); + cx.update(|_, app| view.update(app, |view, cx| { + view.zoom = Zoom::Scale(1.0); + cx.notify(); + })); + settle(cx, |app| view.read(app).tiles.keys().any(|key| key.ty == 0)); + cx.update(|_, app| view.update(app, |view, cx| { + view.go_to = Some((0.0, 4000.0)); + cx.notify(); + })); + settle(cx, |app| !view.read(app).tiles.is_empty() && view.read(app).tiles.keys().all(|key| key.ty > 0)); +} + +#[test] +fn no_worker_runs_before_a_file_opens() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[], false); + let started = |cx: &mut VisualTestContext| cx.update(|_, app| viewer.read(app).core.worker.lock().expect("the lock").started()); + assert!(!started(cx)); + let path = file(dir.path(), "plain.pdf", &[(612.0, 792.0, &["no marks"])]); + cx.update(|_, app| viewer.update(app, |viewer, cx| ely_office::Opening::open_file(viewer, path.clone(), cx))); + settle(cx, |app| viewer.read(app).active_tab().is_some()); + assert!(started(cx)); +} + +#[test] +fn tiles_beside_the_view_are_neither_asked_nor_kept() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "wide.pdf", &[(612.0, 792.0, &["x"])])], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > px(100.0)); + cx.update(|_, app| view.update(app, |view, cx| { + view.zoom = Zoom::Scale(4.0); + cx.notify(); + })); + let columns = |app: &gpui::App| view.read(app).tiles.keys().map(|key| key.tx).collect::>(); + let last = cx.update(|window, app| { + let laid = view.read(app).laid_out(); + ((laid.placed[0].w * window.scale_factor()).ceil() as i32 - 1) / crate::pages::TILE + }); + settle(cx, |app| columns(app).contains(&0) && !view.read(app).painting); + assert!(!cx.update(|_, app| columns(app)).contains(&last), "the right edge is off screen"); + cx.update(|_, app| view.update(app, |view, cx| { + let wide = view.laid_out().size.0 - f32::from(view.room.size.width); + let y = view.scroll.offset().y; + view.scroll.set_offset(point(px(-wide), y)); + cx.notify(); + })); + settle(cx, |app| columns(app).contains(&last) && !columns(app).contains(&0)); +} diff --git a/apps/ely-pdf/src/tests/notes.rs b/apps/ely-pdf/src/tests/notes.rs new file mode 100644 index 0000000..bd369e8 --- /dev/null +++ b/apps/ely-pdf/src/tests/notes.rs @@ -0,0 +1,317 @@ +//! Annotations and forms from the app (C11.4). + +use ely_pdf_engine::{FieldKind, Marks, sample::{Extras, pdf_with}}; +use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext}; + +use super::input::over; +use super::{frames, settle, shown, windowed}; +use crate::annotate::Pick; +use crate::content::{Aim, Tool}; +use crate::marking::Change; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// Two lines and a text field, actual size. +fn marking<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity, &'a mut VisualTestContext, Entity) { + let path = dir.join("doc.pdf"); + let extras = Extras { fields: &[0], ..Extras::default() }; + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes"); + let (viewer, cx) = windowed(cx, dir, &[path], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); + cx.update(|_, app| view.update(app, |view, cx| { + view.zoom = crate::layout::Zoom::Scale(1.0); + cx.notify(); + })); + settle(cx, |app| view.read(app).notes.contains_key(&0)); + (viewer, cx, view) +} + +fn tool(cx: &mut VisualTestContext, view: &Entity, tool: Tool) { + cx.update(|_, app| view.update(app, |view, cx| view.set_tool(tool, cx))); +} + +/// Presses at `from`, moves through `through`, lets go. +fn drag(cx: &mut VisualTestContext, view: &Entity, from: (f32, f32), through: &[(f32, f32)]) { + // A hand presses what the last frame shows. + frames(cx); + let start = over(cx, view, 0, from); + cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::none()); + let mut end = start; + for at in through { + end = over(cx, view, 0, *at); + cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::none()); + } + cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::none()); +} + +fn click(cx: &mut VisualTestContext, view: &Entity, at: (f32, f32)) { + drag(cx, view, at, &[]); +} + +/// Page one's notes once every edit has landed. +fn notes(cx: &mut VisualTestContext, view: &Entity) -> Marks { + settle(cx, |app| view.read(app).settled() && view.read(app).notes.contains_key(&0)); + cx.update(|_, app| view.read(app).notes[&0].clone()) +} + +/// What the panel shows now, sent `change`. +fn send(cx: &mut VisualTestContext, viewer: &Entity, change: Change) { + frames(cx); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + let shown = viewer.note_panel.read(cx).shown.expect("the panel follows the pick"); + viewer.change_note(shown, change, cx) + })); +} + +fn kinds(marks: &Marks) -> Vec { + marks.annots.iter().map(|a| a.kind.clone()).collect() +} + +#[test] +fn picked_text_highlights_in_one_step() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = marking(&mut cx, dir.path()); + tool(cx, &view, Tool::Highlight); + drag(cx, &view, (74.0, 66.0), &[(140.0, 66.0)]); + let marks = notes(cx, &view); + assert_eq!(kinds(&marks), ["Highlight"]); + assert!(!marks.annots[0].author.is_empty(), "signed by the account"); + assert!(cx.update(|_, app| view.read(app).selection.is_none()), "the pick is spent"); + cx.update(|_, app| view.update(app, |view, cx| view.undo(false, cx))); + assert!(notes(cx, &view).annots.is_empty()); +} + +#[test] +fn shapes_and_ink_land_where_they_were_drawn() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = marking(&mut cx, dir.path()); + tool(cx, &view, Tool::Square); + drag(cx, &view, (100.0, 400.0), &[(150.0, 420.0), (200.0, 450.0)]); + let square = notes(cx, &view).annots[0].rect; + assert!((square[0] - 100.0).abs() < 3.0 && (square[3] - 450.0).abs() < 3.0, "{square:?}"); + tool(cx, &view, Tool::Ink); + drag(cx, &view, (300.0, 500.0), &[(320.0, 520.0), (340.0, 500.0), (360.0, 530.0)]); + tool(cx, &view, Tool::Circle); + click(cx, &view, (400.0, 400.0)); + assert_eq!(kinds(¬es(cx, &view)), ["Square", "Ink"], "a click draws no ellipse"); +} + +#[test] +fn notes_and_stamps_ask_first_then_change_and_go() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = marking(&mut cx, dir.path()); + tool(cx, &view, Tool::Note); + click(cx, &view, (300.0, 300.0)); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::NewNote { .. })))); + send(cx, &viewer, Change::Note("Check this".into())); + notes(cx, &view); + tool(cx, &view, Tool::Stamp); + click(cx, &view, (300.0, 600.0)); + send(cx, &viewer, Change::Stamp("Draft".into())); + let marks = notes(cx, &view); + assert_eq!((kinds(&marks), marks.annots[0].contents.as_str(), marks.annots[1].icon.as_str()), (vec!["Text".into(), "Stamp".into()], "Check this", "Draft")); + tool(cx, &view, Tool::Read); + click(cx, &view, (305.0, 305.0)); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::Annot { .. }))), "a click picks the note"); + send(cx, &viewer, Change::Color(0x0000ff)); + let marks = notes(cx, &view); + assert_eq!((marks.annots[0].rgb, marks.annots[0].contents.as_str()), (Some(0x0000ff), "Check this")); + click(cx, &view, (305.0, 305.0)); + send(cx, &viewer, Change::Delete); + assert_eq!(kinds(¬es(cx, &view)), ["Stamp"]); +} + +/// Draws a field of `kind` over `at`, named `name`. +fn add_field(cx: &mut VisualTestContext, viewer: &Entity, view: &Entity, kind: FieldKind, at: [(f32, f32); 2], name: &str, options: &[&str]) { + tool(cx, view, Tool::Field(kind)); + drag(cx, view, at[0], &[at[1]]); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::NewField { .. })))); + send(cx, viewer, Change::Design(name.into(), options.iter().map(|o| o.to_string()).collect())); + notes(cx, view); +} + +#[test] +fn drawn_fields_fill_from_the_page() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = marking(&mut cx, dir.path()); + add_field(cx, &viewer, &view, FieldKind::Checkbox, [(72.0, 300.0), (72.0, 300.0)], "Agree", &[]); + add_field(cx, &viewer, &view, FieldKind::Choice, [(72.0, 340.0), (200.0, 360.0)], "Color", &["Red", "Blue"]); + tool(cx, &view, Tool::Read); + click(cx, &view, (78.0, 306.0)); + assert!(notes(cx, &view).fields[1].checked, "a click checks"); + click(cx, &view, (78.0, 306.0)); + assert!(!notes(cx, &view).fields[1].checked, "and clears"); + click(cx, &view, (100.0, 350.0)); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::Field { .. })))); + send(cx, &viewer, Change::Fill("Blue".into())); + notes(cx, &view); + // The old field sits 652 points down. + click(cx, &view, (100.0, 660.0)); + send(cx, &viewer, Change::Fill("Zoe".into())); + let values: Vec = notes(cx, &view).fields.iter().map(|f| f.value.clone()).collect(); + assert_eq!(values, ["Zoe", "Off", "Blue"]); +} + +#[test] +fn a_second_radio_joins_the_group_and_one_stays_on() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = marking(&mut cx, dir.path()); + add_field(cx, &viewer, &view, FieldKind::Radio, [(72.0, 300.0), (72.0, 300.0)], "Size", &[]); + tool(cx, &view, Tool::Field(FieldKind::Radio)); + click(cx, &view, (100.0, 300.0)); + frames(cx); + let typed = cx.update(|_, app| viewer.read(app).note_panel.read(app).name.clone().expect("drawn").read(app).text().to_string()); + assert_eq!(typed, "Size", "suggests the page's group"); + send(cx, &viewer, Change::Design(typed, Vec::new())); + notes(cx, &view); + tool(cx, &view, Tool::Read); + click(cx, &view, (78.0, 306.0)); + notes(cx, &view); + click(cx, &view, (106.0, 306.0)); + let fields = notes(cx, &view).fields; + assert_eq!((fields[1].checked, fields[2].checked), (false, true)); +} + +#[test] +fn a_click_on_a_box_while_an_edit_is_queued_does_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = marking(&mut cx, dir.path()); + add_field(cx, &viewer, &view, FieldKind::Checkbox, [(72.0, 300.0), (72.0, 300.0)], "Agree", &[]); + tool(cx, &view, Tool::Read); + cx.update(|_, app| view.update(app, |view, _| view.editing = true)); + click(cx, &view, (78.0, 306.0)); + assert!(cx.update(|_, app| view.read(app).queue.is_empty()), "nothing queued with old numbers"); +} + +#[test] +fn the_list_reads_every_page_and_jumps_to_a_pick() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = dir.path().join("three.pdf"); + let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]); + std::fs::write(&path, pdf_with(&[page, page, page], &[], &Extras::default())).expect("writes"); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); + let note = ely_pdf_engine::NewAnnot { page: 2, shape: ely_pdf_engine::Shape::Note((100.0, 500.0)), rgb: None, contents: "Last".into(), author: String::new() }; + cx.update(|_, app| view.update(app, |view, cx| view.annotate("adding a note", vec![note], cx))); + settle(cx, |app| view.read(app).settled()); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.side.update(cx, |side, cx| { + side.pane = crate::side::Pane::Notes; + cx.notify(); + }))); + settle(cx, |app| (0..3).all(|page| view.read(app).notes.contains_key(&page))); + let id = cx.update(|_, app| view.read(app).notes[&2].annots[0].id); + cx.update(|_, app| view.update(app, |view, cx| view.pick_note(2, id, cx))); + frames(cx); + assert_eq!(cx.update(|_, app| (view.read(app).current, view.read(app).aim)), (2, Some(Aim::Note(Pick::Annot { page: 2, id })))); +} + +#[test] +fn a_field_takes_a_new_name_and_options_and_no_change_is_no_step() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = marking(&mut cx, dir.path()); + add_field(cx, &viewer, &view, FieldKind::Choice, [(72.0, 340.0), (200.0, 360.0)], "Color", &["Red", "Blue"]); + let edited = cx.update(|_, app| view.read(app).edited); + click(cx, &view, (100.0, 350.0)); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::Field { .. }))), "the form tool picks a field"); + send(cx, &viewer, Change::Design("Color".into(), vec!["Red".into(), "Blue".into()])); + frames(cx); + assert_eq!(cx.update(|_, app| view.read(app).edited), edited, "as it was"); + send(cx, &viewer, Change::Design("Shade".into(), vec!["Green".into()])); + let field = notes(cx, &view).fields[1].clone(); + assert_eq!((field.name.as_str(), field.options), ("Shade", vec!["Green".to_string()])); +} + +#[test] +fn a_highlight_before_the_text_is_read_lands_when_it_arrives() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = marking(&mut cx, dir.path()); + tool(cx, &view, Tool::Highlight); + // A read seems in flight, so none starts yet. + cx.update(|_, app| view.update(app, |view, _| { + view.text.clear(); + view.asking_text.insert(0); + })); + drag(cx, &view, (74.0, 66.0), &[(140.0, 66.0)]); + assert!(cx.update(|_, app| view.read(app).marking.is_some()), "waits for the text"); + assert!(notes(cx, &view).annots.is_empty()); + cx.update(|_, app| view.update(app, |view, cx| { + view.asking_text.clear(); + view.page_text(0, cx); + })); + settle(cx, |app| view.read(app).edited == 1); + assert_eq!(kinds(¬es(cx, &view)), ["Highlight"]); +} + +#[test] +fn a_new_field_on_another_page_gets_a_free_name() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = dir.path().join("two.pdf"); + let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]); + std::fs::write(&path, pdf_with(&[page, page], &[], &Extras::default())).expect("writes"); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); + let first = ely_pdf_engine::NewField { kind: FieldKind::Text, name: "Text1".into(), rect: [72.0, 300.0, 272.0, 320.0], options: Vec::new() }; + cx.update(|_, app| view.update(app, |view, cx| view.edit("adding a field", move |w, doc| w.add_field(doc, 0, &first), cx))); + settle(cx, |app| view.read(app).settled()); + cx.update(|_, app| view.update(app, |view, cx| { + view.tool = Tool::Field(FieldKind::Text); + view.aim = Some(Aim::Note(Pick::NewField { page: 1, rect: [72.0, 300.0, 272.0, 320.0], kind: FieldKind::Text })); + cx.notify(); + })); + settle(cx, |app| viewer.read(app).note_panel.read(app).shown.is_some()); + let typed = cx.update(|_, app| viewer.read(app).note_panel.read(app).name.clone().expect("drawn").read(app).text().to_string()); + assert_eq!(typed, "Text2", "page one holds Text1"); +} + +#[test] +fn a_default_name_avoids_a_named_parent() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = dir.path().join("nested.pdf"); + let extras = Extras { fields: &[0], parent: Some("Text1"), ..Extras::default() }; + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes"); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + cx.update(|_, app| view.update(app, |view, cx| { + view.tool = Tool::Field(FieldKind::Text); + view.aim = Some(Aim::Note(Pick::NewField { page: 0, rect: [72.0, 300.0, 272.0, 320.0], kind: FieldKind::Text })); + cx.notify(); + })); + settle(cx, |app| viewer.read(app).note_panel.read(app).shown.is_some()); + let typed = cx.update(|_, app| viewer.read(app).note_panel.read(app).name.clone().expect("drawn").read(app).text().to_string()); + assert_eq!(typed, "Text2", "Text1 holds Text1.field0"); +} + +#[test] +fn a_default_name_avoids_a_field_with_no_widget() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = dir.path().join("bare.pdf"); + let extras = Extras { bare: Some(("Text1", "Child")), ..Extras::default() }; + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes"); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + cx.update(|_, app| view.update(app, |view, cx| { + view.tool = Tool::Field(FieldKind::Text); + view.aim = Some(Aim::Note(Pick::NewField { page: 0, rect: [72.0, 300.0, 272.0, 320.0], kind: FieldKind::Text })); + cx.notify(); + })); + settle(cx, |app| viewer.read(app).note_panel.read(app).shown.is_some()); + let typed = cx.update(|_, app| viewer.read(app).note_panel.read(app).name.clone().expect("drawn").read(app).text().to_string()); + assert_eq!(typed, "Text2", "Text1 has no widget but holds the name"); + send(cx, &viewer, Change::Design(typed, Vec::new())); + assert_eq!(notes(cx, &view).fields.len(), 1, "the field lands"); +} diff --git a/apps/ely-pdf/src/tests/organize.rs b/apps/ely-pdf/src/tests/organize.rs new file mode 100644 index 0000000..3716c54 --- /dev/null +++ b/apps/ely-pdf/src/tests/organize.rs @@ -0,0 +1,248 @@ +//! Organizing, undo, saving and output from the app (C11.2). + +use ely_office::{Closing as _, Dialog, Saving as _, Tabs as _}; +use ely_pdf_engine::Worker; +use gpui::{Entity, TestAppContext, VisualTestContext}; + +use super::{file, frames, settle, shown, windowed}; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// Three Letter pages: one, two, three. +fn three<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity, &'a mut VisualTestContext, Entity) { + let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"]), (612.0, 792.0, &["three"])]; + let (viewer, cx) = windowed(cx, dir, &[file(dir, "three.pdf", pages)], true); + let view = shown(cx, &viewer); + (viewer, cx, view) +} + +/// Each page's text, read by a worker of its own. +fn texts(path: &std::path::Path) -> Vec { + let mut worker = Worker::spawn().expect("a worker"); + let doc = worker.open(path).expect("opens").doc; + let count = worker.pages(doc).expect("counts"); + (0..count).map(|page| worker.text(doc, page).expect("reads").iter().map(|c| c.c).collect()).collect() +} + +fn dirty(cx: &mut VisualTestContext, viewer: &Entity) -> bool { + cx.update(|_, app| viewer.read(app).active_tab().expect("a tab").dirty(app)) +} + +/// Runs `step` on the view, then waits for its edits. +fn edit(cx: &mut VisualTestContext, view: &Entity, step: impl FnOnce(&mut PdfView, &mut gpui::Context)) { + cx.update(|_, app| view.update(app, step)); + settle(cx, |app| view.read(app).settled()); +} + +#[test] +fn a_delete_marks_the_tab_and_undo_clears_it() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = three(&mut cx, dir.path()); + assert!(!dirty(cx, &viewer)); + edit(cx, &view, |view, cx| { + view.picked = [1].into(); + view.edit("deleting", |worker, doc| worker.delete_pages(doc, &[1]), cx); + }); + frames(cx); + assert_eq!(cx.update(|_, app| view.read(app).sizes.len()), 2); + assert!(dirty(cx, &viewer), "an edit is unsaved"); + edit(cx, &view, |view, cx| view.undo(false, cx)); + frames(cx); + assert_eq!(cx.update(|_, app| view.read(app).sizes.len()), 3); + assert!(!dirty(cx, &viewer), "undone to the file as loaded"); +} + +#[test] +fn a_save_writes_the_edits_and_undo_after_it_is_unsaved_again() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = three(&mut cx, dir.path()); + edit(cx, &view, |view, cx| { + view.picked = [2].into(); + let to = 0; + view.move_to(to, cx); + }); + assert_eq!(cx.update(|_, app| view.read(app).picked.iter().copied().collect::>()), [0], "the moved page stays picked"); + let id = cx.update(|_, app| viewer.read(app).core.active.expect("a tab")); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.save(id, None, window, cx))); + settle(cx, |app| !viewer.read(app).active_tab().expect("a tab").dirty(app)); + assert_eq!(texts(&dir.path().join("three.pdf")), ["three", "one", "two"]); + edit(cx, &view, |view, cx| view.undo(false, cx)); + frames(cx); + assert!(dirty(cx, &viewer), "the file still holds the move"); + edit(cx, &view, |view, cx| view.undo(true, cx)); + frames(cx); + assert!(!dirty(cx, &viewer), "redone to what was saved"); +} + +#[test] +fn an_edit_queued_behind_another_is_refused() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = three(&mut cx, dir.path()); + cx.update(|_, app| view.update(app, |view, cx| { + view.edit("turning", |worker, doc| worker.rotate_pages(doc, &[0], 90), cx); + view.edit("deleting", |worker, doc| worker.delete_pages(doc, &[0]), cx); + })); + // The worker sees one edit at a time. + assert_eq!(cx.update(|_, app| (view.read(app).editing, view.read(app).queue.len())), (true, 1)); + settle(cx, |app| view.read(app).settled()); + // The delete aimed at the state before the turn. + let sizes = cx.update(|_, app| view.read(app).sizes.clone()); + assert_eq!(sizes, [(792.0, 612.0), (612.0, 792.0), (612.0, 792.0)], "only the turn landed"); +} + +#[test] +fn a_read_from_before_an_edit_is_dropped() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = three(&mut cx, dir.path()); + cx.update(|_, app| view.update(app, |view, cx| { + view.page_text(0, cx); + // As an edit landing first does. + view.version += 1; + })); + frames(cx); + assert!(cx.update(|_, app| view.read(app).text.is_empty()), "page one's old text is not kept"); +} + +#[test] +fn closing_an_edited_tab_asks_first() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = three(&mut cx, dir.path()); + edit(cx, &view, |view, cx| view.edit("turning", |worker, doc| worker.rotate_pages(doc, &[0], 90), cx)); + let id = cx.update(|_, app| viewer.read(app).core.active.expect("a tab")); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.request_close(id, ely_office::After::Tab, window, cx))); + settle(cx, |app| matches!(viewer.read(app).core.dialog, Some(Dialog::Unsaved { .. }))); +} + +#[test] +fn extract_and_split_write_new_files_and_refuse_to_overwrite() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = three(&mut cx, dir.path()); + let id = cx.update(|_, app| viewer.read(app).core.active.expect("a tab")); + let part = dir.path().join("part.pdf"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_pages(id, vec![0, 2], part.clone(), cx))); + settle(cx, |_| part.exists()); + frames(cx); + assert_eq!(texts(&part), ["one", "three"]); + // The open file itself is refused. + let open = dir.path().join("three.pdf"); + let before = std::fs::read(&open).expect("reads"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_pages(id, vec![0], open.clone(), cx))); + frames(cx); + assert_eq!(std::fs::read(&open).expect("reads"), before); + let out = dir.path().join("out"); + std::fs::create_dir(&out).expect("a folder"); + std::fs::write(out.join("three-2.pdf"), b"keep").expect("writes"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_parts(id, 1, out.clone(), cx))); + frames(cx); + let mut names: Vec = std::fs::read_dir(&out).expect("lists").map(|entry| entry.expect("an entry").file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + assert_eq!(names, ["three-2.pdf"], "a taken name writes nothing"); + std::fs::remove_file(out.join("three-2.pdf")).expect("removes"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_parts(id, 2, out.clone(), cx))); + settle(cx, |_| out.join("three-2.pdf").exists()); + frames(cx); + assert_eq!((texts(&out.join("three-1.pdf")), texts(&out.join("three-2.pdf"))), (vec!["one".into(), "two".into()], vec!["three".into()])); + assert_eq!(cx.update(|_, app| view.read(app).sizes.len()), 3, "the document stays"); +} + +#[test] +fn crop_margins_are_millimetres() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = three(&mut cx, dir.path()); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_crop(window, cx))); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + if let Some(crate::ask::Ask::Crop { margins, .. }) = viewer.ask.as_mut() { + *margins = [10.0, 0.0, 0.0, 25.4]; + } + viewer.crop(cx); + })); + settle(cx, |app| view.read(app).edited == 1); + let size = cx.update(|_, app| view.read(app).sizes[0]); + let mm = 72.0 / 25.4; + assert!((size.0 - (612.0 - 72.0)).abs() < 0.1 && (size.1 - (792.0 - 10.0 * mm)).abs() < 0.1, "{size:?}"); +} + +#[test] +fn picks_follow_plain_range_and_toggle_clicks() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = three(&mut cx, dir.path()); + let picked = |cx: &mut VisualTestContext| cx.update(|_, app| view.read(app).picked.iter().copied().collect::>()); + cx.update(|_, app| view.update(app, |view, cx| view.pick(0, (false, false), cx))); + cx.update(|_, app| view.update(app, |view, cx| view.pick(2, (true, false), cx))); + assert_eq!(picked(cx), [0, 1, 2]); + cx.update(|_, app| view.update(app, |view, cx| view.pick(1, (false, true), cx))); + assert_eq!(picked(cx), [0, 2]); + assert_eq!(cx.update(|_, app| view.read(app).chosen_pages()), [0, 2]); +} + +#[test] +fn a_range_pick_starts_over_after_pages_move() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = three(&mut cx, dir.path()); + cx.update(|_, app| view.update(app, |view, cx| view.pick(2, (false, false), cx))); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.delete(cx))); + settle(cx, |app| view.read(app).sizes.len() == 2); + cx.update(|_, app| view.update(app, |view, cx| view.pick(0, (true, false), cx))); + assert_eq!(cx.update(|_, app| view.read(app).chosen_pages()), [0], "no range from a page that went"); + edit(cx, &view, |view, cx| view.edit("blank", |worker, doc| worker.insert_blank(doc, 1, (100.0, 100.0)), cx)); + assert_eq!(cx.update(|_, app| view.read(app).sizes.len()), 3); +} + +#[test] +fn a_failed_edit_lets_a_waiting_save_go() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = three(&mut cx, dir.path()); + let id = cx.update(|_, app| viewer.read(app).core.active.expect("a tab")); + let ran = std::rc::Rc::new(std::cell::Cell::new(false)); + cx.update(|_, app| view.update(app, |view, cx| view.edit("cropping", |worker, doc| worker.crop_pages(doc, &[0], [900.0; 4]), cx))); + cx.update(|_, app| viewer.update(app, |viewer, _| { + let ran = ran.clone(); + viewer.when_settled(id, move |_, _, _| ran.set(true)); + })); + settle(cx, |_| ran.get()); +} + +#[test] +fn a_turned_view_crops_what_is_seen() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = three(&mut cx, dir.path()); + cx.update(|_, app| view.update(app, |view, _| view.rotate = 90)); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_crop(window, cx))); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + if let Some(crate::ask::Ask::Crop { margins, .. }) = viewer.ask.as_mut() { + *margins = [25.4, 0.0, 0.0, 0.0]; + } + viewer.crop(cx); + })); + settle(cx, |app| view.read(app).edited == 1); + let (w, h) = cx.update(|_, app| view.read(app).sizes[0]); + assert!((w - 540.0).abs() < 0.1 && (h - 792.0).abs() < 0.1, "the page's left went: {w} x {h}"); +} + +#[test] +fn the_window_closes_its_tabs_off_the_window() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = three(&mut cx, dir.path()); + assert!(!cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.should_close(window, cx))), "not at once"); + drop(view); + // Once its tab closes, the window itself goes. + for _ in 0..1000 { + cx.executor().tick(); + if cx.cx.update(|app| app.windows().is_empty()) { + return; + } + } + panic!("the window stayed"); +} diff --git a/apps/ely-pdf/src/tests/output.rs b/apps/ely-pdf/src/tests/output.rs new file mode 100644 index 0000000..c3d67ef --- /dev/null +++ b/apps/ely-pdf/src/tests/output.rs @@ -0,0 +1,164 @@ +//! Exports and the repaired copy (C11.8). + +use std::path::PathBuf; + +use ely_office::{Saving as _, Tabs as _}; +use ely_pdf_engine::Optimizing; +use gpui::TestAppContext; + +use super::{file, settle, shown, windowed}; +use crate::output::{Kind, image_names}; + +#[test] +fn image_names_pad_alike() { + assert_eq!(image_names("doc", &[0, 9], 12), ["doc-01.png", "doc-10.png"]); +} + +#[test] +fn pages_export_as_images_and_text() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["First"]), (612.0, 792.0, &["Second"])]); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + shown(cx, &viewer); + let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id); + let targets: Vec = ["doc-1.png", "doc-2.png"].iter().map(|name| dir.path().join(name)).collect(); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_images(id, (vec![0, 1], 0), 72.0, targets.clone(), cx))); + settle(cx, |_| targets.iter().all(|target| std::fs::metadata(target).is_ok_and(|meta| meta.len() > 100))); + let txt = dir.path().join("doc.txt"); + let how = Optimizing { compress: true, from: 300, to: 0 }; + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_export(id, Kind::Text, how, txt.clone(), cx))); + settle(cx, |_| txt.is_file()); + let text = std::fs::read_to_string(&txt).expect("UTF-8"); + assert!(text.contains("First") && text.contains("Second"), "{text}"); +} + +#[test] +fn a_damaged_file_opens_repaired_and_saves_as_a_new_file() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = file(dir.path(), "broken.pdf", &[(612.0, 792.0, &["Hello"])]); + let mut bytes = std::fs::read(&path).expect("reads"); + let at = bytes.windows(9).rposition(|w| w == b"startxref").expect("the trailer"); + bytes.truncate(at); + bytes.extend_from_slice(b"startxref\n9\n%%EOF\n"); + std::fs::write(&path, &bytes).expect("writes"); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true); + let view = shown(cx, &viewer); + assert_eq!(cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.risk)), Some(ely_office::Risk::Repaired)); + assert!(cx.update(|_, app| view.read(app).repair.as_ref().is_some_and(|report| !report.is_empty()))); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.show_repair(true, cx))); + super::frames(cx); + let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.write_as(id, path.clone(), None, window, cx))); + super::frames(cx); + assert_eq!(std::fs::read(&path).expect("reads"), bytes, "Save As refuses the damaged original"); + let fixed = dir.path().join("fixed.pdf"); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.write_as(id, fixed.clone(), None, window, cx))); + settle(cx, |app| fixed.is_file() && viewer.read(app).active_tab().is_some_and(|tab| tab.risk.is_none() && tab.view != view)); +} + +#[test] +fn an_edit_made_while_saving_stays_and_the_tab_reopens_after_the_next_save() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = file(dir.path(), "broken.pdf", &[(612.0, 792.0, &["Hello"])]); + let mut bytes = std::fs::read(&path).expect("reads"); + let at = bytes.windows(9).rposition(|w| w == b"startxref").expect("the trailer"); + bytes.truncate(at); + bytes.extend_from_slice(b"startxref\n9\n%%EOF\n"); + std::fs::write(&path, &bytes).expect("writes"); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + let (id, fixed) = (cx.update(|_, app| viewer.read(app).core.tabs[0].id), dir.path().join("fixed.pdf")); + cx.update(|window, app| viewer.update(app, |viewer, cx| { + viewer.write_as(id, fixed.clone(), None, window, cx); + // Lands after the save took its snapshot. + view.update(cx, |view, cx| view.edit("rotating", |w, doc| w.rotate_pages(doc, &[0], 90), cx)); + })); + settle(cx, |app| fixed.is_file() && view.read(app).edited == 1 && viewer.read(app).active_tab().is_some_and(|tab| !tab.exporting)); + for _ in 0..10 { + super::frames(cx); + } + let tab = cx.update(|_, app| viewer.read(app).active_tab().map(|tab| (tab.view == view, tab.dirty(app)))); + assert_eq!(tab, Some((true, true)), "the edit stays, unsaved"); + assert_eq!(cx.update(|_, app| view.read(app).sizes[0]), (792.0, 612.0)); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.save(id, None, window, cx))); + settle(cx, |app| viewer.read(app).active_tab().is_some_and(|tab| tab.view != view && !tab.reopen)); + let reopened = shown(cx, &viewer); + assert_eq!(cx.update(|_, app| reopened.read(app).sizes[0]), (792.0, 612.0), "the reopened file holds the edit"); +} + +#[test] +fn picked_pages_from_before_an_edit_export_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = file(dir.path(), "doc.pdf", &[(100.0, 100.0, &["One"]), (200.0, 100.0, &["Two"]), (300.0, 100.0, &["Three"])]); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id); + let target = dir.path().join("doc-2.png"); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + let version = view.read(cx).version; + view.update(cx, |view, cx| view.edit("deleting", |w, doc| w.delete_pages(doc, &[0]), cx)); + viewer.write_images(id, (vec![1], version), 72.0, vec![target.clone()], cx); + })); + settle(cx, |app| view.read(app).edited == 1 && view.read(app).settled()); + for _ in 0..10 { + super::frames(cx); + } + assert!(!target.exists(), "page 2 is another page now"); +} + +/// A page drawing `/Im`, a picture that is no zlib. +fn broken_picture(dir: &std::path::Path, height: f32, y: f32) -> (PathBuf, Vec) { + let picture = "<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length 12 >>\nstream\nnot a zlib!!\nendstream".to_string(); + let page = format!(" /MediaBox [0 0 612 {height}] /Resources << /XObject << /Im 5 0 R >> >>"); + let bytes = ely_pdf_engine::sample::one_page("", &page, &format!("q 100 0 0 100 72 {y} cm /Im Do Q"), &[picture]); + let path = dir.join("broken.pdf"); + std::fs::write(&path, &bytes).expect("writes"); + (path, bytes) +} + +#[test] +fn a_picture_that_breaks_as_it_draws_stops_an_overwrite() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (path, bytes) = broken_picture(dir.path(), 792.0, 600.0); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true); + shown(cx, &viewer); + settle(cx, |app| viewer.read(app).active_tab().and_then(|tab| tab.risk) == Some(ely_office::Risk::Repaired)); + let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.save(id, None, window, cx))); + for _ in 0..10 { + super::frames(cx); + } + assert_eq!(std::fs::read(&path).expect("reads"), bytes); +} + +#[test] +fn a_picture_never_drawn_is_checked_before_an_overwrite() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (one, _) = broken_picture(dir.path(), 792.0, 600.0); + // Thirty blank pages first: the picture's page is never drawn. + let mut worker = ely_pdf_engine::Worker::spawn().expect("the worker"); + let mut doc = worker.open(&one).expect("opens").doc; + for _ in 0..30 { + let mark = worker.insert_blank(doc, 0, (612.0, 792.0)).expect("inserts"); + doc = doc.at(&mark).expect("the new state"); + } + let path = dir.path().join("long.pdf"); + worker.save(doc, &path).expect("saves"); + let bytes = std::fs::read(&path).expect("reads"); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true); + shown(cx, &viewer); + for _ in 0..10 { + super::frames(cx); + } + let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id); + assert_eq!(cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.risk)), None, "nothing drawn yet"); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.save(id, None, window, cx))); + settle(cx, |app| viewer.read(app).active_tab().and_then(|tab| tab.risk) == Some(ely_office::Risk::Repaired)); + assert_eq!(std::fs::read(&path).expect("reads"), bytes, "Save turned into Save As"); +} diff --git a/apps/ely-pdf/src/tests/protect.rs b/apps/ely-pdf/src/tests/protect.rs new file mode 100644 index 0000000..d798004 --- /dev/null +++ b/apps/ely-pdf/src/tests/protect.rs @@ -0,0 +1,112 @@ +//! Limits, the owner password and protected copies (C11.7). + +use ely_pdf_engine::{Lock, Worker, proto::may, sample::{Extras, pdf_with}}; +use gpui::{Entity, TestAppContext, VisualTestContext}; + +use super::{frames, settle, shown, windowed}; +use crate::protect::Secure; +use crate::select::Selection; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// Opens a copy with no open password allowing only `allow`. +fn limited<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path, allow: u32) -> (Entity, &'a mut VisualTestContext, Entity) { + let plain = dir.join("plain.pdf"); + std::fs::write(&plain, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &Extras::default())).expect("writes"); + let mut worker = Worker::spawn().expect("the worker"); + let doc = worker.open(&plain).expect("opens").doc; + let path = dir.join("limited.pdf"); + let lock = Lock { user: String::new(), owner: "owner".into(), allow }; + worker.protect(doc, &path, Some(&lock)).expect("protects"); + let (viewer, cx) = windowed(cx, dir, &[path], true); + let view = shown(cx, &viewer); + (viewer, cx, view) +} + +fn type_in(cx: &mut VisualTestContext, viewer: &Entity, owner: bool, text: &str) { + cx.update(|_, app| { + let field = match viewer.read(app).secure.as_ref() { + Some(Secure::Owner { field, .. }) => field.clone(), + Some(Secure::Protect { user, owner: field, .. }) => if owner { field.clone() } else { user.clone() }, + None => panic!("no dialog"), + }; + field.update(app, |field, cx| field.set_text(text.to_string(), cx)); + }); +} + +#[test] +fn limits_hold_until_the_owner_password_lifts_them() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = limited(&mut cx, dir.path(), may::PRINT); + assert_eq!(cx.update(|_, app| view.read(app).allowed) & (may::ASSEMBLE | may::OWNER), 0); + cx.update(|_, app| view.update(app, |view, cx| view.edit("rotating", |w, doc| w.rotate_pages(doc, &[0], 90), cx))); + settle(cx, |app| view.read(app).settled()); + assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "refused"); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_owner(window, cx))); + type_in(cx, &viewer, true, "wrong"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.try_owner(cx))); + frames(cx); + assert!(cx.update(|_, app| viewer.read(app).secure.is_some()), "asks again"); + type_in(cx, &viewer, true, "owner"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.try_owner(cx))); + settle(cx, |app| view.read(app).allowed & may::OWNER != 0); + assert!(cx.update(|_, app| viewer.read(app).secure.is_none())); + cx.update(|_, app| view.update(app, |view, cx| view.edit("rotating", |w, doc| w.rotate_pages(doc, &[0], 90), cx))); + settle(cx, |app| view.read(app).edited == 1); +} + +#[test] +fn copying_is_refused_where_the_file_forbids_it() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = limited(&mut cx, dir.path(), may::PRINT); + cx.update(|_, app| view.update(app, |view, cx| view.page_text(0, cx))); + settle(cx, |app| view.read(app).text.contains_key(&0)); + cx.update(|_, app| view.update(app, |view, cx| { + view.selection = Some(Selection { anchor: (0, (70.0, 60.0)), head: (0, (140.0, 75.0)) }); + view.copy(cx); + })); + frames(cx); + assert!(cx.update(|_, app| app.read_from_clipboard()).is_none()); +} + +#[test] +fn protecting_needs_a_distinct_owner_password_and_writes_a_locked_copy() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = super::file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["Hello"])]); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + shown(cx, &viewer); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_protect(window, cx))); + type_in(cx, &viewer, false, "same"); + type_in(cx, &viewer, true, "same"); + assert!(cx.update(|_, app| viewer.read(app).lock(app)).is_none(), "owner equals open"); + type_in(cx, &viewer, true, "owner"); + let lock = cx.update(|_, app| viewer.read(app).lock(app)).flatten().expect("a lock"); + let tab = cx.update(|_, app| viewer.read(app).core.tabs[0].id); + let target = dir.path().join("locked.pdf"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_protected(tab, Some(lock), target.clone(), cx))); + settle(cx, |_| target.is_file()); + let mut worker = Worker::spawn().expect("the worker"); + let opened = worker.open(&target).expect("opens"); + assert!(opened.locked); + assert!(worker.unlock(opened.doc, "same").expect("tries")); +} + +#[test] +fn an_owner_reply_closes_only_its_own_dialog() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = limited(&mut cx, dir.path(), may::PRINT); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_owner(window, cx))); + type_in(cx, &viewer, true, "owner"); + cx.update(|window, app| viewer.update(app, |viewer, cx| { + viewer.try_owner(cx); + // A newer dialog opens before the answer. + viewer.secure = None; + viewer.ask_owner(window, cx); + })); + settle(cx, |app| view.read(app).allowed & may::OWNER != 0); + assert!(cx.update(|_, app| viewer.read(app).secure.is_some()), "the newer dialog stays"); +} diff --git a/apps/ely-pdf/src/tests/removal.rs b/apps/ely-pdf/src/tests/removal.rs new file mode 100644 index 0000000..f595484 --- /dev/null +++ b/apps/ely-pdf/src/tests/removal.rs @@ -0,0 +1,144 @@ +//! Marking, removing and cleaning from the app (C11.6). + +use ely_pdf_engine::{Cleaning, Worker, sample::{Extras, pdf_with}}; +use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext}; + +use super::input::over; +use super::{frames, settle, shown, windowed}; +use crate::content::Tool; +use crate::redact::Removal; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// Two lines and a title, actual size. +fn opened<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity, &'a mut VisualTestContext, Entity) { + let path = dir.join("doc.pdf"); + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &Extras::default())).expect("writes"); + let (viewer, cx) = windowed(cx, dir, &[path], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); + cx.update(|_, app| view.update(app, |view, cx| { + view.zoom = crate::layout::Zoom::Scale(1.0); + cx.notify(); + })); + frames(cx); + (viewer, cx, view) +} + +fn drag(cx: &mut VisualTestContext, view: &Entity, from: (f32, f32), to: (f32, f32)) { + frames(cx); + let (a, b) = (over(cx, view, 0, from), over(cx, view, 0, to)); + cx.simulate_mouse_down(a, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_move(b, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(b, MouseButton::Left, Modifiers::none()); +} + +fn text_of(path: &std::path::Path) -> String { + let mut worker = Worker::spawn().expect("the worker"); + let doc = worker.open(path).expect("opens").doc; + worker.text(doc, 0).expect("text").iter().map(|c| c.c).collect() +} + +#[test] +fn areas_mark_and_a_click_unmarks() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_, cx, view) = opened(&mut cx, dir.path()); + cx.update(|_, app| view.update(app, |view, cx| view.set_tool(Tool::RedactArea, cx))); + drag(cx, &view, (100.0, 300.0), (200.0, 350.0)); + assert_eq!(cx.update(|_, app| view.read(app).redactions.len()), 1); + drag(cx, &view, (150.0, 320.0), (150.0, 320.0)); + assert!(cx.update(|_, app| view.read(app).redactions.is_empty()), "a click inside takes it back"); +} + +#[test] +fn marked_text_is_removed_into_a_new_file() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = opened(&mut cx, dir.path()); + cx.update(|_, app| view.update(app, |view, cx| view.set_tool(Tool::RedactText, cx))); + drag(cx, &view, (73.0, 66.0), (110.0, 66.0)); + settle(cx, |app| !view.read(app).redactions.is_empty()); + let out = dir.path().join("out.pdf"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.ask_redact(cx))); + let asked = cx.update(|_, app| viewer.update(app, |viewer, _| viewer.removal.take().expect("asked"))); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_removal(asked, out.clone(), cx))); + settle(cx, |_| out.is_file()); + let left = text_of(&out); + assert!(!left.contains("Hello") && left.contains("Second line"), "{left}"); + assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "the open document is untouched"); +} + +#[test] +fn an_edit_drops_the_marks_and_none_asks_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = opened(&mut cx, dir.path()); + cx.update(|_, app| view.update(app, |view, cx| view.mark_regions(0, vec![[72.0, 60.0, 120.0, 80.0]], None, cx))); + cx.update(|_, app| view.update(app, |view, cx| view.edit("rotating", |w, doc| w.rotate_pages(doc, &[0], 90), cx))); + settle(cx, |app| view.read(app).edited == 1); + assert!(cx.update(|_, app| view.read(app).redactions.is_empty()), "pages may have moved"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.ask_redact(cx))); + assert!(cx.update(|_, app| viewer.read(app).removal.is_none())); +} + +#[test] +fn cleaning_counts_first_then_writes_a_clean_file() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = opened(&mut cx, dir.path()); + cx.update(|_, app| view.update(app, |view, cx| view.edit("titling", |w, doc| w.set_properties(doc, &[("Title", "Plan")]), cx))); + settle(cx, |app| view.read(app).edited == 1); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.ask_clean(cx))); + settle(cx, |app| viewer.read(app).removal.is_some()); + let (tab, version, reach) = cx.update(|_, app| match viewer.read(app).removal.as_ref() { + Some(Removal::Clean { tab, version, reach, .. }) => (*tab, *version, reach.clone()), + _ => panic!("a cleaning"), + }); + assert!(reach.meta >= 1, "{reach:?}"); + let out = dir.path().join("clean.pdf"); + let what = Cleaning { meta: true, ..Cleaning::default() }; + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_removal(Removal::Clean { tab, version, reach, what }, out.clone(), cx))); + settle(cx, |_| out.is_file()); + let mut worker = Worker::spawn().expect("the worker"); + let doc = worker.open(&out).expect("opens").doc; + assert_eq!(worker.properties(doc).expect("reads").title, None); +} + +#[test] +fn marks_changed_after_asking_write_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = opened(&mut cx, dir.path()); + cx.update(|_, app| view.update(app, |view, cx| view.mark_regions(0, vec![[72.0, 52.0, 120.0, 78.0]], None, cx))); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.ask_redact(cx))); + let asked = cx.update(|_, app| viewer.update(app, |viewer, _| viewer.removal.take().expect("asked"))); + cx.update(|_, app| view.update(app, |view, cx| view.mark_regions(0, vec![[72.0, 76.0, 200.0, 102.0]], None, cx))); + let out = dir.path().join("out.pdf"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_removal(asked, out.clone(), cx))); + for _ in 0..10 { + frames(cx); + } + assert!(!out.exists(), "the marks are not what was confirmed"); +} + +#[test] +fn a_scan_for_a_tab_left_behind_opens_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (a, b) = (dir.path().join("a.pdf"), dir.path().join("b.pdf")); + for path in [&a, &b] { + std::fs::write(path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &Extras::default())).expect("writes"); + } + let (viewer, cx) = windowed(&mut cx, dir.path(), &[a, b], true); + let first = cx.update(|_, app| viewer.read(app).core.tabs[0].id); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + viewer.core.active = Some(first); + viewer.ask_clean(cx); + viewer.core.active = Some(viewer.core.tabs[1].id); + })); + for _ in 0..20 { + frames(cx); + } + assert!(cx.update(|_, app| viewer.read(app).removal.is_none()), "another tab is in front"); +} diff --git a/apps/ely-pdf/src/tests/sign.rs b/apps/ely-pdf/src/tests/sign.rs new file mode 100644 index 0000000..510637c --- /dev/null +++ b/apps/ely-pdf/src/tests/sign.rs @@ -0,0 +1,230 @@ +//! Signatures beside the pages, signed copies, signed files (C11.7). + +use std::path::{Path, PathBuf}; + +use ely_office::{Saving as _, Tabs as _}; +use ely_pdf_engine::{Digest, Signing, Worker, sample::{Extras, pdf_with}}; +use ely_platform::trust::Trust; +use gpui::{Entity, TestAppContext, VisualTestContext}; + +use crate::view::PdfView; +use crate::viewer::Viewer; + +use super::{settle, shown, windowed}; +use crate::sign::SignAt; + +fn signing(name: &str) -> Signing { + let pfx = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/pdf/signer.p12"); + Signing { + page: 0, + object: None, + rect: [72.0, 600.0, 272.0, 660.0], + name: name.into(), + pfx: pfx.to_str().expect("UTF-8").into(), + pfx_password: "ely".into(), + reason: String::new(), + location: String::new(), + } +} + +fn plain(dir: &Path) -> PathBuf { + let path = dir.join("plain.pdf"); + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &Extras::default())).expect("writes"); + path +} + +fn signed(dir: &Path) -> PathBuf { + let mut worker = Worker::spawn().expect("the worker"); + let doc = worker.open(&plain(dir)).expect("opens").doc; + let path = dir.join("signed.pdf"); + worker.sign(doc, &path, &signing("Signature1")).expect("signs"); + path +} + +#[test] +fn a_signed_file_lists_its_checks_and_saves_as_a_copy() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = signed(dir.path()); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + assert_eq!(cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.risk)), Some(ely_office::Risk::Signed)); + let path = tab_path(cx, &viewer); + cx.update(|_, app| view.update(app, |view, cx| view.ask_signatures(path, cx))); + settle(cx, |app| view.read(app).signatures.is_some()); + cx.update(|_, app| { + let read = view.read(app); + let checked = &read.signatures.as_ref().expect("read").1[0]; + assert_eq!(checked.field.signed.as_ref().map(|s| s.digest), Some(Digest::Intact)); + assert!(matches!(checked.trust, Some(Ok(Trust::Untrusted(_)))), "self-signed"); + }); + // The dialog draws every fact. + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.show_signatures(true, cx))); + super::frames(cx); + assert!(cx.update(|_, app| viewer.read(app).signs_open)); +} + +#[test] +fn signing_writes_a_signed_copy_and_leaves_the_document() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[plain(dir.path())], true); + let view = shown(cx, &viewer); + let at = SignAt { page: 0, object: None, rect: [72.0, 600.0, 272.0, 660.0], version: 0 }; + cx.update(|_, app| view.update(app, |view, _| view.sign_at = Some(at))); + let (tab, target) = (cx.update(|_, app| viewer.read(app).core.tabs[0].id), dir.path().join("out.pdf")); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_signed(tab, at, signing(""), target.clone(), cx))); + settle(cx, |app| target.is_file() && view.read(app).sign_at.is_none()); + let mut worker = Worker::spawn().expect("the worker"); + let doc = worker.open(&target).expect("opens").doc; + let found = worker.signatures(doc, &target).expect("reads"); + assert_eq!((found.len(), found[0].name.as_str()), (1, "Signature1")); + assert_eq!(cx.update(|_, app| view.read(app).edited), 0); +} + +#[test] +fn a_place_from_before_an_edit_signs_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[plain(dir.path())], true); + let view = shown(cx, &viewer); + let at = SignAt { page: 0, object: None, rect: [72.0, 600.0, 272.0, 660.0], version: 0 }; + cx.update(|_, app| view.update(app, |view, cx| { + view.sign_at = Some(at); + view.edit("rotating", |w, doc| w.rotate_pages(doc, &[0], 90), cx) + })); + settle(cx, |app| view.read(app).edited == 1); + let (tab, target) = (cx.update(|_, app| viewer.read(app).core.tabs[0].id), dir.path().join("out.pdf")); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_signed(tab, at, signing(""), target.clone(), cx))); + super::frames(cx); + assert!(!target.exists()); +} + +#[test] +fn a_finished_signing_clears_only_its_own_place() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (a, b) = (plain(dir.path()), dir.path().join("b.pdf")); + std::fs::copy(&a, &b).expect("copies"); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[a, b], true); + let (first, second) = cx.update(|_, app| (viewer.read(app).core.tabs[0].view.clone(), viewer.read(app).core.tabs[1].view.clone())); + let at = SignAt { page: 0, object: None, rect: [72.0, 600.0, 272.0, 660.0], version: 0 }; + cx.update(|_, app| { + first.update(app, |view, _| view.sign_at = Some(at)); + second.update(app, |view, _| view.sign_at = Some(at)); + }); + let (tab, target) = (cx.update(|_, app| viewer.read(app).core.tabs[0].id), dir.path().join("out.pdf")); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_signed(tab, at, signing(""), target.clone(), cx))); + settle(cx, |app| target.is_file() && first.read(app).sign_at.is_none()); + assert_eq!(cx.update(|_, app| second.read(app).sign_at), Some(at), "the other tab keeps its place"); +} + +fn tab_path(cx: &mut VisualTestContext, viewer: &Entity) -> PathBuf { + cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.session.path().map(PathBuf::from)).expect("a file")) +} + +/// Whether the tab's file lists a signature. +fn shows_signed(cx: &mut VisualTestContext, viewer: &Entity, view: &Entity) -> bool { + let path = tab_path(cx, viewer); + cx.update(|_, app| view.update(app, |view, cx| view.ask_signatures(path.clone(), cx))); + settle(cx, |app| view.read(app).signatures.as_ref().is_some_and(|(read, _)| *read == path)); + cx.update(|_, app| view.read(app).signatures.as_ref().expect("read").1.iter().any(|c| c.field.signed.is_some())) +} + +/// Saves the signed tab as `copy`; waits for the reopen. +fn save_signed_as(cx: &mut VisualTestContext, viewer: &Entity, old: &Entity, copy: &Path) -> Entity { + let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id); + let target = copy.to_path_buf(); + std::fs::write(copy, b"").expect("a place"); + let copy = std::fs::canonicalize(copy).expect("the real path"); + std::fs::remove_file(©).expect("clears the place"); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.write_as(id, target, None, window, cx))); + settle(cx, |app| { + let read = viewer.read(app); + read.core.tabs.len() == 1 && read.active_tab().is_some_and(|tab| tab.session.path() == Some(copy.as_path()) && tab.view != *old) + }); + shown(cx, viewer) +} + +#[test] +fn a_signed_file_saved_as_a_copy_reopens_as_the_copy() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = signed(dir.path()); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + assert!(shows_signed(cx, &viewer, &view)); + cx.update(|_, app| view.update(app, |view, cx| view.edit("rotating", |w, doc| w.rotate_pages(doc, &[0], 90), cx))); + settle(cx, |app| view.read(app).edited == 1); + let copy = dir.path().join("copy.pdf"); + let reopened = save_signed_as(cx, &viewer, &view, ©); + let tab = cx.update(|_, app| (viewer.read(app).active_tab().map(|tab| (tab.risk, tab.dirty(app))))); + assert_eq!(tab, Some((None, false)), "an unsigned, saved copy"); + assert!(!shows_signed(cx, &viewer, &reopened), "the copy holds no signature"); + assert_eq!(cx.update(|_, app| reopened.read(app).sizes[0]), (792.0, 612.0), "the edit is in the copy"); +} + +#[test] +fn signing_after_save_as_signs_the_copy() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = signed(dir.path()); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + let copy = dir.path().join("copy.pdf"); + let reopened = save_signed_as(cx, &viewer, &view, ©); + let mut worker = Worker::spawn().expect("the worker"); + let read = worker.open(©).expect("opens").doc; + let field = worker.signatures(read, ©).expect("reads")[0].clone(); + assert!(field.signed.is_none(), "the copy's field is empty"); + let at = SignAt { page: 0, object: Some(field.object), rect: field.rect, version: cx.update(|_, app| reopened.read(app).version) }; + cx.update(|_, app| reopened.update(app, |view, _| view.sign_at = Some(at))); + let (tab, target) = (cx.update(|_, app| viewer.read(app).core.tabs[0].id), dir.path().join("resigned.pdf")); + let mut how = signing(""); + how.object = Some(field.object); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_signed(tab, at, how, target.clone(), cx))); + settle(cx, |_| target.is_file()); + let doc = worker.open(&target).expect("opens").doc; + let found = worker.signatures(doc, &target).expect("reads"); + assert_eq!(found.len(), 1, "only the copy's one field"); + assert_eq!(found[0].signed.as_ref().map(|s| (s.digest, s.later)), Some((Digest::Intact, false))); +} + +#[test] +fn a_failed_save_as_keeps_the_signed_file_and_its_signatures() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = signed(dir.path()); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + let (id, blocked) = (cx.update(|_, app| viewer.read(app).core.tabs[0].id), dir.path().join("directory.pdf")); + std::fs::create_dir(&blocked).expect("a directory in the way"); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.write_as(id, blocked.clone(), None, window, cx))); + for _ in 0..20 { + super::frames(cx); + } + assert_eq!(cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.risk)), Some(ely_office::Risk::Signed)); + assert!(shows_signed(cx, &viewer, &view), "the signed file and its list are as they were"); +} + +#[test] +fn reopening_the_dialog_checks_the_file_as_it_is_now() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = signed(dir.path()); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true); + let view = shown(cx, &viewer); + let digest = |cx: &mut VisualTestContext| { + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.show_signatures(true, cx))); + settle(cx, |app| view.read(app).signatures.is_some()); + let digest = cx.update(|_, app| view.read(app).signatures.as_ref().expect("read").1[0].field.signed.as_ref().map(|s| s.digest)); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.show_signatures(false, cx))); + digest + }; + assert_eq!(digest(cx), Some(Digest::Intact)); + let mut bytes = std::fs::read(&path).expect("reads"); + let at = bytes.windows(5).position(|w| w == b"Hello").expect("the text"); + bytes[at] = b'J'; + std::fs::write(&path, bytes).expect("writes"); + assert_eq!(digest(cx), Some(Digest::Changed)); +} diff --git a/apps/ely-pdf/src/tests/structure.rs b/apps/ely-pdf/src/tests/structure.rs new file mode 100644 index 0000000..b35872f --- /dev/null +++ b/apps/ely-pdf/src/tests/structure.rs @@ -0,0 +1,236 @@ +//! Links, bookmarks, properties, attachments, OCR from the app (C11.5). + +use ely_pdf_engine::{ + Worker, + sample::{Extras, pdf, pdf_with, scan}, +}; +use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext}; + +use super::input::over; +use super::{frames, settle, shown, windowed}; +use crate::annotate::Pick; +use crate::content::{Aim, Tool}; +use crate::marking::Change; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// Two pages, a bookmark, a link to page two. +fn opened<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity, &'a mut VisualTestContext, Entity) { + let path = dir.join("doc.pdf"); + let pages: [(f32, f32, &[&str]); 2] = [(612.0, 792.0, &["Page one"]), (612.0, 792.0, &["Page two"])]; + std::fs::write(&path, pdf_with(&pages, &[("One", 0, &[])], &Extras { links: &[(0, 1)], ..Extras::default() })).expect("writes"); + let (viewer, cx) = windowed(cx, dir, &[path], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); + cx.update(|_, app| view.update(app, |view, cx| { + view.zoom = crate::layout::Zoom::Scale(1.0); + view.mode = crate::layout::Mode::Single; + cx.notify(); + })); + settle(cx, |app| view.read(app).links.contains_key(&0)); + (viewer, cx, view) +} + +fn press(cx: &mut VisualTestContext, view: &Entity, from: (f32, f32), to: (f32, f32)) { + frames(cx); + let (a, b) = (over(cx, view, 0, from), over(cx, view, 0, to)); + cx.simulate_mouse_down(a, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_move(b, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(b, MouseButton::Left, Modifiers::none()); +} + +fn landed(cx: &mut VisualTestContext, view: &Entity, edits: u64) { + settle(cx, |app| view.read(app).edited == edits && view.read(app).settled() && view.read(app).links.contains_key(&0)); +} + +fn send(cx: &mut VisualTestContext, viewer: &Entity, change: Change) { + frames(cx); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + let shown = viewer.note_panel.read(cx).shown.expect("the panel follows the pick"); + viewer.change_note(shown, change, cx) + })); +} + +#[test] +fn a_click_follows_a_link_and_the_link_tool_adds_retargets_and_deletes() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = opened(&mut cx, dir.path()); + // The sample link covers 72..200 across, 692..720 down. + press(cx, &view, (100.0, 700.0), (100.0, 700.0)); + settle(cx, |app| view.read(app).current == 1); + cx.update(|_, app| view.update(app, |view, cx| view.show(0, 0.0, cx))); + cx.update(|_, app| view.update(app, |view, cx| view.set_tool(Tool::Link, cx))); + press(cx, &view, (72.0, 300.0), (200.0, 320.0)); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::NewLink { .. })))); + send(cx, &viewer, Change::Target("javascript:x".into())); + frames(cx); + assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "a bad target is refused"); + send(cx, &viewer, Change::Target("https://example.com/".into())); + landed(cx, &view, 1); + let links = cx.update(|_, app| view.read(app).links[&0].clone()); + assert_eq!((links.len(), links[1].uri.as_str()), (2, "https://example.com/")); + press(cx, &view, (100.0, 310.0), (100.0, 310.0)); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::Link { index: 1, .. })))); + send(cx, &viewer, Change::Target("2".into())); + landed(cx, &view, 2); + assert_eq!(cx.update(|_, app| view.read(app).links[&0][1].page), Some(1)); + press(cx, &view, (100.0, 310.0), (100.0, 310.0)); + send(cx, &viewer, Change::Delete); + landed(cx, &view, 3); + assert_eq!(cx.update(|_, app| view.read(app).links[&0].len()), 1); +} + +#[test] +fn bookmarks_add_after_the_pick_rename_and_delete() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = opened(&mut cx, dir.path()); + let titles = |cx: &mut VisualTestContext| cx.update(|_, app| view.read(app).marks.iter().map(|m| m.title.clone()).collect::>()); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_bookmark(true, window, cx))); + let typed = cx.update(|_, app| viewer.read(app).naming.as_ref().expect("asked").field.read(app).text().to_string()); + let label = cx.update(|_, app| Viewer::text(app, "pdf.page.label", &[("n", "1")]).to_string()); + assert_eq!(typed, label, "named for the page"); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.name_bookmark("Start".into(), cx))); + landed(cx, &view, 1); + assert_eq!(titles(cx), ["One", "Start"]); + cx.update(|_, app| view.update(app, |view, _| view.mark_pick = Some(0))); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_bookmark(false, window, cx))); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.name_bookmark("First".into(), cx))); + landed(cx, &view, 2); + assert_eq!(titles(cx), ["First", "Start"]); + cx.update(|_, app| view.update(app, |view, _| view.mark_pick = Some(1))); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.delete_bookmark(cx))); + landed(cx, &view, 3); + assert_eq!(titles(cx), ["First"]); +} + +#[test] +fn properties_show_and_set_what_changed() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = opened(&mut cx, dir.path()); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_properties(window, cx))); + settle(cx, |app| viewer.read(app).props.is_some()); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + let field = viewer.props.as_ref().expect("open").fields[0].clone(); + field.update(cx, |field, cx| field.set_text("Report", cx)); + viewer.set_properties(cx) + })); + landed(cx, &view, 1); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_properties(window, cx))); + settle(cx, |app| viewer.read(app).props.is_some()); + assert_eq!(cx.update(|_, app| viewer.read(app).props.as_ref().expect("open").read.title.clone()), Some("Report".into())); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.set_properties(cx))); + frames(cx); + assert_eq!(cx.update(|_, app| view.read(app).edited), 1, "nothing changed, no step"); +} + +#[test] +fn attachments_list_save_and_remove() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, view) = opened(&mut cx, dir.path()); + let file = dir.path().join("notes.txt"); + std::fs::write(&file, "notes").expect("writes"); + cx.update(|_, app| view.update(app, |view, cx| view.edit("attaching", move |w, doc| w.attach(doc, "notes.txt", &file), cx))); + landed(cx, &view, 1); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.side.update(cx, |side, cx| { + side.pane = crate::side::Pane::Files; + cx.notify(); + }))); + settle(cx, |app| view.read(app).files.as_ref().is_some_and(|files| files.len() == 1)); + let out = dir.path().join("saved.txt"); + let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_attachment(id, "n:notes.txt".into(), out.clone(), cx))); + settle(cx, |_| out.is_file()); + assert_eq!(std::fs::read(&out).expect("reads"), b"notes"); + cx.update(|_, app| view.update(app, |view, _| view.file_pick = Some("n:notes.txt".into()))); + cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.remove_attachment(cx))); + settle(cx, |app| view.read(app).files.as_ref().is_some_and(Vec::is_empty)); +} + +/// A scan of two English lines. +fn scanned(dir: &std::path::Path) -> std::path::PathBuf { + let source = dir.join("text.pdf"); + std::fs::write(&source, pdf(&[(612.0, 792.0, &["Hello world", "Second line"])], &[])).expect("writes"); + let mut worker = Worker::spawn().expect("the worker"); + let doc = worker.open(&source).expect("opens").doc; + let s: f32 = 200.0 / 72.0; + let (w, h) = ((612.0 * s).ceil() as i32, (792.0 * s).ceil() as i32); + let bgra = worker.render(doc, 0, (s, 0), [0, 0, w, h], false).expect("draws").bgra; + let gray: Vec = bgra.chunks_exact(4).map(|p| ((u16::from(p[0]) + u16::from(p[1]) + u16::from(p[2])) / 3) as u8).collect(); + let path = dir.join("scan.pdf"); + std::fs::write(&path, scan((612.0, 792.0), (w as u32, h as u32), &gray)).expect("writes"); + path +} + +#[test] +fn ocr_reads_the_pages_then_lays_their_text_in_one_step() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = scanned(dir.path()); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + cx.update(|window, app| viewer.update(app, |viewer, cx| { + viewer.ask_ocr(window, cx); + viewer.start_ocr(cx); + })); + assert!(cx.update(|_, app| viewer.read(app).ocr.as_ref().is_some_and(|ocr| ocr.run.is_some())), "reading"); + settle(cx, |app| viewer.read(app).ocr.is_none() && view.read(app).edited == 1); + let text = cx.update(|_, app| { + let doc = view.read(app).doc; + viewer.read(app).core.worker.lock().expect("the worker lock").get().expect("started").text(doc, 0).expect("text") + }); + let read: String = text.iter().map(|c| c.c).collect(); + assert!(read.contains("Hello world"), "{read}"); + // A second run skips the page that has text now. + cx.update(|window, app| viewer.update(app, |viewer, cx| { + viewer.ask_ocr(window, cx); + viewer.start_ocr(cx); + })); + settle(cx, |app| viewer.read(app).ocr.is_none()); + frames(cx); + assert_eq!(cx.update(|_, app| view.read(app).edited), 1, "nothing to lay"); +} + +#[test] +fn a_cancelled_ocr_lays_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = scanned(dir.path()); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + cx.update(|window, app| viewer.update(app, |viewer, cx| { + viewer.ask_ocr(window, cx); + viewer.start_ocr(cx); + viewer.ocr = None; + })); + for _ in 0..20 { + frames(cx); + } + assert_eq!(cx.update(|_, app| view.read(app).edited), 0); +} + +#[test] +fn a_cancelled_run_cannot_touch_the_next() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = scanned(dir.path()); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + let tab = cx.update(|_, app| viewer.read(app).core.tabs[0].id); + let first = cx.update(|window, app| viewer.update(app, |viewer, cx| { + viewer.ask_ocr(window, cx); + viewer.start_ocr(cx); + viewer.ocr = None; + viewer.ask_ocr(window, cx); + viewer.start_ocr(cx); + viewer.ocr_runs - 1 + })); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + viewer.ocr_read((tab, first, 0), Err(std::io::Error::other("from the first run")), cx) + })); + assert!(cx.update(|_, app| viewer.read(app).ocr.is_some()), "the second run goes on"); + settle(cx, |app| viewer.read(app).ocr.is_none() && view.read(app).edited == 1); +} diff --git a/apps/ely-pdf/src/tests/view.rs b/apps/ely-pdf/src/tests/view.rs new file mode 100644 index 0000000..975ea9a --- /dev/null +++ b/apps/ely-pdf/src/tests/view.rs @@ -0,0 +1,115 @@ +//! Password prompts, zoomed pairs, jumps across (C11.1). + +use ely_office::{Dialog, Dialogs as _, Tabs as _}; +use gpui::{Entity, TestAppContext, VisualTestContext, point, px}; + +use super::{file, frames, settle, shown, windowed}; +use crate::layout::{Mode, Zoom}; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// The file the open prompt asks for. +fn asking(app: &gpui::App, viewer: &Entity) -> Option { + match &viewer.read(app).core.dialog { + Some(Dialog::Password { path, .. }) => Some(path.clone()), + _ => None, + } +} + +/// Two files asked for at once, as locked loads do. +fn asked<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity, &'a mut VisualTestContext, [std::path::PathBuf; 2]) { + let (viewer, cx) = windowed(cx, dir, &[], true); + let paths = [file(dir, "a.pdf", &[(612.0, 792.0, &["a"])]), file(dir, "b.pdf", &[(612.0, 792.0, &["b"])])]; + cx.update(|window, app| viewer.update(app, |viewer, cx| { + for path in &paths { + viewer.ask_password(path.clone(), false, false, window, cx); + } + })); + frames(cx); + (viewer, cx, paths) +} + +#[test] +fn a_typed_password_opens_the_file() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, paths) = asked(&mut cx, dir.path()); + // An open file takes any password. + cx.simulate_input("secret"); + cx.simulate_keystrokes("enter"); + settle(cx, |app| viewer.read(app).core.tabs.len() == 1); + assert_eq!(cx.update(|_, app| asking(app, &viewer)), Some(paths[1].clone()), "the next asks"); +} + +#[test] +fn escape_gives_up_each_queued_file_in_turn() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (viewer, cx, paths) = asked(&mut cx, dir.path()); + assert_eq!(cx.update(|_, app| asking(app, &viewer)), Some(paths[0].clone())); + cx.simulate_keystrokes("escape"); + settle(cx, |app| asking(app, &viewer) == Some(paths[1].clone())); + frames(cx); + cx.simulate_keystrokes("escape"); + settle(cx, |app| viewer.read(app).core.dialog.is_none()); + assert!(cx.update(|_, app| viewer.read(app).active_tab().is_none())); +} + +/// Two zoomed Letter pages side by side. +fn zoomed_pair<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity, &'a mut VisualTestContext) { + let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"])]; + let (viewer, cx) = windowed(cx, dir, &[file(dir, "pair.pdf", pages)], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > px(100.0)); + cx.update(|_, app| view.update(app, |view, cx| { + (view.mode, view.zoom) = (Mode::Two, Zoom::Scale(4.0)); + cx.notify(); + })); + frames(cx); + (view, cx) +} + +#[test] +fn two_pages_keep_the_right_hand_page() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"]), (612.0, 792.0, &["three"])]; + let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "three.pdf", pages)], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > px(100.0)); + cx.update(|_, app| view.update(app, |view, cx| { + view.mode = Mode::Two; + view.show(1, 0.0, cx); + })); + frames(cx); + assert_eq!(cx.update(|_, app| view.read(app).current), 1); +} + +#[test] +fn scrolling_across_to_the_right_page_makes_it_current() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (view, cx) = zoomed_pair(&mut cx, dir.path()); + cx.update(|_, app| view.update(app, |view, cx| { + let right = view.laid_out().placed[1].x; + let y = view.scroll.offset().y; + view.scroll.set_offset(point(px(-right), y)); + cx.notify(); + })); + frames(cx); + assert_eq!(cx.update(|_, app| view.read(app).current), 1); +} + +#[test] +fn a_jump_to_the_right_page_brings_it_across() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (view, cx) = zoomed_pair(&mut cx, dir.path()); + cx.update(|_, app| view.update(app, |view, cx| view.show(1, 0.0, cx))); + frames(cx); + let (x, left, wide) = cx.update(|_, app| { + let view = view.read(app); + (view.laid_out().placed[1].x, -f32::from(view.scroll.offset().x), f32::from(view.room.size.width)) + }); + assert!(x >= left && x < left + wide, "page at {x}, view {left}..{}", left + wide); +} diff --git a/apps/ely-pdf/src/tests/visible.rs b/apps/ely-pdf/src/tests/visible.rs new file mode 100644 index 0000000..465a27e --- /dev/null +++ b/apps/ely-pdf/src/tests/visible.rs @@ -0,0 +1,103 @@ +//! Visible signatures placed as ink, text or a picture (C11.7). + +use std::path::Path; + +use gpui::{Entity, TestAppContext, VisualTestContext}; + +use super::{settle, shown, windowed}; +use crate::content::Tool; +use crate::view::PdfView; +use crate::visible::{Stamp, fitted, placed}; + +fn opened<'a>(cx: &'a mut TestAppContext, dir: &Path) -> (&'a mut VisualTestContext, Entity) { + let path = super::file(dir, "doc.pdf", &[(612.0, 792.0, &["Hello"])]); + let (viewer, cx) = windowed(cx, dir, &[path], true); + let view = shown(cx, &viewer); + (cx, view) +} + +fn place(cx: &mut VisualTestContext, view: &Entity, stamp: Stamp) { + cx.update(|_, app| view.update(app, |view, cx| { + view.set_tool(Tool::PlaceSignature, cx); + view.stamp = Some(stamp); + view.place_stamp(0, [100.0, 500.0, 100.0, 500.0], true, cx); + })); + settle(cx, |app| view.read(app).edited == 1); + assert_eq!(cx.update(|_, app| view.read(app).tool), Tool::Read, "one place, then reading"); +} + +#[test] +fn strokes_fit_by_their_longer_side_and_a_click_places_a_set_width() { + let (strokes, aspect) = fitted(&[vec![(10.0, 10.0), (110.0, 60.0)]]).expect("a stroke"); + assert_eq!((strokes[0][1], aspect), ((1.0, 0.5), 0.5)); + assert!(fitted(&[vec![(1.0, 1.0)]]).is_none(), "a dot is not a signature"); + assert_eq!(placed(0.5, [10.0, 20.0, 10.0, 20.0], true), [10.0, 20.0, 170.0, 100.0]); + assert_eq!(placed(0.5, [0.0, 0.0, 300.0, 50.0], false), [0.0, 0.0, 100.0, 50.0], "fits the drag"); +} + +#[test] +fn a_drawn_signature_lands_as_ink() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (cx, view) = opened(&mut cx, dir.path()); + place(cx, &view, Stamp::Ink { strokes: vec![vec![(0.0, 0.0), (1.0, 0.3)]], aspect: 0.3 }); + cx.update(|_, app| view.update(app, |view, cx| view.ask_notes([0], cx))); + settle(cx, |app| view.read(app).notes.contains_key(&0)); + let kinds: Vec = cx.update(|_, app| view.read(app).notes[&0].annots.iter().map(|a| a.kind.clone()).collect()); + assert_eq!(kinds, vec!["Ink".to_string()]); +} + +#[test] +fn a_typed_signature_lands_as_text() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (cx, view) = opened(&mut cx, dir.path()); + place(cx, &view, Stamp::Typed { text: "Ada Lovelace".into(), font: "script".into() }); + cx.update(|_, app| view.update(app, |view, cx| view.page_text(0, cx))); + settle(cx, |app| view.read(app).text.contains_key(&0)); + let text: String = cx.update(|_, app| view.read(app).text[&0].iter().map(|c| c.c).collect()); + assert!(text.contains("Ada Lovelace"), "{text}"); +} + +#[test] +fn a_picture_lands_in_its_own_proportions() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (cx, view) = opened(&mut cx, dir.path()); + let picture = dir.path().join("sig.png"); + image::RgbImage::from_pixel(40, 10, image::Rgb([0, 0, 0])).save(&picture).expect("writes"); + place(cx, &view, Stamp::Picture { path: picture, aspect: 0.25 }); + cx.update(|_, app| view.update(app, |view, cx| { + view.set_tool(Tool::Edit, cx); + view.ask_layers(&[0], cx); + })); + settle(cx, |app| view.read(app).layers.contains_key(&0)); + let drawn = cx.update(|_, app| view.read(app).layers[&0].drawn.clone()); + assert_eq!(drawn.len(), 1); + let r = drawn[0].rect; + assert!(((r[2] - r[0]) - 160.0).abs() < 0.5 && ((r[3] - r[1]) - 40.0).abs() < 0.5, "{r:?}"); +} + +#[test] +fn the_dialog_hands_a_drawing_to_the_place_tool() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = super::file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["Hello"])]); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_visible(window, cx))); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + let dot = vec![gpui::point(gpui::px(5.0), gpui::px(5.0))]; + viewer.making.as_mut().expect("open").strokes = vec![dot]; + viewer.ready_stamp(cx); + })); + assert!(cx.update(|_, app| viewer.read(app).making.is_some()), "a dot is not ready"); + cx.update(|_, app| viewer.update(app, |viewer, cx| { + viewer.making.as_mut().expect("open").strokes = vec![vec![gpui::point(gpui::px(0.0), gpui::px(0.0)), gpui::point(gpui::px(80.0), gpui::px(20.0))]]; + viewer.ready_stamp(cx); + })); + assert!(cx.update(|_, app| viewer.read(app).making.is_none())); + let (tool, stamp) = cx.update(|_, app| (view.read(app).tool, view.read(app).stamp.clone())); + assert_eq!(tool, Tool::PlaceSignature); + assert!(matches!(stamp, Some(Stamp::Ink { aspect, .. }) if (aspect - 0.25).abs() < 1e-6)); +} diff --git a/apps/ely-pdf/src/view.rs b/apps/ely-pdf/src/view.rs new file mode 100644 index 0000000..e7b1dab --- /dev/null +++ b/apps/ely-pdf/src/view.rs @@ -0,0 +1,338 @@ +//! One open PDF: its pages, tiles, text and picks (PDF-01). + +use std::{ + collections::{BTreeSet, HashMap, HashSet, VecDeque}, + io, + sync::{Arc, Mutex}, +}; + +use ely_pdf_engine::{Bookmark, Char, Doc, Worker}; +use gpui::{ + Context, EventEmitter, FocusHandle, InteractiveElement as _, IntoElement, ParentElement as _, + Render, RenderImage, ScrollHandle, Styled as _, Window, div, +}; + +use crate::engine::{Loaded, MEASURE, Mupdf}; +use crate::layout::{Mode, Zoom}; +use crate::pages::{TileKey, rect_on}; +use crate::select::Selection; + +/// A failure to show; the window toasts it. +pub struct Failed(pub String); + +/// The document changed what it shows. +pub struct Reported; + +pub struct PdfView { + worker: Arc>, + pub doc: Doc, + /// Page sizes in points; unmeasured ones copy the first. + pub sizes: Vec<(f32, f32)>, + pub mode: Mode, + pub zoom: Zoom, + pub rotate: i32, + /// The page at the top, or the one shown. + pub current: usize, + pub scroll: ScrollHandle, + pub tiles: HashMap>, + /// Images no longer drawn, freed at the next render. + pub retired: ely_gpui_component::primitives::Retired, + /// Tiles the worker could not draw; not asked again. + pub failed: HashSet, + pub text: HashMap>>, + pub asking_text: HashSet, + pub marks: Vec, + pub selection: Option, + /// Found words by page, each with its quads. + pub found: Vec<(usize, Vec<[f32; 8]>)>, + /// The found word chosen. + pub chosen: Option, + /// Where to scroll once laid out, (x, y) content pixels. + pub go_to: Option<(f32, f32)>, + /// A tile is on its way from the worker. + pub painting: bool, + /// The view's size and place, as last painted. + pub room: gpui::Bounds, + pub focus: FocusHandle, + /// Counts edits; replies from before one are dropped. + pub version: u64, + /// Edits landed so far. + pub edited: u64, + /// Edits waiting their turn, and whether one runs. + pub queue: VecDeque, + pub editing: bool, + /// History marks: now, and at the last load or save. + pub mark: String, + pub saved: String, + /// Page pictures for the side panel, by page and darkness. + pub thumbs: HashMap<(usize, bool), Arc>, + pub thumbs_failed: HashSet, + /// A picture is on its way from the worker. + pub thumbing: bool, + /// Pages picked in the side panel, and the anchor. + pub picked: BTreeSet, + pub anchor: Option, + pub tool: crate::content::Tool, + /// What pages hold, read while editing. + pub layers: crate::content::Layers, + pub reading_layer: bool, + pub aim: Option, + pub drag: Option, + /// The gesture's page, its window origin and scale. + pub grip: Option<(usize, gpui::Point, f32)>, + /// The aim when a gesture began; the panel keeps it. + pub held: Option, + /// Annotations and fields by page, for every tool. + pub notes: crate::annotate::Notes, + pub reading_notes: bool, + /// Links by page, read with the notes. + pub links: HashMap>, + /// Every field name in the form, read for new fields. + pub form_names: Option>, + pub reading_names: bool, + /// The bookmark picked in the side panel, depth first. + pub mark_pick: Option, + /// Attachments, read when listed, and the one picked. + pub files: Option>, + pub reading_files: bool, + pub file_pick: Option, + /// Regions marked to remove; never in the document. + pub redactions: Vec, + pub sketch: Option, + /// A pick to mark once its text is read. + pub marking: Option, + /// Where the last text pick was pressed, in the window. + pub pressed: Option>, + /// What the document allows, `proto::may` bits. + pub allowed: u32, + /// The file read, and its checked signature fields. + pub signatures: Option<(std::path::PathBuf, Vec)>, + pub reading_signatures: bool, + pub sign_at: Option, + /// A visible signature waiting for its place. + pub stamp: Option, + /// MuPDF's words, when it repaired the file. + pub repair: Option, + /// A draw met damage; the tab hears once. + pub damage_seen: bool, +} + +/// Why a request was refused, by what it needs. +pub fn restricted(needed: &str, cx: &gpui::App) -> gpui::SharedString { + let what = ely_ui::i18n::text(cx, &format!("pdf.may.{needed}"), &[]); + ely_ui::i18n::text(cx, "pdf.restricted", &[("what", &what)]) +} + +/// Page sizes in points; unmeasured pages copy the first. +pub fn sizes(pages: usize, first: &[[f32; 4]]) -> Vec<(f32, f32)> { + let size = |b: &[f32; 4]| (b[2] - b[0], b[3] - b[1]); + let mut sizes = vec![size(&first[0]); pages]; + for (at, bounds) in first.iter().enumerate() { + sizes[at] = size(bounds); + } + sizes +} + +impl EventEmitter for PdfView {} +impl EventEmitter for PdfView {} + +impl PdfView { + /// All its images, for a closing tab to free. + pub fn retire_all(&mut self) -> ely_gpui_component::primitives::Retired { + let mut images = std::mem::take(&mut self.retired); + let tiles = self.tiles.drain().map(|(_, image)| image); + for image in tiles.chain(self.thumbs.drain().map(|(_, image)| image)) { + images.push(image); + } + images + } + + pub fn new(worker: Arc>, loaded: Loaded, cx: &mut Context) -> Self { + let sizes = sizes(loaded.pages, &loaded.first); + log::info!("pdf: a view of {} pages", loaded.pages); + let measured = loaded.first.len(); + let mut view = PdfView { + worker, + doc: loaded.doc, + sizes, + mode: Mode::Continuous, + zoom: Zoom::Width, + rotate: 0, + current: 0, + scroll: ScrollHandle::new(), + tiles: HashMap::new(), + retired: Default::default(), + failed: HashSet::new(), + text: HashMap::new(), + asking_text: HashSet::new(), + marks: loaded.marks, + selection: None, + found: Vec::new(), + chosen: None, + go_to: None, + painting: false, + room: gpui::Bounds::default(), + focus: cx.focus_handle(), + version: 0, + edited: 0, + queue: VecDeque::new(), + editing: false, + saved: loaded.mark.clone(), + mark: loaded.mark, + thumbs: HashMap::new(), + thumbs_failed: HashSet::new(), + thumbing: false, + picked: BTreeSet::new(), + anchor: None, + tool: crate::content::Tool::Read, + layers: HashMap::new(), + reading_layer: false, + aim: None, + drag: None, + held: None, + grip: None, + notes: HashMap::new(), + reading_notes: false, + links: HashMap::new(), + form_names: None, + reading_names: false, + mark_pick: None, + files: None, + reading_files: false, + file_pick: None, + redactions: Vec::new(), + sketch: None, + marking: None, + pressed: None, + allowed: loaded.allowed, + signatures: None, + reading_signatures: false, + sign_at: None, + stamp: None, + repair: loaded.repaired.clone(), + damage_seen: false, + }; + view.measure(measured, cx); + view + } + + /// Says `what` failed; the window shows it. + pub fn fail(&self, what: &str, error: &io::Error, cx: &mut Context) { + // Engine text may quote the document: the user sees it. + log::error!("pdf: {what} failed ({:?})", error.kind()); + let text = match error.kind() { + _ if ely_pdf_engine::is_stale(error) => ely_ui::i18n::text(cx, "pdf.object.changed", &[]), + io::ErrorKind::PermissionDenied => restricted(&error.to_string(), cx), + _ => ely_ui::i18n::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]), + }; + cx.emit(Failed(text.to_string())); + } + + /// Runs `work` off the window; edits between drop `then`. + pub fn read( + &self, + work: impl FnOnce(&mut Worker) -> io::Result + Send + 'static, + then: impl FnOnce(&mut Self, io::Result, &mut Context) + 'static, + cx: &mut Context, + ) { + let version = self.version; + self.off( + work, + move |view, done, cx| match view.version == version { + true => then(view, done, cx), + false => log::info!("pdf: a read from before an edit is dropped"), + }, + cx, + ); + } + + /// Runs `work` off the window, then `then` here. + pub fn off( + &self, + work: impl FnOnce(&mut Worker) -> io::Result + Send + 'static, + then: impl FnOnce(&mut Self, io::Result, &mut Context) + 'static, + cx: &mut Context, + ) { + let worker = self.worker.clone(); + cx.spawn(async move |this, cx| { + let done = cx + .background_executor() + .spawn(async move { work(worker.lock().expect("the worker lock").get()?) }) + .await; + if this.update(cx, |view, cx| then(view, done, cx)).is_err() { + log::info!("pdf: a reply outlived its view"); + } + }) + .detach(); + } + + /// Measures the other pages, a batch at a time. + pub fn measure(&mut self, from: usize, cx: &mut Context) { + let (doc, count) = (self.doc, (self.sizes.len() - from).min(MEASURE)); + if count == 0 { + return; + } + self.read( + move |worker| worker.bounds(doc, from, count), + move |view, read, cx| match read { + Ok(boxes) => { + for (at, b) in boxes.iter().enumerate() { + view.sizes[from + at] = (b[2] - b[0], b[3] - b[1]); + } + cx.emit(Reported); + cx.notify(); + view.measure(from + count, cx); + } + Err(error) => view.fail("page sizes", &error, cx), + }, + cx, + ); + } + + /// Scrolls `page` into view, `y` points near the top. + pub fn show(&mut self, page: usize, y: f32, cx: &mut Context) { + self.current = page; + let laid = self.laid_out(); + if let Some(place) = laid.placed.iter().find(|place| place.page == page) { + let (_, top, _, _) = rect_on([0.0, y, 0.0, y], self.sizes[page], self.rotate, laid.scale); + // Across: stay unless the page's left is out of view. + let (left, wide) = (-f32::from(self.scroll.offset().x), f32::from(self.room.size.width)); + let x = match place.x >= left && place.x < left + wide { + true => left, + false => (place.x - crate::layout::GAP).max(0.0), + }; + self.go_to = Some((x, (place.y + top - crate::layout::GAP * 2.0).max(0.0))); + } + cx.notify(); + } + + /// Every page tile goes; images are freed next render. + pub fn retire_tiles(&mut self) { + for (_, image) in self.tiles.drain() { + self.retired.push(image); + } + } + + /// Changes how pages show; the page read stays in view. + pub fn change(&mut self, change: impl FnOnce(&mut Self, f32), cx: &mut Context) { + let scale = self.laid_out().scale; + change(self, scale); + self.retire_tiles(); + self.show(self.current, 0.0, cx); + } + + /// Moves `by` pages; continuous views scroll there. + pub fn turn(&mut self, by: isize, cx: &mut Context) { + let last = self.sizes.len().saturating_sub(1) as isize; + let page = (self.current as isize + by).clamp(0, last) as usize; + self.show(page, 0.0, cx); + } +} + +impl Render for PdfView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.retired.release(window); + let pages = self.page_view(window, cx); + div().size_full().track_focus(&self.focus).child(pages) + } +} diff --git a/apps/ely-pdf/src/viewer.rs b/apps/ely-pdf/src/viewer.rs new file mode 100644 index 0000000..3688347 --- /dev/null +++ b/apps/ely-pdf/src/viewer.rs @@ -0,0 +1,269 @@ +//! PDFs in the shared shell, one worker behind (PDF-01). + +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use ely_gpui_component::{ + feedback::EmptyState, + primitives::IconName, + shell::{StatusBar, StatusBarItem}, +}; +use ely_office::{Core, Host, Kind, prelude::*}; +use ely_platform::AppId; +use ely_ui::{Root, Shell, commands}; +use gpui::{ + App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, PathPromptOptions, Render, SharedString, Styled as _, Window, div, +}; + +use crate::engine::{Mupdf, Pdf}; +use crate::view::PdfView; + +pub struct Viewer { + pub core: Core, + pub side: Entity, + /// Crop or split, while asked. + pub ask: Option, + pub edit_panel: Entity, + pub note_panel: Entity, + /// A layout change shown before it is made. + pub preview: Option, + /// Preview pictures no longer drawn, freed next render. + pub retired: ely_gpui_component::primitives::Retired, + pub find: Option, + /// A bookmark title being asked. + pub naming: Option, + pub props: Option, + pub ocr: Option, + /// OCR runs started so far. + pub ocr_runs: u64, + pub removal: Option, + pub secure: Option, + pub signer: Option, + pub making: Option, + pub signs_open: bool, + pub exporting: Option, + pub repair_open: bool, + /// Find boxes opened so far. + pub finds: u64, +} + +impl Host for Viewer { + type Extra = (); + type Engine = Pdf; + const KIND: Kind = Kind { + app: AppId::Pdf, + kept: &["pdf"], + save_as: &["pdf"], + macros: None, + }; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + fn read_at(&self, tab: u64, cx: &gpui::App) -> Option { + self.tab(tab).map(|tab| tab.view.read(cx).doc.clone()) + } + + fn observed(&mut self, id: u64, view: &Entity, cx: &mut Context) { + // Damage found while drawing: no overwrite from now on. + if view.read(cx).damage_seen { + view.update(cx, |view, _| view.damage_seen = false); + if let Some(tab) = self.tab_mut(id) { + log::warn!("pdf: tab {id} turned out damaged"); + tab.risk = Some(ely_office::Risk::Repaired); + } + } + // Words found before an edit are searched again. + let version = view.read(cx).version; + if self.find.as_ref().is_some_and(|find| find.tab == id && find.version != version) { + self.search(cx); + } + // A preview from before an edit is void. + if self.preview.as_ref().is_some_and(|preview| preview.tab == id && preview.version != version) { + self.end_preview(); + } + cx.notify(); + } +} + +/// The window root with no document. +pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { + crate::keys::bind(cx); + let work = ely_platform::data_dir(app) + .map_err(std::io::Error::other) + .and_then(|data| ely_office::fresh_work(&data)); + let work = match work { + Ok(work) => work, + Err(error) => ely_platform::startup::fail(app, &format!("{error}")), + }; + // The worker starts with the first file (PERF-01). + let viewer = cx.new(|cx| { + let mut host = Viewer::new(Arc::default(), work, cx); + // Recovery points, crash return, autosave (§6.2, §6.3). + ely_office::Recovering::start_recovery(&mut host, cx); + ::start_autosave(cx); + host + }); + let root = ely_ui::document_root(app, viewer.clone().into(), &commands::FILE, crate::keys::menus(cx), window, cx); + viewer.update(cx, |viewer, cx| { + viewer.core.root = root.downgrade(); + viewer.core.window = Some(window.window_handle()); + window.focus(&viewer.core.focus, cx); + }); + for path in ely_ui::launch_files(cx) { + viewer.update(cx, |viewer, cx| viewer.open_file(path, cx)); + } + let opening = viewer.clone(); + ely_ui::on_open(cx, move |path, cx| { + opening.update(cx, |viewer, cx| viewer.open_file(path, cx)) + }); + let closing = viewer.clone(); + window.on_window_should_close(cx, move |window, cx| { + closing.update(cx, |viewer, cx| viewer.should_close(window, cx)) + }); + root +} + +impl Viewer { + pub fn new(worker: Arc>, work: PathBuf, cx: &mut Context) -> Self { + let (this, weak) = (cx.entity(), cx.weak_entity()); + Viewer { + core: Core::new(worker, work, cx), + side: cx.new(|cx| crate::side::Side::new(&this, cx)), + ask: None, + edit_panel: cx.new(|_| crate::editing::EditPanel::new(weak.clone())), + note_panel: cx.new(|_| crate::notes::NotePanel::new(weak.clone())), + preview: None, + retired: Default::default(), + find: None, + naming: None, + props: None, + ocr: None, + ocr_runs: 0, + removal: None, + secure: None, + signer: None, + making: None, + signs_open: false, + exporting: None, + repair_open: false, + finds: 0, + } + } + + pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString { + ely_ui::i18n::text(cx, key, args) + } + + /// The active tab's view. + pub fn active_view(&self) -> Option> { + self.active_tab().map(|tab| tab.view.clone()) + } + + /// Runs `change` on the active view. + pub fn on_view(&self, change: impl FnOnce(&mut PdfView, &mut Context), cx: &mut App) { + if let Some(view) = self.active_view() { + view.update(cx, change); + } + } + + /// Asks for files; each opens in its own tab. + pub fn open_picked(&mut self, _: &commands::Open, _: &mut Window, cx: &mut Context) { + let picked = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: true, + prompt: None, + }); + cx.spawn(async move |this, cx| { + let picked = match picked.await { + Ok(Ok(paths)) => Ok(paths), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + let done = this.update(cx, |viewer, cx| match picked { + Ok(Some(paths)) => paths.into_iter().for_each(|path| viewer.open_file(path, cx)), + Ok(None) => log::info!("pdf: the open panel was cancelled"), + Err(error) => viewer.panel_failed(&error, cx), + }); + if done.is_err() { + log::info!("pdf: the viewer closed before the panel"); + } + }) + .detach(); + } + + fn status(&self, cx: &mut Context) -> StatusBar { + let state = StatusBarItem::new("pdf-state").label(self.save_state(cx)); + let Some(view) = self.active_view() else { + return StatusBar::new().left(state); + }; + let view = view.read(cx); + let (n, count) = ((view.current + 1).to_string(), view.sizes.len().to_string()); + let page = Self::text(cx, "pdf.status.page", &[("n", &n), ("count", &count)]); + StatusBar::new().left(state).left(StatusBarItem::new("pdf-page").label(page)) + } +} + +impl Render for Viewer { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.retired.release(window); + let title = self.active_tab().map_or_else(|| AppId::Pdf.name().into(), |tab| tab.name.clone()); + let body = match self.active_view() { + Some(view) => view.into_any_element(), + None => EmptyState::new("pdf-empty", IconName::File, Self::text(cx, "command.blocked.no_document", &[])).into_any_element(), + }; + let shell = Shell::new(title, &self.core.focus) + .tabs(self.tab_strip(cx)) + .left(self.active_view().map(|_| self.side.clone().into())) + .right(self.active_view().and_then(|view| view.read(cx).panel_aim()).map(|aim| match aim { + crate::content::Aim::Note(_) => self.note_panel.clone().into(), + _ => self.edit_panel.clone().into(), + })) + .status(self.status(cx)) + .children(self.file_banner(cx)) + .children(self.find_bar(cx)) + .child(body); + // Esc leaves a tool from pages and panel alike. + let editing = self.active_view().is_some_and(|view| view.read(cx).tool != crate::content::Tool::Read); + let context = match editing { + true => format!("{} {}", commands::CONTEXT, crate::writes::EDITING), + false => commands::CONTEXT.to_string(), + }; + let root = div() + .size_full() + .key_context(context.as_str()) + .track_focus(&self.core.focus) + .on_action(cx.listener(Self::open_picked)) + .on_action(cx.listener(Self::on_save)) + .on_action(cx.listener(Self::on_save_as)) + .on_action(cx.listener(Self::on_export)) + .on_action(cx.listener(Self::on_print)) + .on_action(cx.listener(Self::on_quit)) + .on_action(cx.listener(Self::on_close_tab)) + .child(shell) + .children(self.core_dialog(cx)) + .children(self.ask_element(cx)) + .children(self.preview_element(cx)) + .children(self.naming_element(cx)) + .children(self.props_element(cx)) + .children(self.ocr_element(cx)) + .children(self.removal_element(cx)) + .children(self.secure_element(cx)) + .children(self.sign_element(cx)) + .children(self.visible_element(cx)) + .children(self.signs_element(cx)) + .children(self.export_element(cx)) + .children(self.repair_element(cx)); + let root = crate::writes::on(crate::organize::on(crate::keys::on(root, cx), cx), cx); + crate::document::on(crate::marking::on(root, cx), cx) + } +} diff --git a/apps/ely-pdf/src/visible.rs b/apps/ely-pdf/src/visible.rs new file mode 100644 index 0000000..e5c5c1f --- /dev/null +++ b/apps/ely-pdf/src/visible.rs @@ -0,0 +1,256 @@ +//! Visible signatures: drawn, typed or pictured (PDF-12). + +use std::{io, path::PathBuf}; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + data_display::DescriptionList, + forms::{Choice, Input, Select, SignaturePad, Stroke, TextInput}, + overlays::Dialog as Card, + theme::ControlSize, +}; +use ely_office::Closing as _; +use ely_pdf_engine::{NewAnnot, Shape, Writing, Wrote}; +use gpui::{AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, Window, div}; + +use crate::content::Tool; +use crate::view::PdfView; +use crate::viewer::Viewer; + +/// The width a click places, in points. +const WIDTH: f32 = 160.0; + +/// A visible signature ready to place, in its own units. +#[derive(Clone, Debug, PartialEq)] +pub enum Stamp { + /// Strokes scaled to fit a 1×`aspect` box. + Ink { strokes: Vec>, aspect: f32 }, + Typed { text: String, font: SharedString }, + Picture { path: PathBuf, aspect: f32 }, +} + +#[derive(Clone, Copy, PartialEq)] +enum Mode { + Draw, + Type, + Picture, +} + +/// The dialog's state while it is open. +pub struct Making { + mode: Mode, + pub(crate) strokes: Vec, + name: Entity, + font: SharedString, + picture: Option<(PathBuf, f32)>, +} + +/// Strokes moved to the corner, the longer side 1. +pub fn fitted(strokes: &[Vec<(f32, f32)>]) -> Option<(Vec>, f32)> { + if strokes.iter().all(|stroke| stroke.len() < 2) { + return None; + } + let points = || strokes.iter().flatten(); + let (x0, y0) = points().fold((f32::MAX, f32::MAX), |(x, y), p| (x.min(p.0), y.min(p.1))); + let (x1, y1) = points().fold((f32::MIN, f32::MIN), |(x, y), p| (x.max(p.0), y.max(p.1))); + // The longer side spans 1, so tall strokes fit too. + let width = (x1 - x0).max(y1 - y0).max(1.0); + let fitted = strokes.iter().map(|stroke| stroke.iter().map(|p| ((p.0 - x0) / width, (p.1 - y0) / width)).collect()).collect(); + Some((fitted, (y1 - y0) / width)) +} + +/// A stamp's box: fitted to a drag, or click sized. +pub fn placed(aspect: f32, rect: [f32; 4], click: bool) -> [f32; 4] { + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let width = if click { WIDTH } else { w.min(h / aspect.max(0.01)) }; + [rect[0], rect[1], rect[0] + width, rect[1] + width * aspect] +} + +impl PdfView { + /// Places the ready stamp at `rect` on `page`. + pub fn place_stamp(&mut self, page: usize, rect: [f32; 4], click: bool, cx: &mut Context) { + let Some(stamp) = self.stamp.take() else { + return; + }; + self.set_tool(Tool::Read, cx); + match stamp { + Stamp::Ink { strokes, aspect } => { + let at = placed(aspect, rect, click); + let scale = at[2] - at[0]; + let strokes = strokes.iter().map(|stroke| stroke.iter().map(|p| (at[0] + p.0 * scale, at[1] + p.1 * scale)).collect()).collect(); + let annot = NewAnnot { page, shape: Shape::Ink(strokes), rgb: Some(0), contents: String::new(), author: String::new() }; + self.annotate("placing a drawn signature", vec![annot], cx); + } + Stamp::Typed { text, font } => { + let at = placed(0.35, rect, click); + let writing = Writing { + block: None, + expected: String::new(), + text, + font: font.to_string(), + size: (at[3] - at[1]) * 0.8, + rgb: 0, + bx: [at[0], at[1], self.sizes[page].0 - at[0]], + }; + self.settled_edit("placing a typed signature", cx, move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? { + Wrote::Done(mark) => Ok(mark), + Wrote::Missing(c) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the font has no “{c}”"))), + Wrote::Preview(..) => Err(io::Error::other("a preview where a write was asked")), + }); + } + Stamp::Picture { path, aspect } => { + let at = placed(aspect, rect, click); + self.settled_edit("placing a signature picture", cx, move |worker, doc| worker.add_image(doc, page, &path, at)); + } + } + } +} + +impl Viewer { + pub fn ask_visible(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_view().is_none() { + return self.no_document(cx); + } + let name = cx.new(|cx| TextInput::new(window, cx)); + self.making = Some(Making { mode: Mode::Draw, strokes: Vec::new(), name, font: "script".into(), picture: None }); + cx.notify(); + } + + fn choose_picture(&mut self, cx: &mut Context) { + self.pick(false, cx, |viewer, path, cx| { + match image::image_dimensions(&path) { + Ok((w, h)) if w > 0 => { + if let Some(making) = viewer.making.as_mut() { + making.picture = Some((path, h as f32 / w as f32)); + } + } + Ok(_) => viewer.panel_failed("a picture with no pixels", cx), + Err(error) => viewer.panel_failed(&error.to_string(), cx), + } + cx.notify(); + }); + } + + /// The stamp the dialog describes, if it describes one. + fn stamp(&self, cx: &App) -> Option { + let making = self.making.as_ref()?; + match making.mode { + Mode::Draw => { + let strokes: Vec> = + making.strokes.iter().map(|stroke| stroke.iter().map(|p| (f32::from(p.x), f32::from(p.y))).collect()).collect(); + let (strokes, aspect) = fitted(&strokes)?; + Some(Stamp::Ink { strokes, aspect }) + } + Mode::Type => { + let text = making.name.read(cx).text().trim().to_string(); + (!text.is_empty()).then(|| Stamp::Typed { text, font: making.font.clone() }) + } + Mode::Picture => making.picture.clone().map(|(path, aspect)| Stamp::Picture { path, aspect }), + } + } + + /// Hands the stamp to the view's next press. + pub(crate) fn ready_stamp(&mut self, cx: &mut Context) { + let (Some(stamp), Some(view)) = (self.stamp(cx), self.active_view()) else { + return; + }; + self.making = None; + view.update(cx, |view, cx| { + view.set_tool(Tool::PlaceSignature, cx); + view.stamp = Some(stamp); + }); + cx.notify(); + } + + fn set_making(&mut self, cx: &mut Context, change: impl FnOnce(&mut Making)) { + if let Some(making) = self.making.as_mut() { + change(making); + } + cx.notify(); + } + + /// The visible signature dialog, while open. + pub fn visible_element(&self, cx: &mut Context) -> Option { + let making = self.making.as_ref()?; + let this = cx.entity(); + let label = |key: &str| Self::text(cx, key, &[]); + let switch = this.clone(); + let mode = match making.mode { + Mode::Draw => "draw", + Mode::Type => "type", + Mode::Picture => "picture", + }; + let modes = SegmentedControl::new("pdf-visible-mode", mode) + .size(ControlSize::Sm) + .segment("draw", label("pdf.visible.draw"), None) + .segment("type", label("pdf.visible.type"), None) + .segment("picture", label("pdf.visible.picture"), None) + .on_change(move |value, _, cx| { + let mode = match value.as_ref() { + "type" => Mode::Type, + "picture" => Mode::Picture, + _ => Mode::Draw, + }; + switch.update(cx, |viewer, cx| viewer.set_making(cx, |making| making.mode = mode)) + }); + let body = match making.mode { + Mode::Draw => { + let drawn = this.clone(); + SignaturePad::new("pdf-visible-pad", making.strokes.clone()) + .on_change(move |strokes, _, cx| { + let strokes = strokes.to_vec(); + drawn.update(cx, |viewer, cx| viewer.set_making(cx, |making| making.strokes = strokes)) + }) + .into_any_element() + } + Mode::Type => { + let fonts = std::iter::once(("script", "pdf.font.script")) + .chain(crate::editing::FONTS.iter().copied().filter(|(value, _)| *value != "original")) + .map(|(value, key)| Choice::new(value, label(key))) + .collect::>(); + let picked = this.clone(); + let font = Select::new("pdf-visible-font", fonts) + .selected(making.font.clone()) + .label(label("pdf.text.font")) + .on_change(move |value, _, cx| { + let value = value.clone(); + picked.update(cx, |viewer, cx| viewer.set_making(cx, |making| making.font = value)) + }); + DescriptionList::new() + .item(label("pdf.visible.name"), div().w_full().child(Input::new(&making.name))) + .item(label("pdf.text.font"), font) + .into_any_element() + } + Mode::Picture => { + let chosen = making.picture.as_ref().map_or_else(|| label("pdf.visible.no_picture"), |(path, _)| ely_office::files::file_name(path)); + let pick = this.clone(); + div() + .flex() + .gap_2() + .items_center() + .child(div().flex_1().child(chosen)) + .child(Button::new("pdf-visible-pick", label("pdf.sign.choose")).on_click(move |_, _, cx| pick.update(cx, |viewer, cx| viewer.choose_picture(cx)))) + .into_any_element() + } + }; + let closed = this.clone(); + let dismiss = move |_: &mut Window, cx: &mut App| { + closed.update(cx, |viewer, cx| { + viewer.making = None; + cx.notify(); + }) + }; + let (ready, go) = (self.stamp(cx).is_some(), label("pdf.visible.place")); + let card = Card::new("pdf-visible", label("pdf.visible.title"), dismiss) + .detail(label("pdf.visible.detail")) + .child(div().flex().flex_col().gap_3().child(modes).child(body)) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("pdf-visible-go", go) + .variant(ButtonVariant::Primary) + .disabled(!ready) + .on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.ready_stamp(cx))) + }); + Some(card.into_any_element()) + } +} diff --git a/apps/ely-pdf/src/writes.rs b/apps/ely-pdf/src/writes.rs new file mode 100644 index 0000000..59bda49 --- /dev/null +++ b/apps/ely-pdf/src/writes.rs @@ -0,0 +1,264 @@ +//! Writing text, previews, objects and new images (PDF-03, PDF-04). + +use std::{io, path::PathBuf, sync::Arc}; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + overlays::Dialog as Card, + primitives::Severity, +}; +use ely_office::{Closing as _, Tabs as _}; +use ely_pdf_engine::{Writing, Wrote}; +use gpui::{ + AnyElement, App, Context, IntoElement, KeyBinding, MenuItem, ParentElement as _, Styled as _, Window, actions, + div, img, px, +}; + +use crate::content::{Aim, Tool}; +use crate::editing::Preview; +use crate::viewer::Viewer; + +actions!(pdf, [EditContent, AddText, AddImage, StopEditing]); + +/// The key context while an editing tool is on. +pub const EDITING: &str = "PdfEditing"; + +pub fn bind(cx: &mut App) { + cx.bind_keys([KeyBinding::new("escape", StopEditing, Some(EDITING))]); +} + +/// The editing items of the Edit menu. +pub fn items(cx: &App) -> [MenuItem; 3] { + let label = |key: &str| Viewer::text(cx, key, &[]); + [ + MenuItem::action(label("pdf.tool.edit"), EditContent), + MenuItem::action(label("pdf.tool.text"), AddText), + MenuItem::action(label("pdf.tool.image"), AddImage), + ] +} + +/// Pixels a point in a preview. +const PREVIEW: f32 = 2.0; + +impl Viewer { + /// Turns `tool` on, or off when it is on. + pub(crate) fn toggle_tool(&mut self, tool: Tool, cx: &mut Context) { + let Some(view) = self.active_view() else { + return self.no_document(cx); + }; + view.update(cx, |view, cx| { + let now = if view.tool == tool { Tool::Read } else { tool }; + view.set_tool(now, cx) + }); + } + + /// Writes the panel's text, or empties the block when `erase`. + pub fn write_text(&mut self, erase: bool, cx: &mut Context) { + let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else { + return; + }; + let Some(aim) = view.read(cx).aim else { + return; + }; + let Some((page, mut writing)) = self.edit_panel.read(cx).writing(view.read(cx), aim, cx) else { + return; + }; + if erase { + writing.text.clear(); + } + // A replacement that changes the layout is shown first. + let (replacing, version) = (matches!(aim, Aim::Text { .. }), view.read(cx).version); + let (asked, viewer) = (writing.clone(), cx.weak_entity()); + view.update(cx, |view, cx| { + let doc = view.doc; + view.read( + move |worker| worker.put_text(doc, page, &asked, PREVIEW), + move |_, answer, cx| { + // The viewer edits this view; wait until it is free. + cx.defer(move |cx| { + if let Some(viewer) = viewer.upgrade() { + viewer.update(cx, |viewer, cx| viewer.previewed((tab, version, page), writing, answer, replacing, cx)); + } + }) + }, + cx, + ); + }); + } + + /// Shows a layout change, a missing glyph, or writes. + fn previewed( + &mut self, + (tab, version, page): (u64, u64, usize), + writing: Writing, + answer: io::Result, + replacing: bool, + cx: &mut Context, + ) { + match answer { + Ok(Wrote::Missing(c)) => { + let note = Self::text(cx, "pdf.text.missing", &[("char", &c.to_string())]); + self.edit_panel.update(cx, |panel, cx| { + panel.note = Some(note); + cx.notify(); + }); + } + Ok(Wrote::Preview(pixels, size, grew)) if replacing && (grew || writing.font != "original") => { + let Some(image) = crate::pages::picture(size, pixels) else { + return log::error!("pdf: a preview came back the wrong size"); + }; + self.end_preview(); + self.preview = Some(Preview { tab, version, page, writing, image }); + cx.notify(); + } + Ok(_) => self.commit_text((tab, version, page), writing, cx), + Err(error) => self.failed_read("previewing text", &error, cx), + } + } + + /// Writes for real if tab `tab` is still at `version`. + pub fn commit_text(&mut self, (tab, version, page): (u64, u64, usize), writing: Writing, cx: &mut Context) { + let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else { + let text = Self::text(cx, "pdf.object.changed", &[]); + return self.toast(text, Severity::Warning, cx); + }; + self.on_same(&view, version, cx, |view, cx| { + view.edit( + "writing text", + move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? { + Wrote::Done(mark) => Ok(mark), + Wrote::Missing(c) => Err(io::Error::other(format!("the font has no {c}"))), + Wrote::Preview(..) => Err(io::Error::other("a write came back as a preview")), + }, + cx, + ) + }); + } + + fn failed_read(&self, what: &str, error: &io::Error, cx: &mut App) { + log::error!("pdf: {what} failed ({:?})", error.kind()); + let text = Self::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]); + self.toast(text, Severity::Danger, cx); + } + + /// The aimed object: its page and index. + fn aimed_object(&self, cx: &App) -> Option<(usize, usize)> { + match self.active_view()?.read(cx).aim? { + Aim::Object { page, index } => Some((page, index)), + _ => None, + } + } + + pub fn delete_object(&mut self, cx: &mut Context) { + let (Some((page, index)), Some(view)) = (self.aimed_object(cx), self.active_view()) else { + return; + }; + let version = view.read(cx).version; + self.on_same(&view, version, cx, |view, cx| { + view.edit("deleting an object", move |worker, doc| worker.delete_object(doc, page, index), cx) + }); + } + + /// Asks for a picture to take the aimed image's place. + pub fn replace_image(&mut self, cx: &mut Context) { + let (Some((page, index)), Some(view)) = (self.aimed_object(cx), self.active_view()) else { + return; + }; + let version = view.read(cx).version; + self.pick(false, cx, move |viewer, path: PathBuf, cx| { + viewer.on_same(&view, version, cx, |view, cx| { + view.edit("replacing an image", move |worker, doc| worker.replace_image(doc, page, index, &path), cx) + }) + }); + } + + /// Runs `step` on `view` if no edit came since `version`. + pub(crate) fn on_same( + &self, + view: &gpui::Entity, + version: u64, + cx: &mut Context, + step: impl FnOnce(&mut crate::view::PdfView, &mut Context), + ) { + // Landed or queued, an edit since may move the target. + let open = self.core.tabs.iter().any(|tab| tab.view == *view); + if !open || view.read(cx).version != version || !view.read(cx).settled() { + let text = Self::text(cx, "pdf.object.changed", &[]); + return self.toast(text, Severity::Warning, cx); + } + view.update(cx, step); + } + + /// Asks for a picture; it lands mid-page. + fn add_image(&mut self, cx: &mut Context) { + let Some(view) = self.active_view() else { + return self.no_document(cx); + }; + let (version, page) = (view.read(cx).version, view.read(cx).current); + self.pick(false, cx, move |viewer, path: PathBuf, cx| { + let (w, h) = match image::image_dimensions(&path) { + Ok(size) => size, + Err(error) => return viewer.failed_read("reading the picture", &io::Error::other(error), cx), + }; + viewer.on_same(&view, version, cx, |view, cx| { + let (pw, ph) = view.sizes[page]; + // Pixels as points, shrunk to half the page. + let fit = (pw * 0.5 / w as f32).min(ph * 0.5 / h as f32).min(0.75); + let (iw, ih) = (w as f32 * fit, h as f32 * fit); + let rect = [(pw - iw) / 2.0, (ph - ih) / 2.0, (pw + iw) / 2.0, (ph + ih) / 2.0]; + view.edit("adding an image", move |worker, doc| worker.add_image(doc, page, &path, rect), cx) + }); + }); + } + + /// The preview of a layout change, while open. + pub fn preview_element(&self, cx: &mut Context) -> Option { + let preview = self.preview.as_ref()?; + let this = cx.entity(); + let dismiss = { + let this = this.clone(); + move |_: &mut Window, cx: &mut App| { + this.update(cx, |viewer, cx| { + viewer.end_preview(); + cx.notify(); + }) + } + }; + let go = Self::text(cx, "pdf.text.apply", &[]); + let picture = img(Arc::clone(&preview.image)).max_w(px(560.0)).max_h(px(420.0)); + let card = Card::new("pdf-preview", Self::text(cx, "pdf.preview.title", &[]), dismiss) + .detail(Self::text(cx, "pdf.preview.detail", &[])) + .child(div().flex().justify_center().child(picture)) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("pdf-preview-go", go).variant(ButtonVariant::Primary).on_click(move |_, _, cx| { + this.update(cx, |viewer, cx| { + if let Some(Preview { tab, version, page, writing, image }) = viewer.preview.take() { + viewer.retired.push(image); + viewer.commit_text((tab, version, page), writing, cx); + } + cx.notify(); + }) + }) + }); + Some(card.into_any_element()) + } +} + +/// Wires the editing actions onto the root. +pub fn on(root: gpui::Div, cx: &mut Context) -> gpui::Div { + use gpui::InteractiveElement as _; + root.on_action(cx.listener(|v, _: &EditContent, _, cx| v.toggle_tool(Tool::Edit, cx))) + .on_action(cx.listener(|v, _: &AddText, _, cx| v.toggle_tool(Tool::AddText, cx))) + .on_action(cx.listener(|v, _: &AddImage, _, cx| v.add_image(cx))) + .on_action(cx.listener(|v, _: &StopEditing, _, cx| v.on_view(|view, cx| view.set_tool(Tool::Read, cx), cx))) +} + +impl Viewer { + /// Drops the text preview; its picture is freed next render. + pub fn end_preview(&mut self) { + if let Some(preview) = self.preview.take() { + self.retired.push(preview.image); + } + } +} diff --git a/apps/ely-show/Cargo.toml b/apps/ely-show/Cargo.toml new file mode 100644 index 0000000..87f9f11 --- /dev/null +++ b/apps/ely-show/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "ely-show" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +ely-files.workspace = true +ely-gpui-component.workspace = true +ely-lok = { workspace = true, features = ["view"] } +ely-office.workspace = true +gpui.workspace = true +image.workspace = true +jiff.workspace = true +log.workspace = true +serde_json.workspace = true +ely-platform.workspace = true +ely-ui.workspace = true + +[dev-dependencies] +ely-ui = { workspace = true, features = ["test-support"] } +ely-gpui-component = { workspace = true, features = ["test-support"] } +tempfile.workspace = true +gpui = { workspace = true, features = ["test-support"] } +gpui_platform = { workspace = true, features = ["test-support"] } + +[[test]] +name = "compose" +harness = false + +[lints] +workspace = true diff --git a/apps/ely-show/src/chart.rs b/apps/ely-show/src/chart.rs new file mode 100644 index 0000000..aa6f072 --- /dev/null +++ b/apps/ely-show/src/chart.rs @@ -0,0 +1,400 @@ +//! Charts on a slide: look and their own table (SHOW-07). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + feedback::InlineMessage, + forms::{Input, TextInput}, + overlays::Dialog as Card, + primitives::Severity, + theme::ControlSize, +}; +use ely_lok::{ + sheet::Look, + show::{ChartData, Objects, SlideChart}, +}; +use ely_office::{ + chart::{LookFields, field, fresh, look_rows}, + in_window, + prelude::*, +}; +use gpui::{ + AnyElement, App, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, + TaskExt as _, Window, div, +}; + +use crate::deck::Deck; +use crate::dialog::{Dialog, edit}; + +/// A new chart's table, as the kernel's sample has it. +const SAMPLE: [[f64; 3]; 2] = [[4.3, 2.5, 3.5], [2.4, 4.4, 1.8]]; + +/// The chart dialog: look, then its table if any. +pub struct Editing { + pub tab: u64, + /// The marked chart changes; otherwise a new one goes in. + pub changing: bool, + pub look: Look, + pub fields: LookFields, + pub table: Option, + pub error: Option, +} + +/// A category by series table of fields. +pub struct Cells { + pub categories: Vec>, + pub series: Vec<(Entity, Vec>)>, +} + +fn number(value: Option) -> String { + value.map(|n| n.to_string()).unwrap_or_default() +} + +impl Editing { + fn new(tab: u64, chart: Option, window: &mut Window, cx: &mut App) -> Self { + let changing = chart.is_some(); + let text = |key: &str, n: usize, cx: &App| { + Deck::text(cx, key, &[("n", &n.to_string())]).to_string() + }; + let (look, data) = match chart { + Some(chart) => (chart.look, chart.data), + None => { + let categories = (1..=3) + .map(|n| text("show.chart.category", n, cx)) + .collect(); + let series = SAMPLE + .iter() + .enumerate() + .map(|(at, values)| { + let name = text("show.chart.series", at + 1, cx); + (name, values.iter().map(|v| Some(*v)).collect()) + }) + .collect(); + (fresh(), Some(ChartData { categories, series })) + } + }; + Editing { + tab, + changing, + fields: LookFields::new(&look, window, cx), + look, + table: data.map(|data| Cells { + categories: (data.categories.iter()) + .map(|name| field(name, "show.chart.category_name", window, cx)) + .collect(), + series: (data.series.iter()) + .map(|(name, values)| { + let cells = (values.iter()) + .map(|value| field(&number(*value), "show.chart.value", window, cx)) + .collect(); + (field(name, "show.chart.series_name", window, cx), cells) + }) + .collect(), + }), + error: None, + } + } + + /// The typed table, or the first bad cell. + fn data(&self, kind: &str, cx: &App) -> Result, SharedString> { + let Some(table) = &self.table else { + return Ok(None); + }; + let text = |field: &Entity| field.read(cx).text().trim().to_string(); + let categories: Vec = table.categories.iter().map(text).collect(); + // A scatter's categories are its X values. + let number = |typed: &str| typed.parse::().ok().filter(|v| v.is_finite()); + if kind == "scatter" + && let Some(bad) = categories + .iter() + .find(|category| number(category).is_none()) + { + return Err(Deck::text( + cx, + "show.chart.x_not_a_number", + &[("category", bad)], + )); + } + let mut series = Vec::new(); + for (name, cells) in &table.series { + let mut values = Vec::new(); + for (at, cell) in cells.iter().enumerate() { + let typed = text(cell); + // An empty cell is a missing point. + if typed.is_empty() { + values.push(None); + continue; + } + let Some(value) = typed.parse::().ok().filter(|v| v.is_finite()) else { + let args = [("series", text(name)), ("category", categories[at].clone())]; + let args: Vec<(&str, &str)> = + args.iter().map(|(k, v)| (*k, v.as_str())).collect(); + return Err(Deck::text(cx, "show.chart.not_a_number", &args)); + }; + values.push(Some(value)); + } + series.push((text(name), values)); + } + Ok(Some(ChartData { categories, series })) + } +} + +impl Deck { + /// Opens the chart dialog: marked chart, else new. + pub fn ask_chart(&mut self, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return; + }; + let (id, doc) = (tab.id, tab.doc.clone()); + let Some(asked) = marked_chart(self) else { + return self.open_chart((id, None), None, cx); + }; + ely_lok::read_off( + &self.core.worker, + &doc, + |worker, doc| { + let part = doc.sheets(worker)?.current; + let objects = ely_lok::show::objects(&worker.values(doc, ely_lok::show::OBJECTS)?)?; + let chart = + ely_lok::show::slide_chart(&worker.values(doc, ely_lok::sheet::CHART)?)?; + Ok((marked(part, &objects), chart)) + }, + move |deck: &mut Self, read: std::io::Result<_>, cx| match read { + // What the read saw must be what was asked for. + Ok((seen, chart)) if seen == Some(asked) => { + deck.open_chart((id, Some(asked)), chart, cx) + } + Ok(_) => log::info!("show: the chart changed before its read came back"), + Err(error) => deck.engine_failed(&format!("the chart: {error}"), cx), + }, + cx, + ); + } + + /// Opens the dialog if tab and marked chart still match. + fn open_chart( + &mut self, + (tab, asked): (u64, Option), + chart: Option, + cx: &mut Context, + ) { + cx.spawn(async move |this, cx| { + in_window(&this, cx, |deck, window, cx| { + let still = deck.core.active == Some(tab) + && asked.is_none_or(|asked| marked_chart(deck) == Some(asked)); + if !still { + return log::info!("show: a chart dialog for tab {tab} came late"); + } + let editing = Editing::new(tab, chart, window, cx); + deck.dialog = Some(Dialog::Chart(Box::new(editing))); + cx.notify(); + }) + }) + .detach_and_log_err(cx); + } + + /// Inserts or changes the chart the dialog holds. + pub(crate) fn chart_asked(&mut self, cx: &mut Context) { + let Some(Dialog::Chart(mut editing)) = self.dialog.take() else { + return; + }; + let look = editing.fields.read(&editing.look, cx); + match editing.data(&look.kind, cx) { + Ok(data) => { + let json = ely_lok::show::chart_json(&look, data.as_ref()); + self.run_in(Some(editing.tab), "show.chart", &[&json], cx); + self.force_reread(editing.tab, cx); + } + Err(error) => { + editing.error = Some(error); + self.dialog = Some(Dialog::Chart(editing)); + } + } + cx.notify(); + } + + pub fn chart_dialog(&self, editing: &Editing, cx: &mut Context) -> AnyElement { + let this = cx.entity(); + let label = |key: &str| Deck::text(cx, key, &[]); + let (picking, toggling) = (this.clone(), this.clone()); + let (rows, legend) = look_rows( + "show-chart", + DescriptionList::new(), + (&editing.look, &editing.fields), + move |kind, cx| edit(&picking, cx, |dialog, _| chart(dialog).look.kind = kind), + move |on, cx| edit(&toggling, cx, |dialog, _| chart(dialog).look.legend = on), + cx, + ); + let (title, go) = match editing.changing { + true => ("office.chart.edit_title", "office.chart.apply"), + false => ("office.chart.insert_title", "office.chart.go"), + }; + let closing = this.clone(); + let dismiss = move |_: &mut Window, cx: &mut App| { + closing.update(cx, |deck, cx| { + deck.dialog = None; + cx.notify(); + }) + }; + let go = label(go); + Card::new("show-chart", label(title), dismiss) + .child(rows) + .child(legend) + .child(match &editing.table { + Some(cells) => table(&this, cells, cx), + None => InlineMessage::new(Severity::Info, label("show.chart.no_table")) + .into_any_element(), + }) + .children( + editing + .error + .clone() + .map(|error| InlineMessage::new(Severity::Danger, error)), + ) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("show-chart-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |deck, cx| deck.chart_asked(cx))) + }) + .into_any_element() + } +} + +/// A marked chart: slide, place in the object list, frame. +type Marked = (usize, usize, (i64, i64, i64, i64)); + +/// The one marked object on slide `part`, if a chart. +fn marked(part: usize, objects: &Objects) -> Option { + let selection = objects + .selection + .as_ref() + .filter(|s| s.kind == "chart" && s.count == 1)?; + let mut picked = objects + .objects + .iter() + .enumerate() + .filter(|(_, o)| o.selected); + let (at, _) = picked.next().filter(|_| picked.next().is_none())?; + Some((part, at, selection.rect)) +} + +/// The marked chart as the deck last read it. +fn marked_chart(deck: &Deck) -> Option { + let tab = deck.active_tab()?; + marked(tab.extra.slides.current, tab.extra.objects.as_ref()?) +} + +fn cells(dialog: &mut Dialog) -> &mut Cells { + chart(dialog) + .table + .as_mut() + .expect("the resize buttons need a table") +} + +fn chart(dialog: &mut Dialog) -> &mut Editing { + match dialog { + Dialog::Chart(editing) => editing, + _ => unreachable!("a chart dialog"), + } +} + +/// Series across, categories down, with grow and shrink buttons. +fn table(this: &Entity, editing: &Cells, cx: &App) -> AnyElement { + let cell = |input: &Entity| div().w_20().child(Input::new(input)); + let header = div() + .flex() + .gap_1() + .child(div().w_20()) + .children(editing.series.iter().map(|(name, _)| cell(name))); + let rows = editing.categories.iter().enumerate().map(|(at, name)| { + div() + .flex() + .gap_1() + .child(cell(name)) + .children(editing.series.iter().map(|(_, cells)| cell(&cells[at]))) + }); + let label = |key: &str| Deck::text(cx, key, &[]); + let grow = |id: &'static str, key: &str, more: bool, across: bool, on: bool| { + let this = this.clone(); + Button::new(id, label(key)) + .size(ControlSize::Sm) + .disabled(!on) + .on_click(move |_, window, cx| { + edit(&this, cx, |dialog, cx| { + resize(cells(dialog), (more, across), window, cx) + }) + }) + }; + let buttons = div() + .flex() + .gap_1() + .child(grow( + "show-chart-add-row", + "show.chart.add_category", + true, + false, + true, + )) + .child(grow( + "show-chart-drop-row", + "show.chart.drop_category", + false, + false, + editing.categories.len() > 1, + )) + .child(grow( + "show-chart-add-column", + "show.chart.add_series", + true, + true, + true, + )) + .child(grow( + "show-chart-drop-column", + "show.chart.drop_series", + false, + true, + editing.series.len() > 1, + )); + div() + .flex() + .flex_col() + .gap_1() + .child(header) + .children(rows) + .child(buttons) + .into_any_element() +} + +/// Adds or drops the last category or series. +fn resize(editing: &mut Cells, (more, across): (bool, bool), window: &mut Window, cx: &mut App) { + let blank = |key: &str, window: &mut Window, cx: &mut App| field("", key, window, cx); + match (more, across) { + (true, false) => { + editing + .categories + .push(blank("show.chart.category_name", window, cx)); + for (_, cells) in &mut editing.series { + cells.push(blank("show.chart.value", window, cx)); + } + } + (false, false) => { + editing.categories.pop(); + for (_, cells) in &mut editing.series { + cells.pop(); + } + } + (true, true) => { + let cells = (0..editing.categories.len()) + .map(|_| blank("show.chart.value", window, cx)) + .collect(); + editing + .series + .push((blank("show.chart.series_name", window, cx), cells)); + } + (false, true) => { + editing.series.pop(); + } + } +} diff --git a/apps/ely-show/src/check.rs b/apps/ely-show/src/check.rs new file mode 100644 index 0000000..55db2a8 --- /dev/null +++ b/apps/ely-show/src/check.rs @@ -0,0 +1,265 @@ +//! What may go wrong in a show, and where (SHOW-14). + +use ely_gpui_component::{ + lists::{ListItem, SelectableList}, + overlays::Dialog as Card, + primitives::Severity, +}; +use ely_lok::show::{Check, PageCheck, ShowSlide}; +use ely_office::prelude::*; +use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Window, div}; + +use crate::deck::Deck; +use crate::dialog::Dialog; + +/// What a finding is. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Problem { + Font, + Media, + /// A linked file; `missing` says if it is gone. + Link, + /// Something the kernel cannot show. + Object, +} + +/// One thing to fix, and where it is. +#[derive(Clone, Debug, PartialEq)] +pub struct Finding { + pub problem: Problem, + pub name: String, + pub missing: bool, + /// Slide indexes from zero. + pub slides: Vec, + pub masters: Vec, +} + +/// Every page's checks: slides by index, masters by name. +fn pages(check: &Check) -> impl Iterator, &PageCheck)> { + let slides = check.slides.iter().enumerate().map(|(at, page)| (Ok(at), page)); + slides.chain(check.masters.iter().map(|(name, page)| (Err(name.as_str()), page))) +} + +fn add(found: &mut Vec, (problem, name, missing): (Problem, &str, bool), at: Result) { + let index = found.iter().position(|f| f.problem == problem && f.name == name); + let finding = match index { + Some(index) => &mut found[index], + None => { + found.push(Finding { + problem, + name: name.to_string(), + missing, + slides: Vec::new(), + masters: Vec::new(), + }); + found.last_mut().expect("just pushed") + } + }; + match at { + Ok(slide) if !finding.slides.contains(&slide) => finding.slides.push(slide), + Err(master) if !finding.masters.iter().any(|m| m == master) => { + finding.masters.push(master.to_string()) + } + _ => {} + } +} + +/// Missing fonts, missing media, links, then broken objects. +pub fn findings(check: &Check, slides: &[ShowSlide], missing_fonts: &[String]) -> Vec { + let mut found = Vec::new(); + for font in missing_fonts { + let fonts = (Problem::Font, font.as_str(), true); + let mut placed = false; + for (at, _) in pages(check).filter(|(_, page)| page.fonts.contains(font)) { + add(&mut found, fonts, at); + placed = true; + } + // Missing, yet no slide text asks for it. + if !placed { + found.push(Finding { + problem: Problem::Font, + name: font.clone(), + missing: true, + slides: Vec::new(), + masters: Vec::new(), + }); + } + } + for (at, slide) in slides.iter().enumerate() { + for media in &slide.media { + match (media.missing, media.link) { + (true, _) => add(&mut found, (Problem::Media, &media.url, true), Ok(at)), + (false, true) => add(&mut found, (Problem::Link, &media.url, false), Ok(at)), + (false, false) => {} + } + } + } + for (at, page) in pages(check) { + for (url, missing) in &page.links { + add(&mut found, (Problem::Link, url, *missing), at); + } + } + for (at, page) in pages(check) { + for name in &page.broken { + add(&mut found, (Problem::Object, name, true), at); + } + } + found +} + +impl Deck { + /// Reads every slide and master, then lists what to fix. + pub fn check_show(&mut self, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + let text = Deck::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + let (id, missing) = (tab.id, tab.view.read(cx).missing_fonts().to_vec()); + let asked = self.ask_dialog(); + log::info!("show: checks the deck"); + self.read_in( + id, + |worker, doc| { + let check = ely_lok::show::check(&worker.values(doc, ely_lok::show::SLIDE_CHECK)?)?; + let slides = ely_lok::show::slide_show(&worker.values(doc, ely_lok::show::SLIDE_SHOW)?)?; + Ok((check, slides)) + }, + move |deck, read, _, cx| match read { + Ok((check, slides)) if deck.dialog_due(asked, id) => { + let found = findings(&check, &slides, &missing); + log::info!("show: the check found {} things", found.len()); + deck.dialog = Some(Dialog::Check(found)); + cx.notify(); + } + Ok(_) => log::info!("show: the check is no longer due"), + Err(error) => deck.engine_failed(&format!("the check: {error}"), cx), + }, + cx, + ); + } + + /// The findings; a row goes to its first slide. + pub fn check_dialog(&self, found: &[Finding], cx: &mut Context) -> AnyElement { + let this = cx.entity(); + let text = |key: &str| Deck::text(cx, key, &[]); + let closing = this.clone(); + let dismiss = move |_: &mut Window, cx: &mut App| { + closing.update(cx, |deck, cx| { + deck.dialog = None; + cx.notify(); + }) + }; + let card = Card::new("show-check", text("show.check.title"), dismiss); + if found.is_empty() { + return card.child(div().child(text("show.check.clear"))).into_any_element(); + } + let mut list = SelectableList::new("show-check-list").label(text("show.check.title")); + for (at, finding) in found.iter().enumerate() { + let kind = match (finding.problem, finding.missing) { + (Problem::Font, _) => "show.check.font", + (Problem::Media, _) => "show.check.media", + (Problem::Link, true) => "show.check.link_missing", + (Problem::Link, false) => "show.check.link", + (Problem::Object, _) => "show.check.object", + }; + let mut places: Vec = (finding.slides.iter()) + .map(|slide| Deck::text(cx, "show.check.slide", &[("n", &(slide + 1).to_string())]).to_string()) + .collect(); + places.extend(finding.masters.iter().map(|name| { + Deck::text(cx, "show.check.master", &[("name", name)]).to_string() + })); + let name = match finding.name.is_empty() { + true => text("show.check.unnamed"), + false => SharedString::from(finding.name.clone()), + }; + let place = match places.is_empty() { + true => text("show.check.nowhere").to_string(), + false => places.join(", "), + }; + let item = ListItem::new(SharedString::from(format!("show-check-{at}")), name) + .description(format!("{} · {place}", text(kind))); + list = list.row(SharedString::from(at.to_string()), item); + } + let firsts: Vec> = found.iter().map(|f| f.slides.first().copied()).collect(); + let list = list.on_change(move |keys, _, cx| { + let first = keys.first().and_then(|key| key.parse::().ok()).and_then(|at| firsts[at]); + this.update(cx, |deck, cx| { + if let (Some(part), Some(tab)) = (first, deck.active_tab()) { + let view = tab.view.clone(); + view.update(cx, |view, _| view.show_part(part)); + deck.dialog = None; + } + cx.notify(); + }) + }); + card.child(list).into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ely_lok::show::{Media, Transition}; + + fn slide(media: Vec) -> ShowSlide { + ShowSlide { + hidden: false, + notes: String::new(), + transition: Transition { + preset: String::new(), + duration: 0.0, + auto: false, + time: 0.0, + }, + effects: Vec::new(), + media, + } + } + + fn media(url: &str, link: bool, missing: bool) -> Media { + Media { + index: 0, + url: url.into(), + link, + file: url.into(), + missing, + repeat: false, + bounds: (0, 0, 1, 1), + } + } + + #[test] + fn findings_say_what_and_where_in_order() { + let page = |fonts: &[&str], links: &[(&str, bool)], broken: &[&str]| PageCheck { + fonts: fonts.iter().map(|f| f.to_string()).collect(), + links: links.iter().map(|(u, m)| (u.to_string(), *m)).collect(), + broken: broken.iter().map(|b| b.to_string()).collect(), + }; + let check = Check { + slides: vec![ + page(&["Lost", "Inter"], &[("file:///a.png", true)], &[]), + page(&["Lost"], &[], &["Chart 1"]), + ], + masters: vec![("Plain".into(), page(&["Lost"], &[("file:///a.png", true)], &[]))], + }; + let slides = [slide(vec![media("file:///clip.mp4", false, true)]), slide(vec![media("file:///b.mp3", true, false)])]; + let missing = ["Lost".to_string(), "Gone".to_string()]; + let found = findings(&check, &slides, &missing); + let short: Vec<(Problem, &str, Vec, Vec)> = found + .iter() + .map(|f| (f.problem, f.name.as_str(), f.slides.clone(), f.masters.clone())) + .collect(); + assert_eq!( + short, + [ + (Problem::Font, "Lost", vec![0, 1], vec!["Plain".to_string()]), + (Problem::Font, "Gone", vec![], vec![]), + (Problem::Media, "file:///clip.mp4", vec![0], vec![]), + (Problem::Link, "file:///b.mp3", vec![1], vec![]), + (Problem::Link, "file:///a.png", vec![0], vec!["Plain".to_string()]), + (Problem::Object, "Chart 1", vec![1], vec![]), + ] + ); + assert!(found[4].missing && !found[3].missing); + } +} diff --git a/apps/ely-show/src/deck.rs b/apps/ely-show/src/deck.rs new file mode 100644 index 0000000..2a41547 --- /dev/null +++ b/apps/ely-show/src/deck.rs @@ -0,0 +1,506 @@ +//! ElyShow's window: decks over one LOK worker (§10.1). + +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use ely_gpui_component::{feedback::EmptyState, primitives::IconName, shell::StatusBar}; +use ely_lok::{ + LokView, Sheets, Worker, + history::Step, + show::{Design, Objects}, +}; +use ely_office::{Core, Kind, prelude::*}; +use ely_platform::AppId; +use ely_ui::{Root, Shell, commands}; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, Render, RenderImage, SharedString, Styled as _, Window, div, +}; + +use crate::design::Panel; +use crate::slides::Strip; + +/// Parts, edits, caret and selected frame. +pub type Seen = (u64, u64, bool, Option); + +/// What ElyShow keeps per deck. +#[derive(Default)] +pub struct Extra { + pub slides: Sheets, + /// The document as the last read saw it. + pub read_at: Option, + /// What the view showed at the last read. + pub read: Option, + pub reading: bool, + pub design: Option, + pub objects: Option, + pub notes: Option, + pub remarks: Vec, + /// The shown slide's transition and effects; none on a master. + pub motion: Option<(ely_lok::show::Transition, Vec)>, + pub media: Vec, + /// Counts completed reads. + pub reads: u64, + /// The slide size in twips. + pub size: (i64, i64), + /// Undo and redo lists at the last read. + pub history: Option<(Vec, Vec)>, + /// A size sent, in 1/100 mm, until read back. + pub size_asked: Option<(i64, i64)>, + /// A transition sent for a slide, until read back. + pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>, + /// Pictures by slide name; stale ones show until repainted. + pub thumbs: HashMap>, + pub retired: ely_gpui_component::primitives::Retired, + pub stale: HashSet, + /// Pictures that failed; retried after the next read. + pub failed: HashSet, + pub painting: bool, + /// The parts are masters. + pub masters: bool, + /// The scheme the pictures were painted with. + pub dark: Option, +} + +impl Extra { + /// The size to show, in 1/100 mm: asked, else read. + pub fn shown_size(&self) -> (i64, i64) { + self.size_asked + .unwrap_or_else(|| crate::design::mm100(self.size)) + } +} + +pub struct Deck { + pub core: Core, + pub dialog: Option, + pub strip: Entity, + pub panel: Entity, + /// The kernel's own blank deck, written once. + pub blank: Option, + /// The kernel's font families; read once. + pub fonts: Vec, + reading_fonts: bool, + pub guide_drag: Option, + pub notes: crate::notes::NotesField, + /// The right column shows comments, not properties. + pub remarks_open: bool, + pub preview: Option, + /// Counts preview requests; only the last may play. + pub preview_asked: u64, + pub probe: Option, + pub playing: Option, + pub show: Option>, + /// A show is being read or its screens picked. + pub show_starting: bool, + /// Shows started; each copy has its own file. + pub shows: u64, + /// Reads that end in a dialog; only the last opens. + pub dialog_asked: u64, + /// Dialog kind and tab last seen; a change ends asks. + pub dialog_seen: (Option>, Option), + pub active_remark: Option, + pub remarks_pane: Entity, + /// What the menus last checked: grid, guides, comments. + pub snap_menus: Option<(bool, bool, bool)>, + /// Pictures of closed tabs and ended previews, freed later. + pub retired: ely_gpui_component::primitives::Retired, +} + +impl Host for Deck { + type Extra = Extra; + type Engine = ely_office::Lok; + const KIND: Kind = Kind { + app: AppId::Show, + kept: &["pptx", "odp", "ppt", "fodp"], + save_as: &["pptx", "odp"], + macros: Some(&["pptm", "potm", "ppsm"]), + }; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + fn tab_closed(&mut self, tab: &ely_office::Tab, _: &mut Context) { + self.retire_tab(tab); + } + + fn read_at(&self, tab: u64, _: &gpui::App) -> Option { + self.tab(tab).and_then(|tab| tab.extra.read_at.clone()) + } + + fn observed(&mut self, id: u64, view: &Entity, cx: &mut Context) { + view.update(cx, |view, cx| view.fit_page(cx)); + // Pictures follow the kernel's light or dark scheme. + let dark = view.read(cx).dark(); + if let Some(tab) = self.tab_mut(id) + && tab.extra.dark != dark + { + tab.extra.dark = dark; + self.repaint_all(id, cx); + } + self.reread(id, view, cx); + } +} + +/// ElyShow's menus after File. +pub fn menus(on: (bool, bool, bool), cx: &App) -> Vec { + let [insert, view] = crate::insert::menus(on, cx); + vec![crate::slides::menu(cx), insert, view, crate::slideshow::menu(cx)] +} + +/// The window root with a blank deck. +pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { + ely_lok::init(cx); + let (worker, work) = match ely_office::start(app, cx) { + Ok(started) => started, + Err(error) => ely_platform::startup::fail(app, &format!("{error}")), + }; + let deck = cx.new(|cx| { + let mut host = Deck::new(worker, work, cx); + // Recovery points, crash return, autosave (§6.2, §6.3). + ely_office::Recovering::start_recovery(&mut host, cx); + ::start_autosave(cx); + host + }); + crate::slideshow::bind_keys(cx); + ely_office::bind_zoom_keys(cx); + let menus = crate::deck::menus((false, false, false), cx); + let root = ely_ui::document_root(app, deck.clone().into(), &ely_ui::commands::FILE, menus, window, cx); + deck.update(cx, |deck, cx| { + deck.core.root = root.downgrade(); + deck.core.window = Some(window.window_handle()); + window.focus(&deck.core.focus, cx); + }); + let launched = ely_ui::launch_files(cx); + deck.update(cx, |deck, cx| match launched.is_empty() { + true => deck.new_blank(cx), + false => launched.into_iter().for_each(|path| deck.open_file(path, cx)), + }); + let opening = deck.clone(); + ely_ui::on_open(cx, move |path, cx| { + opening.update(cx, |deck, cx| deck.open_file(path, cx)) + }); + let closing = deck.clone(); + window.on_window_should_close(cx, move |window, cx| { + closing.update(cx, |deck, cx| deck.should_close(window, cx)) + }); + root +} + +impl Deck { + pub fn new(worker: Arc>, work: PathBuf, cx: &mut Context) -> Self { + let deck = cx.weak_entity(); + // Closing or switching tabs stops playback. + cx.observe_self(|deck, _| { + deck.check_playing(); + deck.watch_dialogs(); + }) + .detach(); + Deck { + core: Core::new(worker, work, cx), + dialog: None, + strip: cx.new(|cx| Strip::new(deck.clone(), cx)), + panel: cx.new(|cx| Panel::new(deck.clone(), cx)), + remarks_pane: cx.new(|_| crate::remarks::Pane { deck }), + blank: None, + fonts: Vec::new(), + reading_fonts: false, + guide_drag: None, + snap_menus: Some((false, false, false)), + notes: Default::default(), + remarks_open: false, + preview: None, + preview_asked: 0, + probe: None, + playing: None, + show: None, + show_starting: false, + shows: 0, + dialog_asked: 0, + retired: Default::default(), + dialog_seen: (None, None), + active_remark: None, + } + } + + pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString { + ely_ui::i18n::text(cx, key, args) + } + + /// Rereads slides and design when the kernel moves them. + fn reread(&mut self, id: u64, view: &Entity, cx: &mut Context) { + let read = view.read(cx); + let seen = ( + read.cell().sheets, + read.edited(), + read.caret(), + read.graphic(), + ); + let Some(tab) = self.tab_mut(id) else { + return; + }; + // Numbered commands need the objects read at the latest revision. + let behind = tab.extra.read_at.as_ref().is_some_and(|at| view.read(cx).read_at().newer_than(at)); + if tab.extra.reading || tab.extra.read == Some(seen) && !behind { + return; + } + // Edits and leaving text edit dirty the picture. + if tab + .extra + .read + .is_some_and(|(_, edited, caret, _)| edited != seen.1 || caret && !seen.2) + && let Some(slide) = tab.extra.slides.list.get(tab.extra.slides.current) + { + tab.extra.stale.insert(slide.name.clone()); + } + (tab.extra.reading, tab.extra.read) = (true, Some(seen)); + let landed = tab.extra.transition_landed(view.read(cx)); + let doc = tab.doc.clone(); + ely_lok::read_off( + &self.core.worker, + &doc, + |worker, doc| { + let at = worker.now(doc)?; + let slides = doc.sheets(worker)?; + let design = ely_lok::show::design(&worker.values(doc, ely_lok::show::DESIGN)?)?; + let mut steps = |command| ely_lok::history::steps(&worker.values(doc, command)?); + let history = (steps(".uno:Undo")?, steps(".uno:Redo")?); + let objects = ely_lok::show::objects(&worker.values(doc, ely_lok::show::OBJECTS)?)?; + let notes = ely_lok::show::notes(&worker.values(doc, ely_lok::show::NOTES)?)?; + let remarks = ely_lok::show::remarks(&worker.values(doc, ".uno:ViewAnnotations")?)?; + let media = ely_lok::show::media(&worker.values(doc, ely_lok::show::MEDIA)?)?; + let motion = match design.master_view { + true => None, + false => Some(( + ely_lok::show::transition(&worker.values(doc, ely_lok::show::TRANSITION)?)?, + ely_lok::show::effects(&worker.values(doc, ely_lok::show::ANIMATIONS)?)?, + )), + }; + Ok(( + (slides, at), + (design, objects, notes, remarks, (motion, media)), + doc.size(worker)?, + history, + )) + }, + move |deck: &mut Self, read, cx| { + let Some(tab) = deck.tab_mut(id) else { + return; + }; + tab.extra.reading = false; + let view = tab.view.clone(); + match read { + Ok(((slides, at), (design, objects, notes, remarks, (motion, media)), size, history)) => { + tab.extra.read_at = Some(at); + log::info!("show: tab {id} has {} parts", slides.list.len()); + view.update(cx, |view, _| view.set_part(slides.current)); + let names: HashSet<&String> = + slides.list.iter().map(|slide| &slide.name).collect(); + tab.extra.keep_pictures(&names); + // The kernel sends no repaint when the mode flips. + if tab.extra.masters != design.master_view { + log::info!("show: tab {id} shows masters {}", design.master_view); + tab.extra.masters = design.master_view; + tab.extra.drop_pictures(); + view.update(cx, |view, cx| view.invalidate(cx)); + } + // Size, colors and masters touch every picture. + let global = + |design: &Design| (design.theme.clone(), design.masters.clone()); + // Undo and redo may reach any slide. + let undone = tab.extra.history.as_ref().is_some_and(|(undo, redo)| { + undo.iter().any(|step| !history.0.contains(step)) + || history.0.iter().any(|step| redo.contains(step)) + }); + if undone { + log::info!("show: tab {id} undid or redid; repainting all"); + } + // Any new step may change the shown slide. + let stepped = tab + .extra + .history + .as_ref() + .is_some_and(|(undo, _)| *undo != history.0); + if let Some(slide) = slides.list.get(slides.current).filter(|_| stepped) { + tab.extra.stale.insert(slide.name.clone()); + } + tab.extra.history = Some(history); + if undone + || tab.extra.size != size + || tab.extra.design.as_ref().map(global) != Some(global(&design)) + { + tab.extra.stale = tab.extra.thumbs.keys().cloned().collect(); + } + let read = crate::design::mm100(size); + if tab.extra.size_asked.is_some_and(|(w, h)| { + (w - read.0).abs() <= 50 && (h - read.1).abs() <= 50 + }) { + tab.extra.size_asked = None; + } + (tab.extra.slides, tab.extra.design) = (slides, Some(design)); + tab.extra.objects = Some(objects); + (tab.extra.notes, tab.extra.remarks) = (Some(notes), remarks); + (tab.extra.motion, tab.extra.media) = (motion, media); + tab.extra.settle_transition(landed); + tab.extra.reads += 1; + tab.extra.size = size; + tab.extra.failed.clear(); + deck.paint_next(id, cx); + deck.read_fonts(id, cx); + deck.sync_menus(cx); + } + Err(error) => deck.engine_failed(&error.to_string(), cx), + } + cx.notify(); + // The kernel may have moved again meanwhile. + deck.reread(id, &view, cx); + }, + cx, + ); + } + + /// Reads slides and design again once input lands. + pub fn force_reread(&mut self, id: u64, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |deck, _, cx| deck.force_reread(id, cx)); + } + let Some(tab) = self.tab_mut(id) else { + return; + }; + tab.extra.read = None; + let view = tab.view.clone(); + self.reread(id, &view, cx); + } + + /// Checks the View menu's snapping to match the shown deck. + pub fn sync_menus(&mut self, cx: &mut Context) { + let Some(objects) = self.active_tab().and_then(|tab| tab.extra.objects.as_ref()) else { + return; + }; + let on = (objects.snap_grid, objects.snap_guides, self.remarks_open); + if self.snap_menus != Some(on) { + self.snap_menus = Some(on); + ely_ui::set_document_menus(AppId::Show, &ely_ui::commands::FILE, menus(on, cx), cx); + } + } + + /// Reads the font families once, off the window. + fn read_fonts(&mut self, id: u64, cx: &mut Context) { + if !self.fonts.is_empty() || self.reading_fonts { + return; + } + let Some(doc) = self.tab_mut(id).map(|tab| tab.doc.clone()) else { + return; + }; + self.reading_fonts = true; + ely_lok::read_off( + &self.core.worker, + &doc, + |worker, doc| ely_lok::commands::fonts(&worker.values(doc, ".uno:CharFontName")?), + |deck: &mut Self, read, cx| { + deck.reading_fonts = false; + match read { + Ok(fonts) => deck.fonts = fonts.into_iter().map(Into::into).collect(), + Err(error) => deck.engine_failed(&format!("the font list: {error}"), cx), + } + cx.notify(); + }, + cx, + ); + } + + /// Marks every slide's picture stale. + pub fn repaint_all(&mut self, id: u64, cx: &mut Context) { + let Some(tab) = self.tab_mut(id) else { + return; + }; + tab.extra.stale = tab.extra.thumbs.keys().cloned().collect(); + self.paint_next(id, cx); + } + + fn body(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let Some(tab) = self.active_tab() else { + let title = match self.core.loading.first() { + Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]), + None => Self::text(cx, "command.blocked.no_document", &[]), + }; + return EmptyState::new("show-empty", IconName::Monitor, title).into_any_element(); + }; + let view = tab.view.clone(); + let guides = self.guide_layer(&view, cx); + let marks = self.remark_layer(&view, cx); + let playing = self.media_layer(&view, window, cx); + let preview = self.preview_layer(&view, window, cx); + let notes = self.notes_pane(window, cx); + div() + .size_full() + .flex() + .flex_col() + .children(self.stopped_banner(cx).or_else(|| self.risk_banner(cx))) + .child( + div() + .flex_1() + .min_h_0() + .relative() + .child(view) + .children(guides) + .children(marks) + .children(playing) + .children(preview), + ) + .children(notes) + .into_any_element() + } +} + +impl Render for Deck { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.release_pictures(window); + let title = self + .active_tab() + .map_or_else(|| AppId::Show.name().into(), |tab| tab.name.clone()); + let shown = self.active_tab().is_some(); + let state = self.save_state(cx); + let status = StatusBar::new() + .left(ely_gpui_component::shell::StatusBarItem::new("show-state").label(state)) + .when_some(self.zoom_control(cx), |status, zoom| status.right(zoom)); + let shell = Shell::new(title, &self.core.focus) + .tabs(self.tab_strip(cx)) + .left(shown.then(|| self.strip.clone().into())) + .right(shown.then(|| match self.remarks_open { + true => self.remarks_pane.clone().into(), + false => self.panel.clone().into(), + })) + .status(status) + .child(self.body(window, cx)); + let root = div() + .size_full() + .key_context(commands::CONTEXT) + .track_focus(&self.core.focus) + .on_action(cx.listener(|deck, _: &commands::New, _, cx| deck.ask_new(cx))) + .on_action(cx.listener(Self::on_open)) + .on_action(cx.listener(Self::on_save)) + .on_action(cx.listener(Self::on_save_as)) + .on_action(cx.listener(Self::on_export)) + .on_action(cx.listener(Self::on_print)) + .on_action(cx.listener(Self::on_quit)) + .on_action(cx.listener(Self::on_close_tab)) + .child(shell) + .children(self.core_dialog(cx)) + .children(self.dialog_element(cx)); + let root = crate::slideshow::bind(crate::insert::bind(crate::slides::bind(root, cx), cx), cx); + Self::bind_zoom(root, cx) + } +} + +impl Zooming for Deck { + const FITS: bool = true; +} diff --git a/apps/ely-show/src/design.rs b/apps/ely-show/src/design.rs new file mode 100644 index 0000000..0ce28ab --- /dev/null +++ b/apps/ely-show/src/design.rs @@ -0,0 +1,361 @@ +//! The design panel: layout, master, theme, size (SHOW-01, 02). + +use std::collections::HashMap; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Choice, NumberInput, Select}, + layout::ScrollArea, +}; +use ely_lok::show::LAYOUTS; +use ely_office::prelude::*; +use gpui::{ + AnyElement, App, Context, IntoElement, ParentElement as _, Render, SharedString, Styled as _, + WeakEntity, Window, div, +}; + +use crate::deck::Deck; + +/// Slide sizes in 1/100 mm. +const SIZES: [(&str, (i64, i64)); 5] = [ + ("wide", (33_867, 19_050)), + ("hd", (28_000, 15_750)), + ("standard", (25_400, 19_050)), + ("a4", (29_700, 21_000)), + ("letter", (27_940, 21_590)), +]; + +/// Twips to 1/100 mm. +pub fn mm100((w, h): (i64, i64)) -> (i64, i64) { + ((w * 127 + 36) / 72, (h * 127 + 36) / 72) +} + +/// The preset a 1/100 mm size matches, within 0.5 mm. +fn preset((w, h): (i64, i64)) -> Option<&'static str> { + SIZES + .iter() + .find(|(_, (pw, ph))| (pw - w).abs() <= 50 && (ph - h).abs() <= 50) + .map(|(key, _)| *key) +} + +impl Deck { + /// Runs a design command; the next read shows what changed. + pub fn design_command(&mut self, id: &str, values: &[&str], cx: &mut Context) { + let Some(tab) = self.core.active else { + return; + }; + self.run_in(Some(tab), id, values, cx); + self.force_reread(tab, cx); + } + + /// Resizes the slides; the asked size shows until read back. + pub(crate) fn resize(&mut self, (w, h): (i64, i64), cx: &mut Context) { + let Some(tab) = self.core.active.and_then(|id| self.tab_mut(id)) else { + return; + }; + let (sw, sh) = tab.extra.shown_size(); + if (sw - w).abs() <= 2 && (sh - h).abs() <= 2 { + log::info!("show: the slides already measure {w} x {h}"); + return; + } + tab.extra.size_asked = Some((w, h)); + let (w, h) = (w.to_string(), h.to_string()); + self.design_command("slide.size", &[&w, &h, "true"], cx); + } + + /// Enters or leaves the master view. + pub(crate) fn masters(&mut self, on: bool, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return; + }; + let id = tab.id; + self.run_in( + Some(id), + if on { "master.edit" } else { "master.close" }, + &[], + cx, + ); + self.force_reread(id, cx); + } +} + +/// The right column over the active deck. +pub struct Panel { + pub(crate) deck: WeakEntity, + /// Per field: value sent, value read, until the next read. + pub(crate) sent: HashMap)>, + /// The tab and read the sends belong to. + pub(crate) seen: Option<(u64, u64)>, + /// The effect picked in the list. + pub(crate) effect: Option, +} + +impl Panel { + pub fn new(deck: WeakEntity, cx: &mut Context) -> Self { + if let Some(deck) = deck.upgrade() { + cx.observe(&deck, |_, _, cx| cx.notify()).detach(); + } + Panel { + deck, + sent: HashMap::new(), + seen: None, + effect: None, + } + } + + /// The kernel's font families, once read. + pub(crate) fn fonts(&self, cx: &App) -> Vec { + self.deck + .upgrade() + .map(|deck| deck.read(cx).fonts.clone()) + .unwrap_or_default() + } +} + +/// Runs `then` on the deck behind `deck`. +pub(crate) fn on_deck( + deck: &WeakEntity, + cx: &mut App, + then: impl FnOnce(&mut Deck, &mut Context), +) { + if let Some(deck) = deck.upgrade() { + deck.update(cx, then); + } +} + +impl Render for Panel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(deck) = self.deck.upgrade() else { + return div(); + }; + let Some(objects) = deck + .read(cx) + .active_tab() + .and_then(|tab| tab.extra.objects.clone()) + else { + return div(); + }; + let top = match &objects.selection { + Some(selection) => self.object_grid(selection, window, cx).into_any_element(), + None => self.design_view(cx), + }; + let list = (!objects.objects.is_empty()).then(|| { + div().px_3().child(DescriptionList::new().stacked().item( + Deck::text(cx, "show.objects", &[]), + self.object_list(&objects.objects, cx), + )) + }); + div().size_full().child( + ScrollArea::new("show-panel-scroll").vertical().size_full().child( + div() + .flex() + .flex_col() + .gap_3() + .pb_3() + .child(top) + .children(self.motion_view(objects.selection.is_some(), cx)) + .children(list), + ), + ) + } +} + +impl Panel { + /// Layout, master, theme and size of the shown slide. + fn design_view(&self, cx: &mut Context) -> AnyElement { + let label = |key: &str| Deck::text(cx, key, &[]); + let Some(deck) = self.deck.upgrade() else { + return div().into_any_element(); + }; + let read = deck.read(cx); + let Some(tab) = read.active_tab() else { + return div().into_any_element(); + }; + let Some(design) = tab.extra.design.clone() else { + return div().into_any_element(); + }; + let (masters, size) = (tab.extra.masters, tab.extra.shown_size()); + let weak = self.deck.clone(); + let theme = Select::new( + "show-theme", + design + .themes + .iter() + .map(|name| Choice::new(name.clone(), name.clone())), + ) + .label(label("show.design.theme")) + .selected(design.theme.clone()) + .on_change({ + let weak = weak.clone(); + move |value, _, cx| { + let value = value.to_string(); + on_deck(&weak, cx, |deck, cx| { + deck.design_command("slide.theme", &[&value], cx) + }) + } + }); + let mut rows = DescriptionList::new(); + if !masters { + let mut layouts: Vec = LAYOUTS + .iter() + .map(|n| Choice::new(n.to_string(), label(&format!("show.layout.{n}")))) + .collect(); + if !LAYOUTS.contains(&design.layout) { + layouts.push(Choice::new( + design.layout.to_string(), + label("show.layout.other"), + )); + } + let layout = Select::new("show-layout", layouts) + .label(label("show.design.layout")) + .selected(design.layout.to_string()) + .on_change({ + let weak = weak.clone(); + move |value, _, cx| { + let value = value.to_string(); + on_deck(&weak, cx, |deck, cx| { + deck.design_command("slide.layout", &[&value], cx) + }) + } + }); + let master = Select::new( + "show-master", + design + .masters + .iter() + .map(|name| Choice::new(name.clone(), name.clone())), + ) + .label(label("show.design.master")) + .selected(design.master.clone()) + .on_change({ + let weak = weak.clone(); + move |value, _, cx| { + let value = value.to_string(); + on_deck(&weak, cx, |deck, cx| { + deck.design_command("slide.master", &[&value], cx) + }) + } + }); + rows = rows + .item(label("show.design.layout"), field(layout)) + .item(label("show.design.master"), field(master)); + } + rows = rows.item(label("show.design.theme"), field(theme)); + if !masters { + rows = size_rows(rows, &weak, size, cx); + } + let toggle = { + let weak = weak.clone(); + let key = if masters { + "show.masters.close" + } else { + "show.masters.edit" + }; + Button::new("show-masters", label(key)) + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| on_deck(&weak, cx, |deck, cx| deck.masters(!masters, cx))) + }; + let insert = masters.then(|| { + Button::new("show-master-new", label("show.masters.new")) + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| { + on_deck(&weak, cx, |deck, cx| { + deck.run_active("master.insert", &[], cx); + if let Some(id) = deck.core.active { + deck.force_reread(id, cx); + } + }) + }) + }); + div() + .size_full() + .p_3() + .flex() + .flex_col() + .gap_3() + .child(rows) + .child(div().flex().gap_2().child(toggle).children(insert)) + .into_any_element() + } +} + +fn field(control: impl IntoElement) -> gpui::Div { + div().w_full().max_w_40().child(control) +} + +/// The size preset, then width and height in centimetres. +fn size_rows( + rows: DescriptionList, + weak: &WeakEntity, + size: (i64, i64), + cx: &App, +) -> DescriptionList { + let label = |key: &str| Deck::text(cx, key, &[]); + let current = preset(size).unwrap_or("custom"); + let mut sizes: Vec = SIZES + .iter() + .map(|(key, _)| Choice::new(*key, label(&format!("show.size.{key}")))) + .collect(); + if current == "custom" { + sizes.push(Choice::new("custom", label("show.size.custom"))); + } + let picking = weak.clone(); + let presets = Select::new("show-size", sizes) + .label(label("show.design.size")) + .selected(current) + .on_change(move |value, _, cx| { + let Some((_, size)) = SIZES.iter().find(|(key, _)| *key == value.as_ref()) else { + return; + }; + let size = *size; + on_deck(&picking, cx, |deck, cx| deck.resize(size, cx)) + }); + let (w, h) = size; + let side = |id: &'static str, value: i64, width: bool| { + let weak = weak.clone(); + let name = label(if width { + "show.design.width" + } else { + "show.design.height" + }); + NumberInput::new(id, value as f64 / 1000.0) + .label(name) + .range(1.0, 300.0) + .step(0.1) + .precision(2) + .on_commit(move |cm, _, cx| { + let value = (cm * 1000.0).round() as i64; + // Build on a size still on its way. + on_deck(&weak, cx, |deck, cx| { + let Some((w, h)) = deck.active_tab().map(|tab| tab.extra.shown_size()) else { + return; + }; + deck.resize(if width { (value, h) } else { (w, value) }, cx) + }) + }) + }; + rows.item(label("show.design.size"), field(presets)) + .item( + label("show.design.width"), + div().w_full().max_w_32().child(side("show-width", w, true)), + ) + .item( + label("show.design.height"), + div().w_full().max_w_32().child(side("show-height", h, false)), + ) +} + +#[cfg(test)] +mod tests { + #[test] + fn sizes_match_presets_by_millimetre() { + assert_eq!(super::preset(super::mm100((15875, 8930))), Some("hd")); + assert_eq!( + super::preset(super::mm100((14401, 10801))), + Some("standard") + ); + assert_eq!(super::preset((10000, 10000)), None); + assert_eq!(super::mm100((14400, 10800)), (25400, 19050)); + } +} diff --git a/apps/ely-show/src/dialog.rs b/apps/ely-show/src/dialog.rs new file mode 100644 index 0000000..dedab59 --- /dev/null +++ b/apps/ely-show/src/dialog.rs @@ -0,0 +1,88 @@ +//! ElyShow's one open dialog. + +use ely_gpui_component::forms::TextInput; +use gpui::{App, Context, Entity, IntoElement as _}; + +use crate::deck::Deck; + +/// What the open dialog asks. +pub enum Dialog { + New(crate::new::Choosing), + Table { + tab: u64, + columns: u16, + rows: u16, + }, + Chart(Box), + /// A new comment, or an edit of one. + Remark { + tab: u64, + edit: Option, + field: Entity, + }, + Show(crate::slideshow::start::Starting), + Output(crate::output::Output), + Check(Vec), +} + +/// Changes the open dialog's state. +pub(crate) fn edit( + this: &Entity, + cx: &mut App, + change: impl FnOnce(&mut Dialog, &mut Context), +) { + this.update(cx, |deck, cx| { + if let Some(mut dialog) = deck.dialog.take() { + change(&mut dialog, cx); + deck.dialog = Some(dialog); + } + cx.notify(); + }); +} + +impl Deck { + /// A read that will open a dialog; earlier ones lapse. + pub fn ask_dialog(&mut self) -> u64 { + self.dialog_asked += 1; + self.dialog_asked + } + + /// A dialog or tab change ends earlier asks. + pub(crate) fn watch_dialogs(&mut self) { + let now = (self.dialog.as_ref().map(std::mem::discriminant), self.core.active); + if self.dialog_seen != now { + self.dialog_seen = now; + self.dialog_asked += 1; + } + } + + /// Whether read `asked` may open its dialog now. + pub fn dialog_due(&self, asked: u64, id: u64) -> bool { + self.dialog_asked == asked && self.dialog.is_none() && self.core.active == Some(id) + } + + /// The open dialog, drawn. + pub fn dialog_element(&mut self, cx: &mut Context) -> Option { + if matches!(self.dialog, Some(Dialog::New(_))) { + return self.new_dialog(cx); + } + Some(match self.dialog.as_ref()? { + Dialog::New(_) => unreachable!("drawn above"), + Dialog::Table { columns, rows, .. } => self.table_dialog((*columns, *rows), cx), + Dialog::Chart(editing) => self.chart_dialog(editing, cx), + Dialog::Remark { tab, edit, field } => self.remark_dialog((*tab, edit), field, cx), + Dialog::Show(starting) => self.show_dialog(starting, cx), + Dialog::Check(found) => self.check_dialog(found, cx), + Dialog::Output(output) => { + let closing = cx.entity(); + let dismiss = move |_: &mut gpui::Window, cx: &mut App| { + closing.update(cx, |deck, cx| { + deck.dialog = None; + cx.notify(); + }) + }; + crate::output::output_dialog(cx.entity(), output, dismiss, cx).into_any_element() + } + }) + } +} diff --git a/apps/ely-show/src/guides.rs b/apps/ely-show/src/guides.rs new file mode 100644 index 0000000..488ce32 --- /dev/null +++ b/apps/ely-show/src/guides.rs @@ -0,0 +1,183 @@ +//! Guides over the canvas; LOK paints none (SHOW-06). + +use ely_gpui_component::theme::ActiveTheme as _; +use ely_lok::{ + LokView, + show::{Guide, GuideKind}, +}; +use ely_office::prelude::*; +use gpui::{ + AnyElement, Context, CursorStyle, Entity, InteractiveElement as _, IntoElement, MouseButton, + MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, SharedString, Styled as _, + div, px, +}; + +use crate::deck::Deck; + +/// The strip a pointer grabs a guide by. +const GRIP: f32 = 6.0; + +/// A guide being dragged, and where it is now. +#[derive(Clone, Copy)] +pub struct Drag { + pub index: usize, + pub kind: GuideKind, + pub at: Point, +} + +fn twips(mm100: i64) -> i32 { + (mm100 * 72 / 127) as i32 +} + +fn mm100(twips: i32) -> i64 { + i64::from(twips) * 127 / 72 +} + +impl Deck { + /// The guide lines and their grips, over `view`. + pub fn guide_layer( + &self, + view: &Entity, + cx: &mut Context, + ) -> Option { + let tab = self.active_tab()?; + let guides = tab.extra.objects.as_ref()?.guides.clone(); + if guides.is_empty() { + return None; + } + let (w, h) = tab.extra.shown_size(); + let lok = view.read(cx); + let top_left = lok.place((0, 0)); + let bottom_right = lok.place((twips(w), twips(h))); + let color = cx.theme().colors.accent; + let drag = self.guide_drag; + let places: Vec> = guides + .iter() + .enumerate() + .map( + |(index, guide)| match drag.filter(|drag| drag.index == index) { + Some(drag) => drag.at, + None => lok.place((twips(guide.at.0), twips(guide.at.1))), + }, + ) + .collect(); + let bounds = (top_left, bottom_right); + let lines: Vec = guides + .iter() + .enumerate() + .map(|(index, guide)| self.guide_line((index, guide), places[index], bounds, color, cx)) + .collect(); + let mut layer = div().absolute().inset_0().children(lines); + if drag.is_some() { + // Catches the pointer anywhere until release. + layer = layer.child( + div() + .id("show-guide-drag") + .absolute() + .inset_0() + .on_mouse_move(cx.listener(|deck, event: &MouseMoveEvent, _, cx| { + deck.drag_guide(event.position, cx) + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|deck, _: &MouseUpEvent, _, cx| deck.drop_guide(cx)), + ), + ); + } + Some(layer.into_any_element()) + } + + fn guide_line( + &self, + (index, guide): (usize, &Guide), + at: Point, + (from, to): (Point, Point), + color: gpui::Hsla, + cx: &mut Context, + ) -> AnyElement { + let id = SharedString::from(format!("show-guide-{index}")); + let kind = guide.kind; + let grip = div() + .id(id) + .absolute() + .flex() + .items_center() + .justify_center() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |deck, event: &gpui::MouseDownEvent, _, cx| { + let origin = deck.view_origin(cx); + deck.guide_drag = Some(Drag { + index, + kind, + at: event.position - origin, + }); + cx.stop_propagation(); + cx.notify(); + }), + ); + let half = px(GRIP / 2.0); + match kind { + GuideKind::Horizontal => grip + .left(from.x) + .w(to.x - from.x) + .top(at.y - half) + .h(px(GRIP)) + .cursor(CursorStyle::ResizeUpDown) + .child(div().w_full().h(px(1.0)).bg(color)), + GuideKind::Vertical => grip + .top(from.y) + .h(to.y - from.y) + .left(at.x - half) + .w(px(GRIP)) + .cursor(CursorStyle::ResizeLeftRight) + .child(div().h_full().w(px(1.0)).bg(color)), + GuideKind::Point => grip + .left(at.x - half) + .top(at.y - half) + .size(px(GRIP)) + .cursor(CursorStyle::Crosshair) + .child(div().size(px(GRIP)).border_1().border_color(color)), + } + .into_any_element() + } + + fn view_origin(&self, cx: &Context) -> Point { + self.active_tab() + .map_or_else(Point::default, |tab| tab.view.read(cx).origin()) + } + + fn drag_guide(&mut self, position: Point, cx: &mut Context) { + let origin = self.view_origin(cx); + if let Some(drag) = self.guide_drag.as_mut() { + drag.at = position - origin; + cx.notify(); + } + } + + /// Moves the guide where dropped; off the slide deletes it. + fn drop_guide(&mut self, cx: &mut Context) { + let Some(drag) = self.guide_drag.take() else { + return; + }; + let Some(tab) = self.active_tab() else { + return; + }; + let (w, h) = tab.extra.shown_size(); + let (x, y) = tab.view.read(cx).twips_at(drag.at); + let (x, y) = (mm100(x), mm100(y)); + let off = match drag.kind { + GuideKind::Horizontal => !(0..=h).contains(&y), + GuideKind::Vertical => !(0..=w).contains(&x), + GuideKind::Point => !(0..=w).contains(&x) || !(0..=h).contains(&y), + }; + let index = drag.index.to_string(); + if off { + log::info!("show: guide {index} dragged off the slide"); + self.object_command("guide.delete", &[&index, "true"], cx); + } else { + self.object_command("guide.move", &[&index, &x.to_string(), &y.to_string()], cx); + } + cx.notify(); + } +} diff --git a/apps/ely-show/src/insert.rs b/apps/ely-show/src/insert.rs new file mode 100644 index 0000000..d897d29 --- /dev/null +++ b/apps/ely-show/src/insert.rs @@ -0,0 +1,152 @@ +//! The Insert and View menus (SHOW-03 to 06). + +use ely_office::prelude::*; +use gpui::{App, Context, Div, InteractiveElement as _, Menu, MenuItem, actions}; + +use crate::deck::Deck; + +actions!( + show, + [ + InsertTextBox, + InsertRectangle, + InsertEllipse, + InsertTriangle, + InsertLine, + InsertArrow, + InsertConnector, + InsertPicture, + InsertMedia, + InsertTable, + InsertChart, + InsertRemark, + ShowRemarks, + SnapToGrid, + SnapToGuides, + AddHorizontalGuide, + AddVerticalGuide + ] +); + +/// Insert and View; View checks what is on. +pub fn menus((grid, guides, remarks): (bool, bool, bool), cx: &App) -> [Menu; 2] { + let label = |key: &str| Deck::text(cx, key, &[]); + let insert = Menu::new(label("menu.insert")).items([ + MenuItem::action(label("show.insert.text"), InsertTextBox), + MenuItem::separator(), + MenuItem::action(label("show.insert.rectangle"), InsertRectangle), + MenuItem::action(label("show.insert.ellipse"), InsertEllipse), + MenuItem::action(label("show.insert.triangle"), InsertTriangle), + MenuItem::action(label("show.insert.line"), InsertLine), + MenuItem::action(label("show.insert.arrow"), InsertArrow), + MenuItem::action(label("show.insert.connector"), InsertConnector), + MenuItem::separator(), + MenuItem::action(label("show.insert.picture"), InsertPicture), + MenuItem::action(label("show.insert.media"), InsertMedia), + MenuItem::action(label("show.insert.table"), InsertTable), + MenuItem::action(label("show.insert.chart"), InsertChart), + MenuItem::separator(), + MenuItem::action(label("show.insert.remark"), InsertRemark), + ]); + let view = Menu::new(label("menu.view")).items([ + MenuItem::action(label("show.view.remarks"), ShowRemarks).checked(remarks), + MenuItem::separator(), + MenuItem::action(label("show.view.snap_grid"), SnapToGrid).checked(grid), + MenuItem::action(label("show.view.snap_guides"), SnapToGuides).checked(guides), + MenuItem::separator(), + MenuItem::action(label("show.view.guide_across"), AddHorizontalGuide), + MenuItem::action(label("show.view.guide_down"), AddVerticalGuide), + MenuItem::separator(), + ]); + let view = view.items(ely_office::zoom_items(true, cx)); + [insert, view] +} + +/// Binds an action to an object command. +macro_rules! run { + ($root:expr, $cx:expr, $action:ty, $id:literal, $values:expr) => { + $root.on_action( + $cx.listener(|deck, _: &$action, _, cx| deck.object_command($id, $values, cx)), + ) + }; +} + +pub fn bind(root: Div, cx: &mut Context) -> Div { + let root = run!(root, cx, InsertTextBox, "draw.text", &["true"]); + let root = run!( + root, + cx, + InsertRectangle, + "draw.shape", + &["rectangle", "true"] + ); + let root = run!(root, cx, InsertEllipse, "draw.shape", &["ellipse", "true"]); + let root = run!( + root, + cx, + InsertTriangle, + "draw.shape", + &["isosceles-triangle", "true"] + ); + let root = run!(root, cx, InsertLine, "draw.line", &[]); + let root = run!(root, cx, InsertArrow, "draw.arrow", &[]); + let root = run!(root, cx, InsertConnector, "draw.connector", &[]); + let root = run!(root, cx, SnapToGrid, "view.snap_grid", &[]); + let root = run!(root, cx, SnapToGuides, "view.snap_guides", &[]); + root.on_action(cx.listener(|deck, _: &InsertPicture, _, cx| deck.pick_picture(false, cx))) + .on_action(cx.listener(|deck, _: &InsertMedia, _, cx| deck.pick_media(cx))) + .on_action(cx.listener(|deck, _: &InsertTable, _, cx| deck.ask_table(cx))) + .on_action(cx.listener(|deck, _: &InsertChart, _, cx| deck.ask_chart(cx))) + .on_action( + cx.listener(|deck, _: &InsertRemark, window, cx| deck.ask_remark(None, window, cx)), + ) + .on_action(cx.listener(|deck, _: &ShowRemarks, _, cx| deck.toggle_remarks(cx))) + .on_action(cx.listener(|deck, _: &AddHorizontalGuide, _, cx| deck.add_guide(false, cx))) + .on_action(cx.listener(|deck, _: &AddVerticalGuide, _, cx| deck.add_guide(true, cx))) +} + +impl Deck { + /// Runs an object command; the next read shows the result. + pub fn object_command(&mut self, id: &str, values: &[&str], cx: &mut Context) { + let Some(tab) = self.core.active else { + return; + }; + self.run_in(Some(tab), id, values, cx); + self.force_reread(tab, cx); + } + + /// Inserts a picture, or replaces the selected one. + pub fn pick_picture(&mut self, replace: bool, cx: &mut Context) { + self.pick(false, cx, move |deck, path, cx| { + let url = ely_lok::file_url(&path); + let id = if replace { + "image.replace" + } else { + "image.insert" + }; + deck.object_command(id, &[&url], cx) + }); + } + + /// Shows comments, or properties, in the right column. + fn toggle_remarks(&mut self, cx: &mut Context) { + self.remarks_open = !self.remarks_open; + self.snap_menus = None; + self.sync_menus(cx); + cx.notify(); + } + + /// A guide through the middle of the slide. + fn add_guide(&mut self, vertical: bool, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return; + }; + let (w, h) = tab.extra.shown_size(); + let (kind, x, y) = if vertical { + ("1", w / 2, 0) + } else { + ("0", 0, h / 2) + }; + self.object_command("guide.add", &[kind, &x.to_string(), &y.to_string()], cx); + } +} diff --git a/apps/ely-show/src/list.rs b/apps/ely-show/src/list.rs new file mode 100644 index 0000000..1ce3e6d --- /dev/null +++ b/apps/ely-show/src/list.rs @@ -0,0 +1,73 @@ +//! The shown slide's objects, front last (SHOW-06). + +use ely_gpui_component::{ + lists::{ListItem, SelectableList}, + primitives::{Icon, IconName}, +}; +use ely_lok::show::Object; +use ely_office::prelude::*; +use gpui::{App, Context, IntoElement, SharedString, WeakEntity}; + +use crate::deck::Deck; +use crate::design::{Panel, on_deck}; + +/// What the list calls `object`. +fn title(object: &Object, cx: &App) -> SharedString { + if !object.name.is_empty() { + return object.name.clone().into(); + } + Deck::text(cx, &format!("show.kind.{}", object.kind), &[]) +} + +impl Panel { + pub(crate) fn object_list( + &self, + objects: &[Object], + cx: &mut Context, + ) -> impl IntoElement { + let mut list = SelectableList::new("show-objects") + .label(Deck::text(cx, "show.objects", &[])) + .multiple(); + let mut chosen = Vec::new(); + for (at, object) in objects.iter().enumerate() { + let key = SharedString::from(at.to_string()); + let mut item = ListItem::new( + SharedString::from(format!("show-object-{at}")), + title(object, cx), + ); + if !object.placeholder.is_empty() { + let key = format!("show.placeholder.{}", object.placeholder); + item = item.description(Deck::text(cx, &key, &[])); + } + if object.locked { + item = item.trailing(Icon::new(IconName::Lock)); + } + if object.selected { + chosen.push(key.clone()); + } + list = list.row(key, item); + } + let deck: WeakEntity = self.deck.clone(); + list.selected(chosen).on_change(move |keys, _, cx| { + let picked: Vec = keys.iter().map(ToString::to_string).collect(); + on_deck(&deck, cx, |deck, cx| deck.select_objects(&picked, cx)) + }) + } +} + +impl Deck { + /// Selects the objects at `picked`, in order. + pub fn select_objects(&mut self, picked: &[String], cx: &mut Context) { + let Some(tab) = self.core.active else { + return; + }; + if picked.is_empty() { + self.run_in(Some(tab), "object.deselect", &["false"], cx); + } + let adds: Vec<[&str; 2]> = + picked.iter().enumerate().map(|(n, at)| [at.as_str(), if n > 0 { "true" } else { "false" }]).collect(); + let commands: Vec<(&str, &[&str])> = adds.iter().map(|values| ("object.select", values.as_slice())).collect(); + self.run_all_in(tab, &commands, cx); + self.force_reread(tab, cx); + } +} diff --git a/apps/ely-show/src/main.rs b/apps/ely-show/src/main.rs new file mode 100644 index 0000000..8ab6928 --- /dev/null +++ b/apps/ely-show/src/main.rs @@ -0,0 +1,29 @@ +mod chart; +mod check; +mod deck; +mod design; +mod dialog; +mod guides; +mod insert; +mod list; +mod media; +mod motion; +mod new; +mod notes; +mod object; +mod output; +mod preview; +mod remarks; +mod slides; +mod slideshow; +mod table; +mod text; + +use ely_platform::AppId; + +fn main() { + ely_ui::run(AppId::Show, deck::open); +} + +#[cfg(test)] +mod tests; diff --git a/apps/ely-show/src/media.rs b/apps/ely-show/src/media.rs new file mode 100644 index 0000000..d64fe22 --- /dev/null +++ b/apps/ely-show/src/media.rs @@ -0,0 +1,356 @@ +//! Slide audio and video: insert, play, explain failures (SHOW-10). + +use std::{ + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use ely_gpui_component::{ + buttons::Button, data_display::PropertyGroup, feedback::InlineMessage, primitives::Severity, + theme::ControlSize, +}; +use ely_lok::{LokView, show::Media}; +use ely_office::prelude::*; +use ely_platform::media::{Player, State}; +use gpui::{ + AnyElement, App, Context, Entity, IntoElement, ParentElement as _, Styled as _, SurfaceSource, + TaskExt as _, Window, div, +}; + +use crate::deck::Deck; +use crate::design::{Panel, on_deck}; + +/// How long a file may take to show it plays. +const PROBE: Duration = Duration::from_secs(10); +/// A sound's frame on the slide, 1/100 mm. +const SOUND: (i64, i64) = (2000, 2000); + +/// A file checked before it goes in. +pub struct Probe { + tab: u64, + path: PathBuf, + player: Player, + started: Instant, +} + +/// The media object playing: tab, slide, place, URL. +pub struct Playing { + tab: u64, + slide: String, + index: usize, + url: String, + player: Player, + frame: Option, +} + +/// `m:ss`. +fn clock(seconds: f64) -> String { + let whole = seconds.max(0.0).round() as u64; + format!("{}:{:02}", whole / 60, whole % 60) +} + +fn name(path: &Path) -> String { + path.file_name().map_or_else( + || path.display().to_string(), + |n| n.to_string_lossy().into_owned(), + ) +} + +impl Deck { + /// Picks a sound or video, checking it plays first. + pub fn pick_media(&mut self, cx: &mut Context) { + self.pick(false, cx, |deck, path, cx| deck.probe_media(path, cx)); + } + + fn probe_media(&mut self, path: PathBuf, cx: &mut Context) { + let Some(tab) = self.core.active else { + return; + }; + let player = match Player::open(&path) { + Ok(player) => player, + Err(error) => return self.media_refused(&path, &error.to_string(), cx), + }; + log::info!("show: checks a media file"); + let started = Instant::now(); + self.probe = Some(Probe { + tab, + path, + player, + started, + }); + cx.spawn(async move |this, cx| -> gpui::Result<()> { + loop { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + if this.update(cx, |deck, cx| deck.probe_tick(cx))? { + return Ok(()); + } + } + }) + .detach_and_log_err(cx); + } + + /// One look at the file being checked; true when settled. + fn probe_tick(&mut self, cx: &mut Context) -> bool { + let Some(probe) = &self.probe else { + return true; + }; + let state = probe.player.state(); + if state == State::Loading && probe.started.elapsed() < PROBE { + return false; + } + let probe = self.probe.take().expect("checked above"); + match state { + State::Ready { video, .. } => self.insert_media(&probe, video, cx), + State::Failed(reason) => self.media_refused(&probe.path, &reason, cx), + State::Loading => self.media_refused(&probe.path, "it did not load in time", cx), + } + true + } + + fn insert_media(&mut self, probe: &Probe, video: Option<(u32, u32)>, cx: &mut Context) { + let Some(tab) = self.tab_mut(probe.tab) else { + return; + }; + let slide = tab.extra.shown_size(); + // Pixels at 96 per inch, within most of the slide. + let (w, h) = match video { + Some((w, h)) => { + let (w, h) = (i64::from(w) * 2540 / 96, i64::from(h) * 2540 / 96); + let fit = (slide.0 as f64 * 0.8 / w as f64) + .min(slide.1 as f64 * 0.8 / h as f64) + .min(1.0); + ((w as f64 * fit) as i64, (h as f64 * fit) as i64) + } + None => SOUND, + }; + let url = ely_lok::file_url(&probe.path); + log::info!("show: inserts media {w} x {h}"); + self.run_in( + Some(probe.tab), + "media.insert", + &[&url, "false", &w.to_string(), &h.to_string()], + cx, + ); + self.force_reread(probe.tab, cx); + } + + fn media_refused(&mut self, path: &Path, reason: &str, cx: &mut Context) { + log::warn!("show: media refused: {reason}"); + let text = Deck::text( + cx, + "show.media.refused", + &[("name", &name(path)), ("reason", reason)], + ); + self.toast(text, Severity::Danger, cx); + } + + /// The shown slide's name and its media at `index`. + fn here(&self, index: usize) -> Option<(u64, String, Media)> { + let tab = self.active_tab()?; + let slide = tab + .extra + .slides + .list + .get(tab.extra.slides.current)? + .name + .clone(); + let media = tab.extra.media.iter().find(|media| media.index == index)?; + Some((tab.id, slide, media.clone())) + } + + /// Stops playback unless the same object is still shown. + pub(crate) fn check_playing(&mut self) { + let Some(playing) = &self.playing else { + return; + }; + let same = self.here(playing.index).is_some_and(|(tab, slide, media)| { + (tab, slide, media.url) == (playing.tab, playing.slide.clone(), playing.url.clone()) + }); + if !same { + log::info!("show: the playing media object is gone from view"); + playing.player.pause(); + self.playing = None; + } + } + + /// The media object picked on the shown slide. + pub(crate) fn picked_media(&self) -> Option<(u64, Media)> { + let tab = self.active_tab()?; + let objects = tab.extra.objects.as_ref()?; + let mut picked = objects + .objects + .iter() + .enumerate() + .filter(|(_, o)| o.selected); + let (at, _) = picked.next().filter(|_| picked.next().is_none())?; + let media = tab.extra.media.iter().find(|media| media.index == at)?; + Some((tab.id, media.clone())) + } + + /// Plays or pauses the picked media object. + pub fn toggle_media(&mut self, cx: &mut Context) { + let Some((_, media)) = self.picked_media() else { + return; + }; + let Some((tab, slide, _)) = self.here(media.index) else { + return; + }; + self.check_playing(); + if let Some(playing) = self.playing.as_ref().filter(|p| p.index == media.index) { + match (playing.player.playing(), playing.player.ended()) { + (true, _) => playing.player.pause(), + // A finished clip starts over. + (false, true) => { + playing.player.seek(0.0); + playing.player.play(); + } + (false, false) => playing.player.play(), + } + return cx.notify(); + } + let opened = (ely_lok::file_path(&media.file).map_err(|error| error.to_string())) + .and_then(|path| Player::open(&path).map_err(|error| error.to_string())); + match opened { + Ok(player) => { + log::info!("show: plays media object {}", media.index); + player.play(); + self.playing = Some(Playing { + tab, + slide, + index: media.index, + url: media.url.clone(), + player, + frame: None, + }); + } + Err(error) => self.media_refused(Path::new(&media.url), &error, cx), + } + cx.notify(); + } + + /// The playing video over its object; frames while it runs. + pub(crate) fn media_layer( + &mut self, + view: &Entity, + window: &mut Window, + cx: &App, + ) -> Option { + self.check_playing(); + let index = self.playing.as_ref()?.index; + let tab = self.active_tab()?; + let rect = tab.extra.objects.as_ref()?.objects.get(index)?.rect; + let repeat = tab.extra.media.iter().any(|m| m.index == index && m.repeat); + let playing = self.playing.as_mut()?; + // A failure shows in the panel, from the player's state. + let drawing = matches!(playing.player.tick(repeat), Ok(true)); + if let Some(frame) = playing.player.frame() { + #[cfg(target_os = "macos")] + { + playing.frame = Some(frame.into()); + } + #[cfg(not(target_os = "macos"))] + match frame {} + } + if drawing { + window.request_animation_frame(); + } + let frame = playing.frame.clone()?; + let twips = |mm100: i64| (mm100 * 72 / 127) as i32; + let (x, y, w, h) = rect; + let lok = view.read(cx); + let (top_left, bottom_right) = ( + lok.place((twips(x), twips(y))), + lok.place((twips(x + w), twips(y + h))), + ); + let bounds = (top_left, bottom_right - top_left); + Some(video(frame, bounds)) + } +} + +/// A video picture over its object. +#[cfg(target_os = "macos")] +pub(crate) fn video( + frame: SurfaceSource, + (at, size): (gpui::Point, gpui::Point), +) -> AnyElement { + gpui::surface(frame) + .absolute() + .left(at.x) + .top(at.y) + .w(size.x) + .h(size.y) + .into_any_element() +} + +/// No platform here makes video pictures. +#[cfg(not(target_os = "macos"))] +pub(crate) fn video( + frame: SurfaceSource, + _: (gpui::Point, gpui::Point), +) -> AnyElement { + match frame {} +} + +impl Panel { + /// Play, where it is, and why it cannot play. + pub(crate) fn media_group(&self, cx: &mut Context) -> Option { + let deck = self.deck.upgrade()?; + let (tab, media) = deck.read(cx).picked_media()?; + let label = |key: &str| Deck::text(cx, key, &[]); + let group = PropertyGroup::new(label("show.media.group")); + if media.missing { + let what = ely_lok::file_path(&media.url) + .map_or(media.url.clone(), |path| path.display().to_string()); + let why = Deck::text(cx, "show.media.missing", &[("path", &what)]); + return Some(group.row( + label("show.media.file"), + InlineMessage::new(Severity::Danger, why), + )); + } + let read = deck.read(cx); + let playing = read + .playing + .as_ref() + .filter(|p| (p.tab, p.index) == (tab, media.index)); + let (running, state) = match playing { + Some(p) => ( + p.player.playing(), + Some((p.player.state(), p.player.position())), + ), + None => (false, None), + }; + if let Some((State::Failed(reason), _)) = &state { + let why = Deck::text(cx, "show.media.failed", &[("reason", reason)]); + return Some(group.row( + label("show.media.file"), + InlineMessage::new(Severity::Danger, why), + )); + } + let weak = self.deck.clone(); + let toggle = Button::new( + "show-media-play", + label(if running { + "show.media.pause" + } else { + "show.media.play" + }), + ) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| on_deck(&weak, cx, |deck, cx| deck.toggle_media(cx))); + let time = match state { + Some((State::Ready { seconds, .. }, at)) => { + format!("{} / {}", clock(at), clock(seconds)) + } + _ => clock(0.0), + }; + let row = div() + .flex() + .items_center() + .gap_2() + .child(toggle) + .child(time); + Some(group.row(label("show.media.play"), row)) + } +} diff --git a/apps/ely-show/src/motion.rs b/apps/ely-show/src/motion.rs new file mode 100644 index 0000000..f0439fa --- /dev/null +++ b/apps/ely-show/src/motion.rs @@ -0,0 +1,432 @@ +//! The shown slide's transition and effects (SHOW-09). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Choice, NumberInput, Select, Switch}, + lists::{ListItem, SelectableList}, + menus::{DropdownMenu, Menu, MenuItem}, + theme::ControlSize, +}; +use ely_lok::show::{EFFECTS, Effect, TRANSITIONS, Transition}; +use ely_office::prelude::*; +use gpui::{ + AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div, +}; +use serde_json::json; + +use crate::deck::{Deck, Extra}; +use crate::design::{Panel, on_deck}; + +/// Ely's name for an effect, else the kernel's. +fn effect_name(effect: &Effect, cx: &App) -> SharedString { + match EFFECTS.contains(&effect.preset.as_str()) { + true => Deck::text(cx, &format!("show.effect.{}", &effect.preset[4..]), &[]), + false => effect.label.clone().into(), + } +} + +/// Seconds sent once, though Enter and blur both commit. +pub(crate) fn dedupe( + panel: gpui::Entity, + field: impl Into, + now: f64, + send: impl Fn(&mut Deck, f64, &mut Context) + Clone + 'static, +) -> impl Fn(f64, &mut gpui::Window, &mut App) + 'static { + let ms = |seconds: f64| (seconds * 1000.0).round() as i64; + let field = field.into(); + move |n, _, cx| { + let send = send.clone(); + let deliver = move |deck: &mut Deck, cx: &mut Context| send(deck, n, cx); + crate::object::commit(&panel, field.clone(), (ms(n), Some(ms(now))), deliver, cx) + } +} + +/// A field of one effect on one slide. +pub(crate) fn effect_key(field: &str, slide: &str, (at, effect): (usize, &Effect)) -> String { + format!("effect.{field}.{slide}.{at}.{}", effect.hash) +} + +fn number(id: &'static str, label: SharedString, now: f64, range: (f64, f64)) -> NumberInput { + NumberInput::new(id, now) + .label(label) + .range(range.0, range.1) + .step(0.1) + .precision(2) + .size(ControlSize::Sm) +} + +impl Extra { + fn slide_name(&self) -> Option<&String> { + Some(&self.slides.list.get(self.slides.current)?.name) + } + + /// The transition to show: sent, else read. + pub(crate) fn shown_transition(&self) -> Option { + match (&self.transition_asked, self.slide_name()) { + (Some((slide, asked, _)), Some(now)) if slide == now => Some(asked.clone()), + _ => Some(self.motion.as_ref()?.0.clone()), + } + } + + /// The input a read may settle: landed before it began. + pub(crate) fn transition_landed(&self, view: &ely_lok::LokView) -> Option { + let (_, _, seq) = self.transition_asked.as_ref()?; + view.reached(*seq).then_some(*seq) + } + + /// Forgets the sent transition once read after it or left. + pub(crate) fn settle_transition(&mut self, landed: Option) { + let gone = match (&self.transition_asked, self.slide_name()) { + (Some((slide, _, seq)), Some(now)) => slide != now || landed == Some(*seq), + (asked, _) => asked.is_some(), + }; + if gone { + self.transition_asked = None; + } + } +} + +impl Deck { + /// Edits the transition, atop any unread send. + pub(crate) fn send_transition( + &mut self, + edit: impl FnOnce(&mut Transition), + all: bool, + cx: &mut Context, + ) { + let Some(tab) = self.core.active.and_then(|id| self.tab_mut(id)) else { + return; + }; + let (Some(mut next), Some(slide)) = ( + tab.extra.shown_transition(), + tab.extra.slide_name().cloned(), + ) else { + return; + }; + edit(&mut next); + let (id, view) = (tab.id, tab.view.clone()); + let json = ely_lok::show::transition_json(&next, all); + self.run_in(Some(id), "slide.transition", &[&json], cx); + let seq = view.read(cx).sent(); + if let Some(tab) = self.tab_mut(id) { + tab.extra.transition_asked = Some((slide, next, seq)); + } + self.force_reread(id, cx); + } + + pub(crate) fn send_effect( + &mut self, + index: usize, + change: serde_json::Value, + cx: &mut Context, + ) { + let mut json = change; + json["index"] = json!(index); + self.design_command("animation.change", &[&json.to_string()], cx); + } +} + +impl Panel { + /// The slide's transition and effects; none on a master. + pub(crate) fn motion_view(&self, picked: bool, cx: &mut Context) -> Option { + let deck = self.deck.upgrade()?; + let extra = &deck.read(cx).active_tab()?.extra; + let (transition, effects) = (extra.shown_transition()?, extra.motion.clone()?.1); + let slide = extra.slide_name()?.clone(); + let rows = self.transition_rows(&transition, &slide, cx); + let effects = self.effect_rows(&effects, (picked, &slide), cx); + Some( + div() + .px_3() + .flex() + .flex_col() + .gap_3() + .child(rows) + .child(effects) + .into_any_element(), + ) + } + + fn transition_rows( + &self, + now: &Transition, + slide: &str, + cx: &mut Context, + ) -> DescriptionList { + let label = |key: &str| Deck::text(cx, key, &[]); + let weak = self.deck.clone(); + let change = move |cx: &mut App, edit: &dyn Fn(&mut Transition)| { + on_deck(&weak, cx, |deck, cx| deck.send_transition(edit, false, cx)); + }; + let mut choices: Vec = (TRANSITIONS.iter()) + .map(|id| { + Choice::new( + *id, + label(&format!( + "show.transition.{}", + if id.is_empty() { "none" } else { id } + )), + ) + }) + .collect(); + if !TRANSITIONS.contains(&now.preset.as_str()) { + choices.push(Choice::new( + now.preset.clone(), + label("show.transition.other"), + )); + } + let pick = change.clone(); + let kind = Select::new("show-transition", choices) + .label(label("show.motion.transition")) + .selected(now.preset.clone()) + .on_change(move |value, _, cx| { + let value = value.to_string(); + pick(cx, &|t| t.preset = value.clone()) + }); + let duration = number( + "show-transition-duration", + label("show.motion.duration"), + now.duration, + (0.1, 60.0), + ) + .on_commit(dedupe( + cx.entity(), + format!("transition.duration.{slide}"), + now.duration, + |deck, n, cx| deck.send_transition(|t| t.duration = n, false, cx), + )); + let flip = change.clone(); + let auto = Switch::new("show-transition-auto", now.auto) + .name(label("show.motion.auto")) + .on_change(move |on, _, cx| flip(cx, &|t| t.auto = on)); + let mut rows = DescriptionList::new() + .item(label("show.motion.transition"), kind) + .item(label("show.motion.duration"), div().w_full().max_w_32().child(duration)) + .item(label("show.motion.auto"), auto); + if now.auto { + let time = number( + "show-transition-time", + label("show.motion.time"), + now.time, + (0.0, 3600.0), + ) + .on_commit(dedupe( + cx.entity(), + format!("transition.time.{slide}"), + now.time, + |deck, n, cx| deck.send_transition(|t| t.time = n, false, cx), + )); + rows = rows.item(label("show.motion.time"), div().w_full().max_w_32().child(time)); + } + let (everyone, preview) = (self.deck.clone(), self.deck.clone()); + let buttons = div() + .flex() + .flex_wrap() + .gap_1() + .child( + Button::new("show-transition-all", label("show.motion.all")) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + on_deck(&everyone, cx, |deck, cx| { + deck.send_transition(|_| {}, true, cx) + }) + }), + ) + .child( + Button::new( + "show-transition-preview", + label("show.motion.preview_transition"), + ) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + on_deck(&preview, cx, |deck, cx| deck.preview_transition(cx)) + }), + ); + rows.item(SharedString::default(), buttons) + } + + fn effect_rows( + &self, + effects: &[Effect], + (picked, slide): (bool, &str), + cx: &mut Context, + ) -> DescriptionList { + let label = |key: &str| Deck::text(cx, key, &[]); + let current = self.effect.filter(|at| *at < effects.len()); + let mut list = SelectableList::new("show-effects").label(label("show.motion.effects")); + for (at, effect) in effects.iter().enumerate() { + let start = label(&format!("show.effect.start.{}", effect.start)); + let item = ListItem::new( + SharedString::from(format!("show-effect-{at}")), + effect_name(effect, cx), + ) + .description(start); + list = list.row(SharedString::from(at.to_string()), item); + } + let this = cx.entity(); + let list = list + .selected( + current + .map(|at| SharedString::from(at.to_string())) + .into_iter() + .collect::>(), + ) + .on_change(move |keys, _, cx| { + let at = keys.first().and_then(|key| key.parse().ok()); + this.update(cx, |panel, cx| { + panel.effect = at; + cx.notify(); + }) + }); + let mut menu = Menu::new(); + for (n, preset) in EFFECTS.iter().enumerate() { + if n > 0 && EFFECTS[n - 1].split('-').nth(1) != preset.split('-').nth(1) { + menu = menu.separator(); + } + let weak = self.deck.clone(); + let name = label(&format!("show.effect.{}", &preset[4..])); + menu = menu.item( + MenuItem::new(name) + .disabled(!picked) + .on_click(move |_, cx| { + let json = json!({ "preset": preset }).to_string(); + on_deck(&weak, cx, |deck, cx| { + deck.design_command("animation.add", &[&json], cx) + }) + }), + ); + } + let preview = self.deck.clone(); + let head = div() + .flex() + .flex_wrap() + .gap_1() + .child(DropdownMenu::new( + "show-effect-add", + label("show.motion.add"), + menu, + )) + .child( + Button::new("show-effect-preview", label("show.motion.preview_effects")) + .size(ControlSize::Sm) + .disabled(effects.is_empty()) + .on_click(move |_, _, cx| { + on_deck(&preview, cx, |deck, cx| deck.preview_effects(cx)) + }), + ); + let mut rows = DescriptionList::new().stacked().item( + label("show.motion.effects"), + div().flex().flex_col().gap_1().child(head).child(list), + ); + if let Some(at) = current { + rows = self.effect_controls(rows, (at, &effects[at], slide), effects.len(), cx); + } + rows + } + + fn effect_controls( + &self, + rows: DescriptionList, + (at, effect, slide): (usize, &Effect, &str), + count: usize, + cx: &mut Context, + ) -> DescriptionList { + let label = |key: &str| Deck::text(cx, key, &[]); + let send = |change: serde_json::Value| { + let weak = self.deck.clone(); + move |cx: &mut App| { + let change = change.clone(); + on_deck(&weak, cx, move |deck, cx| deck.send_effect(at, change, cx)) + } + }; + let starts = ["click", "with", "after"] + .map(|key| Choice::new(key, label(&format!("show.effect.start.{key}")))); + let weak = self.deck.clone(); + let start = Select::new("show-effect-start", starts) + .label(label("show.motion.start")) + .selected(effect.start.clone()) + .on_change(move |value, _, cx| { + let change = json!({ "start": value.to_string() }); + on_deck(&weak, cx, move |deck, cx| deck.send_effect(at, change, cx)) + }); + let duration = number( + "show-effect-duration", + label("show.motion.duration"), + effect.duration, + (0.01, 60.0), + ) + .on_commit(dedupe( + cx.entity(), + effect_key("duration", slide, (at, effect)), + effect.duration, + move |deck, n, cx| deck.send_effect(at, json!({ "duration": n }), cx), + )); + let delay = number( + "show-effect-delay", + label("show.motion.delay"), + effect.delay, + (0.0, 60.0), + ) + .on_commit(dedupe( + cx.entity(), + effect_key("delay", slide, (at, effect)), + effect.delay, + move |deck, n, cx| deck.send_effect(at, json!({ "delay": n }), cx), + )); + let (up, down, gone) = ( + send(json!({ "move": -1 })), + send(json!({ "move": 1 })), + send(json!({ "remove": true })), + ); + let this = cx.entity(); + // The picked effect follows its move. + let follow = |to: Option| { + let this = this.clone(); + move |cx: &mut App| { + this.update(cx, |panel, cx| { + panel.effect = to; + cx.notify(); + }) + } + }; + let (went_up, went_down) = (follow(at.checked_sub(1)), follow(Some(at + 1))); + let buttons = div() + .flex() + .gap_1() + .child( + Button::new("show-effect-up", label("show.motion.up")) + .size(ControlSize::Sm) + .disabled(at == 0) + .on_click(move |_, _, cx| { + up(cx); + went_up(cx) + }), + ) + .child( + Button::new("show-effect-down", label("show.motion.down")) + .size(ControlSize::Sm) + .disabled(at + 1 == count) + .on_click(move |_, _, cx| { + down(cx); + went_down(cx) + }), + ) + .child( + Button::new("show-effect-remove", label("show.motion.remove")) + .size(ControlSize::Sm) + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| { + gone(cx); + this.update(cx, |panel, cx| { + panel.effect = None; + cx.notify(); + }) + }), + ); + rows.item(label("show.motion.start"), start) + .item(label("show.motion.duration"), div().w_full().max_w_32().child(duration)) + .item(label("show.motion.delay"), div().w_full().max_w_32().child(delay)) + .item(SharedString::default(), buttons) + } +} diff --git a/apps/ely-show/src/new.rs b/apps/ely-show/src/new.rs new file mode 100644 index 0000000..15d4ac2 --- /dev/null +++ b/apps/ely-show/src/new.rs @@ -0,0 +1,128 @@ +//! New decks: the kernel's blank, or a bundled template (SHOW-02). + +use std::{path::PathBuf, rc::Rc}; + +use ely_gpui_component::{ + buttons::Button, + documents::{Template as Pick, TemplatePicker}, + overlays::Dialog as Card, +}; +use ely_office::{in_window, prelude::*}; +use gpui::{ + AnyElement, App, AppContext as _, Context, IntoElement, ParentElement as _, TaskExt as _, + Window, +}; + +use crate::deck::Deck; +use crate::dialog::Dialog; + +/// The New dialog's templates: blank first. +pub struct Choosing { + templates: Vec, +} + +fn stem(path: &std::path::Path) -> String { + let name = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + name.replace('_', " ") +} + +impl Deck { + pub fn ask_new(&mut self, cx: &mut Context) { + match ely_lok::templates("presnt") { + Ok(templates) => { + log::info!("show: {} bundled templates", templates.len()); + self.dialog = Some(Dialog::New(Choosing { templates })); + cx.notify(); + } + Err(error) => self.engine_failed(&error.to_string(), cx), + } + } + + /// A new deck as the kernel makes one. + pub fn new_blank(&mut self, cx: &mut Context) { + if let Some(blank) = self.blank.clone() { + return self.new_from_file(&blank, cx); + } + let (worker, path) = (self.core.worker.clone(), self.core.work.join("blank.fodp")); + let writing = path.clone(); + cx.spawn(async move |this, cx| { + let written = cx + .background_spawn(async move { + let mut worker = worker.lock().expect("the worker lock"); + let doc = worker.load("private:factory/simpress")?; + let saved = doc.save_as(&mut worker, &ely_lok::file_url(&writing), "fodp"); + worker.close_doc(doc)?; + saved + }) + .await; + in_window(&this, cx, |deck, _, cx| match written { + Ok(()) => { + deck.blank = Some(path.clone()); + deck.new_from_file(&path, cx) + } + Err(error) => deck.engine_failed(&error.to_string(), cx), + }) + }) + .detach_and_log_err(cx); + } + + pub fn new_dialog(&mut self, cx: &mut Context) -> Option { + let Some(Dialog::New(Choosing { templates })) = &self.dialog else { + return None; + }; + let this = cx.entity(); + let blank = Pick { + name: Deck::text(cx, "show.new.blank", &[]), + icon: "▭".into(), + category: Deck::text(cx, "show.new.category", &[]), + description: Deck::text(cx, "show.new.blank_about", &[]), + markdown: "".into(), + }; + let picks: Vec = std::iter::once(blank) + .chain(templates.iter().map(|path| Pick { + name: stem(path).into(), + icon: "▭".into(), + category: Deck::text(cx, "show.new.category", &[]), + description: "".into(), + markdown: "".into(), + })) + .collect(); + let paths = templates.clone(); + let used = this.clone(); + let picker = + TemplatePicker::new("show-templates", Rc::new(picks)).on_use(move |ix, _, cx| { + let chosen = ix.checked_sub(1).map(|at| paths[at].clone()); + used.update(cx, |deck, cx| { + deck.dialog = None; + match chosen { + Some(template) => deck.new_from_file(&template, cx), + None => deck.new_blank(cx), + } + }) + }); + let dismiss = { + let this = this.clone(); + move |_: &mut Window, cx: &mut App| { + this.update(cx, |deck, cx| { + deck.dialog = None; + cx.notify(); + }) + } + }; + let label = Deck::text(cx, "show.new.from_file", &[]); + let card = Card::new("show-new", Deck::text(cx, "show.new.title", &[]), dismiss) + .child(picker) + .action(move |close| { + Button::new("show-new-file", label).on_click(move |_, window, cx| { + close(window, cx); + this.update(cx, |deck, cx| { + deck.pick(false, cx, |deck, path, cx| deck.new_from_file(&path, cx)) + }); + }) + }); + Some(card.into_any_element()) + } +} diff --git a/apps/ely-show/src/notes.rs b/apps/ely-show/src/notes.rs new file mode 100644 index 0000000..047b4e0 --- /dev/null +++ b/apps/ely-show/src/notes.rs @@ -0,0 +1,133 @@ +//! The speaker notes under the slide (SHOW-08). + +use ely_gpui_component::{ + feedback::InlineMessage, + forms::{Input, InputEvent, TextInput}, + primitives::Severity, + theme::ActiveTheme as _, +}; +use ely_office::prelude::*; +use gpui::{ + AnyElement, AppContext as _, Context, Entity, Focusable as _, IntoElement, ParentElement as _, + Styled as _, Window, div, px, +}; + +use crate::deck::Deck; + +/// The notes field and its last tab, slide, text. +#[derive(Default)] +pub struct NotesField { + field: Option>, + shown: Option<(u64, usize, String)>, + /// The state the shown slide number was read in. + at: Option, + _events: Option, +} + +impl Deck { + fn notes_field(&mut self, window: &mut Window, cx: &mut Context) -> Entity { + if let Some(field) = &self.notes.field { + return field.clone(); + } + let (label, hint) = ( + Deck::text(cx, "show.notes.label", &[]), + Deck::text(cx, "show.notes.hint", &[]), + ); + let field = cx.new(|cx| { + TextInput::new(window, cx) + .multi_line(3, 5) + .label(label) + .placeholder(hint) + }); + let events = cx.subscribe(&field, |deck, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Blur) { + deck.commit_notes(cx) + } + }); + self.notes = NotesField { + field: Some(field.clone()), + shown: None, + at: None, + _events: Some(events), + }; + field + } + + /// Sends typed notes to the slide they came from. + pub(crate) fn commit_notes(&mut self, cx: &mut Context) { + let (Some(field), Some((tab, part, shown))) = (&self.notes.field, &self.notes.shown) else { + return; + }; + let (tab, part, text) = (*tab, *part, field.read(cx).text().to_string()); + if &text == shown { + return; + } + let Some(at) = self.notes.at.clone() else { + return log::warn!("show: notes for slide {part} have no read state"); + }; + log::info!("show: notes for slide {part} changed"); + self.notes.shown = Some((tab, part, text.clone())); + self.run_on(tab, &at, "notes.set", &[&text, &part.to_string()], cx); + self.force_reread(tab, cx); + } + + /// The notes field, showing the read notes unless typing. + pub(crate) fn notes_input( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let tab = self.active_tab()?; + let notes = tab.extra.notes.clone()?; + let (id, part) = (tab.id, notes.part?); + let field = self.notes_field(window, cx); + let typing = field.focus_handle(cx).is_focused(window); + // Typing keeps the state the slide number was read in. + if !typing { + self.notes.at = self.read_at(id, cx); + } + let read = (id, part, notes.text.clone()); + if !typing && self.notes.shown.as_ref() != Some(&read) { + field.update(cx, |field, cx| field.set_text(notes.text, cx)); + self.notes.shown = Some(read); + } + Some(field) + } + + /// The notes pane: the field, or why it cannot edit. + pub fn notes_pane( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let notes = self.active_tab()?.extra.notes.clone()?; + let pane = div() + .h(px(120.0)) + .flex_none() + .p_2() + .border_t_1() + .border_color(cx.theme().colors.border); + if !notes.editable { + let why = Deck::text(cx, "show.notes.none", &[]); + return Some( + pane.child(InlineMessage::new(Severity::Info, why)) + .into_any_element(), + ); + } + if notes.rich { + let why = Deck::text(cx, "show.notes.rich", &[]); + let text = div() + .text_color(cx.theme().colors.fg_muted) + .child(notes.text); + let column = div() + .flex() + .flex_col() + .gap_1() + .child(InlineMessage::new(Severity::Info, why)) + .child(text); + return Some(pane.child(column).into_any_element()); + } + let field = self.notes_input(window, cx)?; + Some(pane.child(Input::new(&field)).into_any_element()) + } +} diff --git a/apps/ely-show/src/object.rs b/apps/ely-show/src/object.rs new file mode 100644 index 0000000..016bbec --- /dev/null +++ b/apps/ely-show/src/object.rs @@ -0,0 +1,379 @@ +//! The selected objects' panel (SHOW-03 to 06). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, IconButton}, + data_display::{PropertyGrid, PropertyGroup}, + forms::{NumberInput, Switch}, + primitives::IconName, + theme::ControlSize, +}; +use ely_lok::show::Selection; +use ely_office::panel::{self as kernel, palette, row}; +use ely_office::prelude::*; +use gpui::{ + AnyElement, App, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, + Window, div, +}; + +use crate::deck::Deck; +use crate::design::{Panel, on_deck}; + +/// Centimetres: 1/100 mm per cm, step, places. +const CM: Unit = (1000.0, 0.1, 2, twips); +/// The swatches' clear buttons. +pub(crate) const NO_FILL: kernel::Clear = ("show.object.no_fill", "object.fill_none", "0"); +pub(crate) const NO_LINE: kernel::Clear = ("show.object.no_line", "object.line_none", "0"); +/// 1/100 mm in a point. +const POINT: f64 = 2540.0 / 72.0; + +/// Shown units: scale, step, places, then what the kernel takes. +type Unit = (f64, f64, usize, fn(i64) -> i64); + +/// LOK reads transform arguments as twips. +pub(crate) fn twips(mm100: i64) -> i64 { + (mm100 * 72 + 63).div_euclid(127) +} + +fn same(value: i64) -> i64 { + value +} + +impl kernel::Panel for Panel { + fn state(&self, uno: &str, cx: &App) -> Option { + let deck = self.deck.upgrade()?; + let tab = deck.read(cx).active_tab()?; + let view = tab.view.read(cx); + view.state(uno).map(|state| state.to_string().into()) + } + + fn run(&mut self, id: &str, values: &[&str], cx: &mut Context) { + on_deck(&self.deck, cx, |deck, cx| { + deck.object_command(id, values, cx) + }); + } +} + +/// Sends `value` unless it is there or on its way. +pub(crate) fn commit( + this: &Entity, + field: impl Into, + (value, now): (i64, Option), + send: impl FnOnce(&mut Deck, &mut Context) + 'static, + cx: &mut App, +) { + let field = field.into(); + this.update(cx, |panel, cx| { + let read = panel.deck.upgrade().and_then(|deck| { + deck.read(cx) + .active_tab() + .map(|tab| (tab.id, tab.extra.reads)) + }); + if panel.seen != read { + panel.sent.clear(); + panel.seen = read; + } + let mark = (value, now); + if now == Some(value) || panel.sent.get(&field) == Some(&mark) { + log::info!("show: {field} already holds {value}"); + return; + } + panel.sent.insert(field, mark); + on_deck(&panel.deck, cx, send); + }); +} + +impl Panel { + /// Groups for the selection on the shown slide. + pub(crate) fn object_grid( + &self, + selection: &Selection, + window: &mut Window, + cx: &mut Context, + ) -> PropertyGrid { + let mut grid = PropertyGrid::new("show-object").group(self.geometry(selection, cx)); + let kind = selection.kind.as_str(); + // Lines and pictures have an outline but no fill. + let fills = !matches!(kind, "image" | "line" | "connector"); + grid = grid.group(self.look(selection, fills, cx)); + if matches!(kind, "text" | "shape" | "mixed") { + grid = grid.group(self.words(selection, window, cx)); + } + if kind == "image" { + grid = grid.group(self.picture(cx)); + } + if kind == "table" { + grid = grid.group(self.table_group(cx)); + } + if kind == "chart" { + grid = grid.group(self.chart_group(cx)); + } + if let Some(group) = (kind == "media").then(|| self.media_group(cx)).flatten() { + grid = grid.group(group); + } + grid.group(self.arrange(selection, cx)) + } + + /// A number field: kernel units over `scale`, `step`, `places`. + fn field( + &self, + (field, command): (&'static str, &'static str), + (now, (scale, step, places, kernel)): (Option, Unit), + range: (f64, f64), + cx: &mut Context, + ) -> AnyElement { + let this = cx.entity(); + let label = Deck::text(cx, &format!("show.object.{field}"), &[]); + let id = SharedString::from(format!("show-{field}")); + let mut input = NumberInput::new(id, now.unwrap_or(0) as f64 / scale) + .label(label) + .range(range.0, range.1) + .step(step) + .precision(places) + .size(ControlSize::Sm) + .on_commit(move |shown, _, cx| { + let value = (shown * scale).round() as i64; + let send = move |deck: &mut Deck, cx: &mut Context| { + deck.object_command(command, &[&kernel(value).to_string()], cx) + }; + commit(&this, field, (value, now), send, cx) + }); + if now.is_none() { + input = input.blank(Deck::text(cx, "office.props.mixed", &[])); + } + div().w_full().max_w_32().child(input).into_any_element() + } + + fn geometry(&self, sel: &Selection, cx: &mut Context) -> PropertyGroup { + let (x, y, w, h) = sel.rect; + let (cm, at, long) = (CM, (-300.0, 300.0), (0.01, 300.0)); + let place = row([ + self.field(("x", "object.x"), (Some(x), cm), at, cx), + self.field(("y", "object.y"), (Some(y), cm), at, cx), + ]); + let extent = row([ + self.field(("width", "object.width"), (Some(w), cm), long, cx), + self.field(("height", "object.height"), (Some(h), cm), long, cx), + ]); + let this = cx.entity(); + let (now, center) = (sel.rotation, sel.rotation_center); + let label = Deck::text(cx, "show.object.rotation", &[]); + let turn = NumberInput::new("show-rotation", now as f64 / 100.0) + .label(label) + .range(0.0, 359.9) + .step(1.0) + .precision(1) + .size(ControlSize::Sm) + .on_commit(move |degrees, _, cx| { + let angle = (degrees * 100.0).round() as i64 % 36_000; + let values = [angle, twips(center.0), twips(center.1)].map(|n| n.to_string()); + commit( + &this, + "rotation", + (angle, Some(now)), + move |deck, cx| { + let values: Vec<&str> = values.iter().map(String::as_str).collect(); + deck.object_command("object.rotate", &values, cx) + }, + cx, + ) + }); + let locked = sel.lock_position == Some(true); + let lock = Switch::new("show-lock", locked) + .name(Deck::text(cx, "show.object.lock", &[])) + .on_change({ + let deck = self.deck.clone(); + move |on, _, cx| { + let on = on.to_string(); + on_deck(&deck, cx, |deck, cx| { + deck.object_command("object.lock", &[&on, &on], cx) + }) + } + }); + let tip = |key: &str| Deck::text(cx, key, &[]); + PropertyGroup::new(tip("show.object.geometry")) + .row(tip("show.object.position"), place) + .row(tip("show.object.size"), extent) + .row(tip("show.object.rotation"), div().w_full().max_w_32().child(turn)) + .row(tip("show.object.lock"), lock) + } + + fn look(&self, sel: &Selection, fills: bool, cx: &mut Context) -> PropertyGroup { + let this = cx.entity(); + let fill = palette( + &this, + "object.fill", + (sel.fill, "show.object.no_fill"), + Some(NO_FILL), + cx, + ); + let line = palette( + &this, + "object.line_color", + (sel.line, "show.object.no_line"), + Some(NO_LINE), + cx, + ); + let see = self.field( + ("transparency", "object.transparency"), + (sel.transparency, (1.0, 1.0, 0, same)), + (0.0, 100.0), + cx, + ); + let width = self.field( + ("line_width", "object.line_width"), + (sel.line_width, (POINT, 0.25, 2, same)), + (0.0, 20.0), + cx, + ); + let shadow = Switch::new("show-shadow", sel.shadow == Some(true)) + .name(Deck::text(cx, "show.object.shadow", &[])) + .on_change({ + let deck = self.deck.clone(); + move |on, _, cx| { + let on = on.to_string(); + on_deck(&deck, cx, |deck, cx| { + deck.object_command("object.shadow", &[&on], cx) + }) + } + }); + let tip = |key: &str| Deck::text(cx, key, &[]); + let title = if fills { + "show.object.look" + } else { + "show.object.line" + }; + let mut group = PropertyGroup::new(tip(title)); + if fills { + group = group + .row(tip("show.object.fill"), fill) + .row(tip("show.object.transparency"), see) + .row(tip("show.object.shadow"), shadow); + } + group + .row(tip("show.object.line"), line) + .row(tip("show.object.line_width"), width) + } + + fn chart_group(&self, cx: &mut Context) -> PropertyGroup { + let deck = self.deck.clone(); + let change = Button::new("show-chart-edit", Deck::text(cx, "show.chart.edit", &[])) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| on_deck(&deck, cx, |deck, cx| deck.ask_chart(cx))); + let name = Deck::text(cx, "show.kind.chart", &[]); + PropertyGroup::new(name.clone()).row(name, change) + } + + fn picture(&self, cx: &mut Context) -> PropertyGroup { + let deck = self.deck.clone(); + let replace = Button::new("show-replace", Deck::text(cx, "show.object.replace", &[])) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| on_deck(&deck, cx, |deck, cx| deck.pick_picture(true, cx))); + let crop = kernel::button( + self, + "image.crop", + "show.object.crop", + "show.object.why_picture", + cx, + ); + PropertyGroup::new(Deck::text(cx, "show.object.picture", &[])).row( + Deck::text(cx, "show.object.picture", &[]), + row([replace.into_any_element(), crop]), + ) + } + + fn arrange(&self, sel: &Selection, cx: &mut Context) -> PropertyGroup { + let act = |id: &'static str, (icon, key): (Option, &str), on: bool| { + let deck = self.deck.clone(); + let run = move |_: &gpui::ClickEvent, _: &mut Window, cx: &mut App| { + on_deck(&deck, cx, |deck, cx| deck.object_command(id, &[], cx)) + }; + let label = Deck::text(cx, key, &[]); + match icon { + Some(icon) => IconButton::new(id, icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(label) + .disabled(!on) + .on_click(run) + .into_any_element(), + None => Button::new(id, label) + .size(ControlSize::Sm) + .disabled(!on) + .on_click(run) + .into_any_element(), + } + }; + let many = sel.count >= 3; + let order = row([ + act("image.front", (None, "show.object.front"), true), + act("image.forward", (None, "show.object.forward"), true), + act("image.backward", (None, "show.object.backward"), true), + act("image.back", (None, "show.object.back"), true), + ]); + let icon = |icon, key| (Some(icon), key); + let align = row([ + act( + "object.align_left", + icon(IconName::AlignStartVertical, "show.object.align_left"), + true, + ), + act( + "object.align_center", + icon(IconName::AlignCenterVertical, "show.object.align_center"), + true, + ), + act( + "object.align_right", + icon(IconName::AlignEndVertical, "show.object.align_right"), + true, + ), + act( + "object.align_top", + icon(IconName::AlignStartHorizontal, "show.object.align_top"), + true, + ), + act( + "object.align_middle", + icon(IconName::AlignCenterHorizontal, "show.object.align_middle"), + true, + ), + act( + "object.align_bottom", + icon(IconName::AlignEndHorizontal, "show.object.align_bottom"), + true, + ), + ]); + let spread = row([ + act( + "object.spread_across", + icon( + IconName::AlignHorizontalDistributeCenter, + "show.object.spread_across", + ), + many, + ), + act( + "object.spread_down", + icon( + IconName::AlignVerticalDistributeCenter, + "show.object.spread_down", + ), + many, + ), + ]); + let group = row([ + act("object.group", (None, "show.object.group"), sel.can_group), + act( + "object.ungroup", + (None, "show.object.ungroup"), + sel.can_ungroup, + ), + ]); + let tip = |key: &str| Deck::text(cx, key, &[]); + PropertyGroup::new(tip("show.object.arrange")) + .row(tip("show.object.order"), order) + .row(tip("show.object.align_to"), align) + .row(tip("show.object.spread"), spread) + .row(tip("show.object.grouping"), group) + } +} diff --git a/apps/ely-show/src/output.rs b/apps/ely-show/src/output.rs new file mode 100644 index 0000000..3fb3816 --- /dev/null +++ b/apps/ely-show/src/output.rs @@ -0,0 +1,358 @@ +//! PDF, slide pictures and handouts, saved or printed (SHOW-13). + +use std::{io, path::PathBuf}; + +use ely_gpui_component::primitives::Severity; +use ely_lok::{Doc, Pdf, Worker}; +use ely_office::{in_window, prelude::*}; +use ely_ui::commands::{Export, Print}; +use gpui::{Context, TaskExt as _, Window}; + +use crate::deck::Deck; +use crate::dialog::Dialog; + +mod dialog; +pub use dialog::output_dialog; + +/// Slide pictures are this wide. +const PICTURE: u32 = 1920; + +/// What goes out. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Kind { + Slides, + Pictures, + Handouts, +} + +/// Which slides, by number from one. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Range { + All, + Current, + Slides(u32, u32), +} + +/// The output dialog's choices. +#[derive(Clone, Debug)] +pub struct Output { + pub tab: u64, + pub print: bool, + pub kind: Kind, + pub range: Range, + /// Slides on a handout page. + pub per: u8, + /// The shown slide, from one; none in master view. + pub current: Option, + /// Each slide, hidden or not. + pub hidden: Vec, +} + +impl Output { + /// First and last slide; None is all. + pub fn pages(&self) -> Option<(u32, u32)> { + match self.range { + Range::All => None, + Range::Current => self.current.map(|at| (at, at)), + Range::Slides(from, to) => Some((from.min(to), from.max(to))), + } + } +} + +/// Runs `work` on a saved copy of `doc`. +fn on_copy( + worker: &mut Worker, + doc: &Doc, + (copy, scheme): (&std::path::Path, Option<&'static str>), + work: impl FnOnce(&mut Worker, &Doc) -> io::Result, +) -> io::Result { + let url = ely_lok::file_url(copy); + doc.save_as(worker, &url, "odp")?; + let done = loaded(worker, &url, scheme, work); + std::fs::remove_file(copy)?; + done +} + +fn loaded( + worker: &mut Worker, + url: &str, + scheme: Option<&'static str>, + work: impl FnOnce(&mut Worker, &Doc) -> io::Result, +) -> io::Result { + let made = worker.load(url)?; + let done = match scheme { + Some(scheme) => worker.run(&made, "view.theme", &[scheme]).and_then(|()| work(worker, &made)), + None => work(worker, &made), + }; + let closed = worker.close_doc(made); + let done = done?; + closed?; + Ok(done) +} + +/// Writes slide `part` as a PNG of `size` pixels. +fn picture(worker: &mut Worker, doc: &Doc, part: usize, size: (u32, u32), to: std::fs::File) -> io::Result<()> { + let mut pixels = doc.paint_part(worker, (part, false), size)?; + // The kernel paints BGRA. + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + png(pixels, size, to) +} + +/// Encodes RGBA `pixels` as PNG; the last write checked. +fn png(pixels: Vec, size: (u32, u32), to: impl io::Write) -> io::Result<()> { + use image::ImageEncoder as _; + if pixels.len() != size.0 as usize * size.1 as usize * 4 { + return Err(io::Error::other("the slide picture has the wrong size")); + } + let mut out = io::BufWriter::new(to); + image::codecs::png::PngEncoder::new(&mut out) + .write_image(&pixels, size.0, size.1, image::ExtendedColorType::Rgba8) + .map_err(io::Error::other)?; + io::Write::flush(&mut out) +} + +/// The shown slides of `range` and their files. +fn targets(hidden: &[bool], range: Option<(u32, u32)>, (folder, stem): (&std::path::Path, &str)) -> Vec<(usize, PathBuf)> { + let (from, to) = range.unwrap_or((1, hidden.len() as u32)); + let width = hidden.len().to_string().len(); + (from..=to) + .map(|number| number as usize - 1) + .filter(|part| hidden.get(*part) == Some(&false)) + .map(|part| (part, folder.join(format!("{stem}-{:0width$}.png", part + 1)))) + .collect() +} + +impl Deck { + pub fn on_export(&mut self, _: &Export, _: &mut Window, cx: &mut Context) { + self.ask_output(false, cx); + } + + pub fn on_print(&mut self, _: &Print, _: &mut Window, cx: &mut Context) { + self.ask_output(true, cx); + } + + /// Reads the slides, not masters, then asks what goes out. + fn ask_output(&mut self, print: bool, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + let text = Deck::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + let id = tab.id; + let current = (!tab.extra.masters) + .then(|| u32::try_from(tab.extra.slides.current + 1).expect("a slide number")); + let asked = self.ask_dialog(); + self.read_in( + id, + |worker, doc| ely_lok::show::slide_show(&worker.values(doc, ely_lok::show::SLIDE_SHOW)?), + move |deck, read, _, cx| match read { + Ok(slides) if deck.dialog_due(asked, id) => { + deck.dialog = Some(Dialog::Output(Output { + tab: id, + print, + kind: Kind::Slides, + range: Range::All, + per: 3, + current, + hidden: slides.iter().map(|slide| slide.hidden).collect(), + })); + cx.notify(); + } + Ok(_) => log::info!("show: the output dialog is no longer due"), + Err(error) => deck.engine_failed(&format!("the slides: {error}"), cx), + }, + cx, + ); + } + + /// Writes what the dialog asks, then saves or prints it. + pub(crate) fn output(&mut self, cx: &mut Context) { + let Some(Dialog::Output(output)) = self.dialog.take() else { + return; + }; + cx.notify(); + if !self.settled(output.tab, cx) { + let tab = output.tab; + self.dialog = Some(Dialog::Output(output)); + return self.when_settled(tab, |deck, _, cx| deck.output(cx)); + } + let Some(source) = self.tab(output.tab) else { + return; + }; + let work = source.work.clone(); + if output.print { + let path = work.join("print.pdf"); + let printing = path.clone(); + return self.write_pdf(&output, path, cx, move |_, cx| { + ely_office::print_later(printing, cx, |deck, error, cx| deck.output_failed(&error, cx)) + }); + } + let Some(dir) = ely_office::folder(source) else { + return self.panel_failed("no documents folder", cx); + }; + let stem = (source.name.rsplit_once('.')) + .map_or(source.name.to_string(), |(stem, _)| stem.to_string()); + if output.kind == Kind::Pictures { + return self.pick(true, cx, move |deck, folder, cx| deck.write_pictures(&output, (folder, stem), cx)); + } + let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.pdf"))); + cx.spawn(async move |this, cx| { + let chosen = match chosen.await { + Ok(Ok(path)) => Ok(path), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + in_window(&this, cx, |deck, _, cx| match chosen { + Ok(Some(path)) => deck.export_pdf(&output, path, cx), + Ok(None) => log::info!("show: the export panel was cancelled"), + Err(error) => deck.panel_failed(&error, cx), + }) + }) + .detach_and_log_err(cx); + } + + pub(crate) fn export_pdf(&mut self, output: &Output, path: PathBuf, cx: &mut Context) { + match self.holder(&path) { + Ok(None) => {} + Ok(Some(_)) => { + let name = ely_office::files::file_name(&path); + let text = Deck::text(cx, "show.output.open", &[("name", &name)]); + return self.toast(text, Severity::Danger, cx); + } + Err(error) => return self.output_failed(&error.to_string(), cx), + } + let (name, opened) = (ely_office::files::file_name(&path), path.clone()); + self.write_pdf(output, path, cx, move |deck, cx| { + let text = Deck::text(cx, "show.output.done", &[("name", &name)]); + deck.toast_output(text, opened.clone(), cx); + }); + } + + /// Writes slides or handouts as a PDF, from a copy. + fn write_pdf( + &mut self, + output: &Output, + path: PathBuf, + cx: &mut Context, + then: impl FnOnce(&mut Self, &mut Context) + 'static, + ) { + let Some(tab) = self.tab(output.tab) else { + return; + }; + let pdf = Pdf { + pages: output.pages(), + handouts: if output.kind == Kind::Handouts { output.per } else { 0 }, + ..Pdf::default() + }; + log::info!("show: writes a PDF ({pdf:?})"); + let (url, copy) = (ely_lok::file_url(&path), tab.work.join("output.odp")); + self.read_in( + output.tab, + move |worker, doc| on_copy(worker, doc, (©, None), |worker, made| made.export_pdf(worker, &url, &pdf)), + move |deck, written, _, cx| match written { + Ok(()) => then(deck, cx), + Err(error) => deck.output_failed(&error.to_string(), cx), + }, + cx, + ); + } + + /// One PNG a shown slide, light, into `folder`. + pub(crate) fn write_pictures(&mut self, output: &Output, (folder, stem): (PathBuf, String), cx: &mut Context) { + let Some(tab) = self.tab(output.tab) else { + return; + }; + let range = output.pages(); + let (w, h) = tab.extra.size; + let size = (PICTURE, (f64::from(PICTURE) * h as f64 / w.max(1) as f64).round() as u32); + let (copy, light) = (tab.work.join("output.odp"), ely_lok::scheme(false)); + let (shown, opened) = (folder.display().to_string(), folder.clone()); + log::info!("show: writes slide pictures"); + self.read_in( + output.tab, + move |worker, doc| { + // Which slides show is the copy's, as it is written. + on_copy(worker, doc, (©, Some(light)), |worker, made| { + let slides = ely_lok::show::slide_show(&worker.values(made, ely_lok::show::SLIDE_SHOW)?)?; + let hidden: Vec = slides.iter().map(|slide| slide.hidden).collect(); + let files = targets(&hidden, range, (&folder, &stem)); + let paths: Vec = files.iter().map(|(_, path)| path.clone()).collect(); + let claimed = ely_files::claim_all(&paths)?; + let written = (files.iter().zip(claimed)) + .try_for_each(|((part, _), file)| picture(worker, made, *part, size, file)); + // A failure leaves none of this run's files. + if let Err(error) = &written + && let Err(left) = files.iter().try_for_each(|(_, path)| std::fs::remove_file(path)) + { + return Err(io::Error::other(format!("{error}; then {left}"))); + } + written.map(|()| files.len()) + }) + }, + move |deck, written, _, cx| match written { + Ok(count) => { + let count = count.to_string(); + let text = Deck::text(cx, "show.output.pictures_done", &[("n", &count), ("folder", &shown)]); + deck.toast_output(text, opened.clone(), cx); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let text = Deck::text(cx, "show.output.taken", &[("name", &error.to_string())]); + deck.toast(text, Severity::Danger, cx); + } + Err(error) => deck.output_failed(&error.to_string(), cx), + }, + cx, + ); + } + + fn output_failed(&self, error: &str, cx: &mut Context) { + let text = Deck::text(cx, "show.output.failed", &[("error", error)]); + self.toast(text, Severity::Danger, cx); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Takes `room` bytes, then the disk is full. + struct Full { + room: usize, + } + + impl io::Write for Full { + fn write(&mut self, bytes: &[u8]) -> io::Result { + match self.room { + 0 => Err(io::Error::new(io::ErrorKind::StorageFull, "full")), + room => { + let took = bytes.len().min(room); + self.room -= took; + Ok(took) + } + } + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn a_full_disk_at_the_last_write_is_an_error() { + let pixels = vec![200u8; 64 * 64 * 4]; + let mut whole = Vec::new(); + png(pixels.clone(), (64, 64), &mut whole).expect("encodes"); + let short = Full { room: whole.len() - 1 }; + let failed = png(pixels, (64, 64), short).expect_err("one byte short"); + assert_eq!(failed.kind(), io::ErrorKind::StorageFull); + } + + #[test] + fn targets_skip_hidden_slides_and_pad_numbers() { + let hidden = [false, true, false, false, false, false, false, false, false, false]; + let files = targets(&hidden, Some((1, 3)), (std::path::Path::new("/out"), "deck")); + let names: Vec = files.iter().map(|(_, p)| p.display().to_string()).collect(); + assert_eq!(names, ["/out/deck-01.png", "/out/deck-03.png"]); + } +} diff --git a/apps/ely-show/src/output/dialog.rs b/apps/ely-show/src/output/dialog.rs new file mode 100644 index 0000000..2a734c5 --- /dev/null +++ b/apps/ely-show/src/output/dialog.rs @@ -0,0 +1,137 @@ +//! The output dialog: what, which slides, how many a page. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + data_display::DescriptionList, + forms::{Choice, NumberInput, Select}, + overlays::Dialog as Card, + theme::ControlSize, +}; +use gpui::{ParentElement as _, Styled as _, Window, div}; + +use super::{Kind, Output, Range}; +use crate::deck::Deck; +use crate::dialog::Dialog; + +/// What goes out, which slides, how many a page. +pub fn output_dialog( + this: gpui::Entity, + output: &Output, + dismiss: impl Fn(&mut Window, &mut gpui::App) + 'static, + cx: &gpui::App, +) -> Card { + let text = |key: &str| Deck::text(cx, key, &[]); + let change = |this: &gpui::Entity, cx: &mut gpui::App, edit: &dyn Fn(&mut Output)| { + this.update(cx, |deck, cx| { + if let Some(Dialog::Output(output)) = &mut deck.dialog { + edit(output); + } + cx.notify(); + }) + }; + let kinds: &[(Kind, &str)] = match output.print { + true => &[(Kind::Slides, "slides"), (Kind::Handouts, "handouts")], + false => &[(Kind::Slides, "pdf"), (Kind::Pictures, "pictures"), (Kind::Handouts, "handouts")], + }; + let now = kinds.iter().find(|(kind, _)| *kind == output.kind).map_or("pdf", |(_, id)| id); + let kind = { + let (this, kinds) = (this.clone(), kinds.to_vec()); + kinds + .iter() + .fold(SegmentedControl::new("show-output-kind", now), |control, (_, id)| { + control.segment(*id, text(&format!("show.output.{id}")), None) + }) + .size(ControlSize::Sm) + .on_change(move |value, _, cx| { + if let Some((kind, _)) = kinds.iter().find(|(_, id)| *id == value.as_ref()) { + let kind = *kind; + change(&this, cx, &|output| output.kind = kind); + } + }) + }; + let which = match output.range { + Range::All => "all", + Range::Current => "current", + Range::Slides(..) => "range", + }; + let last = output.hidden.len() as u32; + let range = { + let this = this.clone(); + let control = SegmentedControl::new("show-output-range", which) + .size(ControlSize::Sm) + .segment("all", text("show.output.all"), None); + // Master view has no current slide. + let control = match output.current { + Some(_) => control.segment("current", text("show.output.current"), None), + None => control, + }; + control + .segment("range", text("show.output.range"), None) + .on_change(move |value, _, cx| { + let range = match value.as_ref() { + "all" => Range::All, + "current" => Range::Current, + _ => Range::Slides(1, last), + }; + change(&this, cx, &|output| output.range = range); + }) + }; + let bound = |id: &'static str, value: u32, first: bool| { + // A typed number counts before blur commits it. + let set = move |this: &gpui::Entity, value: f64, cx: &mut gpui::App| { + let value = value.round() as u32; + change(this, cx, &|output| { + if let Range::Slides(from, to) = &mut output.range { + *(if first { from } else { to }) = value; + } + }) + }; + let (typed, committed) = (this.clone(), this.clone()); + NumberInput::new(id, f64::from(value)) + .label(text(if first { "show.output.from" } else { "show.output.to" })) + .range(1.0, f64::from(last)) + .size(ControlSize::Sm) + .on_change(move |value, _, cx| set(&typed, value, cx)) + .on_commit(move |value, _, cx| set(&committed, value, cx)) + }; + let bounds = match output.range { + Range::Slides(from, to) => Some( + div() + .flex() + .gap_2() + .child(div().w_full().max_w_24().child(bound("show-output-from", from, true))) + .child(div().w_full().max_w_24().child(bound("show-output-to", to, false))), + ), + _ => None, + }; + let per = (output.kind == Kind::Handouts).then(|| { + let this = this.clone(); + let choices = [1u8, 2, 3, 4, 6, 9].map(|n| Choice::new(n.to_string(), n.to_string())); + Select::new("show-output-per", choices) + .label(text("show.output.per")) + .selected(output.per.to_string()) + .on_change(move |value, _, cx| { + let per: u8 = value.parse().expect("listed numbers"); + change(&this, cx, &|output| output.per = per); + }) + }); + let mut rows = DescriptionList::new() + .item(text("show.output.what"), kind) + .item(text("show.output.which"), div().flex().flex_col().gap_2().child(range).children(bounds)); + if let Some(per) = per { + rows = rows.item(text("show.output.per"), per); + } + let (title, go) = match output.print { + true => ("show.output.print_title", "show.output.print"), + false => ("show.output.export_title", "show.output.export"), + }; + let go = text(go); + Card::new("show-output", text(title), dismiss) + .child(rows) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("show-output-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |deck, cx| deck.output(cx))) + }) +} diff --git a/apps/ely-show/src/preview.rs b/apps/ely-show/src/preview.rs new file mode 100644 index 0000000..b87c8c6 --- /dev/null +++ b/apps/ely-show/src/preview.rs @@ -0,0 +1,298 @@ +//! Plays a slide's animations or transition over the canvas (SHOW-09). + +use std::{sync::Arc, time::Instant}; + +use ely_lok::{ + LokView, + show::{Layers, Part}, +}; +use ely_office::prelude::*; +use gpui::{ + AnyElement, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, + RenderImage, Styled as _, Window, div, +}; +use image::{Frame, ImageBuffer, Rgba}; + +use crate::deck::Deck; + +pub(crate) mod compose; +pub(crate) mod timeline; +pub(crate) mod transition; + +/// About this wide; the height keeps the slide's shape. +const WIDTH: u32 = 1280; + +/// What a preview plays. +pub enum Playing { + /// The slide's layers and its effects on the clock. + Effects { + layers: Vec<(Part, Arc)>, + runs: Vec, + /// The slide in 1/100 mm. + slide: (f32, f32), + }, + /// The previous slide, if any, giving way to this one. + Transition { + from: Option)>>, + to: Vec<(Part, Arc)>, + preset: String, + }, +} + +/// Tab, slide, undo list, edits landed and pending. +type Stamp = (u64, String, Vec, u64, bool); + +pub struct Preview { + stamp: Stamp, + pub playing: Playing, + started: Instant, + length: f64, +} + +fn picture((width, height): (u32, u32), pixels: Vec) -> Arc { + let image = ImageBuffer::, _>::from_raw(width, height, pixels) + .expect("the worker paints the asked size"); + Arc::new(RenderImage::new([Frame::new(image)])) +} + +/// The layers as pictures. +pub(crate) fn pictures(layers: Layers) -> Vec<(Part, Arc)> { + let size = layers.size; + (layers.list.into_iter()) + .map(|layer| (layer.part, picture(size, layer.pixels))) + .collect() +} + +/// The preview's pixel size for a slide of `size`. +fn pixels(size: (i64, i64)) -> (u32, u32) { + let height = (WIDTH as f64 * size.1 as f64 / size.0.max(1) as f64).round(); + (WIDTH, height.max(1.0) as u32) +} + +impl Deck { + /// The shown slide now; previews play only while it holds. + fn stamp(&self, cx: &gpui::App) -> Option { + let tab = self.active_tab()?; + let slide = tab + .extra + .slides + .list + .get(tab.extra.slides.current)? + .name + .clone(); + let history = tab.extra.history.as_ref()?.0.clone(); + let view = tab.view.read(cx); + Some((tab.id, slide, history, view.edited(), view.edit_pending())) + } + + /// A new preview request; older ones no longer play. + fn ask_preview(&mut self, cx: &gpui::App) -> Option<(u64, Stamp)> { + self.preview_asked += 1; + Some((self.preview_asked, self.stamp(cx)?)) + } + + /// Whether request `asked` from `stamp` may still play. + fn may_play(&self, asked: u64, stamp: &Stamp, cx: &gpui::App) -> bool { + self.preview_asked == asked && self.stamp(cx).as_ref() == Some(stamp) + } + + /// Plays the shown slide's effects from the kernel's layers. + pub fn preview_effects(&mut self, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return; + }; + let (id, doc, part) = (tab.id, tab.doc.clone(), tab.extra.slides.current); + let Some((_, effects)) = tab.extra.motion.clone() else { + return; + }; + let (asked, slide) = (pixels(tab.extra.size), crate::design::mm100(tab.extra.size)); + let Some((request, stamp)) = self.ask_preview(cx) else { + return; + }; + log::info!("show: previews the effects of slide {part}"); + ely_lok::read_off( + &self.core.worker, + &doc, + move |worker, doc| doc.slide_layers(worker, part, asked), + move |deck: &mut Self, read: std::io::Result, cx| match read { + Ok(layers) if deck.may_play(request, &stamp, cx) => { + let layers = pictures(layers); + let runs = timeline::schedule(&effects); + let length = timeline::length(&runs); + let slide = (slide.0 as f32, slide.1 as f32); + deck.play( + stamp, + Playing::Effects { + layers, + runs, + slide, + }, + length, + cx, + ); + } + Ok(_) => log::info!("show: a preview for tab {id} is no longer due"), + Err(error) => deck.engine_failed(&format!("the preview: {error}"), cx), + }, + cx, + ); + } + + /// Plays the shown slide's transition from the one before. + pub fn preview_transition(&mut self, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return; + }; + let Some((transition, _)) = tab.extra.motion.clone() else { + return; + }; + let (id, doc, part) = (tab.id, tab.doc.clone(), tab.extra.slides.current); + let asked = pixels(tab.extra.size); + let Some((request, stamp)) = self.ask_preview(cx) else { + return; + }; + log::info!("show: previews the transition into slide {part}"); + ely_lok::read_off( + &self.core.worker, + &doc, + move |worker, doc| { + let from = (part > 0) + .then(|| doc.slide_layers(worker, part - 1, asked)) + .transpose()?; + Ok((from, doc.slide_layers(worker, part, asked)?)) + }, + move |deck: &mut Self, read: std::io::Result<(Option, Layers)>, cx| match read { + Ok((from, to)) if deck.may_play(request, &stamp, cx) => { + let playing = Playing::Transition { + from: from.map(pictures), + to: pictures(to), + preset: transition.preset.clone(), + }; + deck.play(stamp, playing, transition.duration, cx); + } + Ok(_) => log::info!("show: a preview for tab {id} is no longer due"), + Err(error) => deck.engine_failed(&format!("the preview: {error}"), cx), + }, + cx, + ); + } + + fn play(&mut self, stamp: Stamp, playing: Playing, length: f64, cx: &mut Context) { + let layers = match &playing { + Playing::Effects { layers, .. } => layers.len(), + Playing::Transition { .. } => 2, + }; + log::info!("show: plays {layers} layers for {length:.2} s"); + self.end_preview(); + self.preview = Some(Preview { + stamp, + playing, + started: Instant::now(), + length, + }); + cx.notify(); + } + + /// Ends the preview; its layers wait to be freed. + fn end_preview(&mut self) -> bool { + let Some(preview) = self.preview.take() else { + return false; + }; + let pictures: Vec<&Arc> = match &preview.playing { + Playing::Effects { layers, .. } => layers.iter().map(|(_, image)| image).collect(), + Playing::Transition { from, to, .. } => from.iter().flatten().chain(to).map(|(_, image)| image).collect(), + }; + for picture in pictures { + self.retired.push(picture.clone()); + } + true + } + + /// Drops a preview whose slide or document moved on. + pub(crate) fn drop_stale_preview(&mut self, cx: &gpui::App) { + if (self.preview.as_ref()).is_some_and(|p| self.stamp(cx).as_ref() != Some(&p.stamp)) { + log::info!("show: the slide changed under the preview"); + self.end_preview(); + } + } + + /// Stops a preview at once. + pub fn stop_preview(&mut self, cx: &mut Context) { + // Reads still out are not wanted either. + self.preview_asked += 1; + if self.end_preview() { + log::info!("show: the preview stops"); + cx.notify(); + } + } + + /// The playing preview over the slide; a press stops it. + pub fn preview_layer( + &mut self, + view: &Entity, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let size = self.active_tab()?.extra.size; + self.drop_stale_preview(cx); + let preview = self.preview.as_ref()?; + let t = preview.started.elapsed().as_secs_f64(); + // The last frame stays a moment, then the canvas returns. + if t > preview.length + 0.5 { + log::info!("show: the preview ends"); + self.end_preview(); + return None; + } + window.request_animation_frame(); + let lok = view.read(cx); + let (top_left, bottom_right) = + (lok.place((0, 0)), lok.place((size.0 as i32, size.1 as i32))); + let frame = (bottom_right.x - top_left.x, bottom_right.y - top_left.y); + let this = cx.entity(); + // A show starts from black; effects play on the page. + let behind = match &preview.playing { + Playing::Transition { .. } => gpui::black(), + Playing::Effects { .. } => ely_lok::paper(cx), + }; + let content = match &preview.playing { + Playing::Effects { + layers, + runs, + slide, + } => compose::effects(layers, runs, *slide, t, frame), + Playing::Transition { from, to, preset } => { + let p = if preview.length <= 0.0 { + 1.0 + } else { + (t / preview.length) as f32 + }; + let paper = ely_lok::paper(cx); + // Exits are not read; the old slide shows everything. + let from = (from.as_deref()) + .map(|from| compose::slide(from, |_| true, frame, paper).into_any_element()); + compose::transition( + (from, to), + &transition::frame(preset, p), + frame, + paper, + ) + } + }; + Some( + div() + .id("show-preview") + .absolute() + .left(top_left.x) + .top(top_left.y) + .w(frame.0) + .h(frame.1) + .overflow_hidden() + .bg(behind) + .on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| { + this.update(cx, |deck, cx| deck.stop_preview(cx)) + }) + .child(content) + .into_any_element(), + ) + } +} diff --git a/apps/ely-show/src/preview/compose.rs b/apps/ely-show/src/preview/compose.rs new file mode 100644 index 0000000..a0530d3 --- /dev/null +++ b/apps/ely-show/src/preview/compose.rs @@ -0,0 +1,147 @@ +//! Slides drawn from their layers: effects and transitions. + +use std::sync::Arc; + +use ely_lok::show::Part; +use gpui::{AnyElement, IntoElement, ParentElement as _, RenderImage, Styled as _, div, img, px}; + +use super::{timeline, transition}; + +type Px = gpui::Pixels; +/// A slide's layers, bottom first. +pub type Stack = [(Part, Arc)]; + +/// A whole slide on its page; `moving` picks objects. +pub fn slide( + layers: &Stack, + moving: fn(bool) -> bool, + (w, h): (Px, Px), + paper: gpui::Hsla, +) -> gpui::Div { + let shown = layers.iter().filter(|(part, _)| match part { + Part::Moving { visible, .. } => moving(*visible), + _ => true, + }); + let pictures = shown.map(|(_, image)| img(image.clone()).absolute().size_full()); + div().relative().w(w).h(h).bg(paper).children(pictures) +} + +/// A transition frame: old slide as drawn, new unplayed. +pub fn transition( + (from, to): (Option, &Stack), + frame: &transition::Frame, + (w, h): (Px, Px), + paper: gpui::Hsla, +) -> AnyElement { + let at = |shift: (f32, f32)| { + div() + .absolute() + .left(w * shift.0) + .top(h * shift.1) + .w(w) + .h(h) + }; + let old = from.map(|from| at(frame.from_shift).child(from)); + let new = frame.to_areas.iter().map(|area| { + let (x0, y0, x1, y1) = *area; + let whole = slide(to, |visible| visible, (w, h), paper); + div() + .absolute() + .left(w * (x0 + frame.to_shift.0)) + .top(h * (y0 + frame.to_shift.1)) + .w(w * (x1 - x0)) + .h(h * (y1 - y0)) + .overflow_hidden() + .opacity(frame.to_opacity) + .child(div().absolute().left(-(w * x0)).top(-(h * y0)).child(whole)) + }); + // Uncover slides the old slide off the new one beneath. + let old = old.map(IntoElement::into_any_element); + let new = new.map(IntoElement::into_any_element); + let pieces: Vec = match frame.old_on_top { + true => new.chain(old).collect(), + false => old.into_iter().chain(new).collect(), + }; + div() + .size_full() + .children(pieces) + .child( + div() + .absolute() + .size_full() + .bg(gpui::black()) + .opacity(frame.black), + ) + .into_any_element() +} + +/// The layers at second `t`, moved as effects say. +pub fn effects( + layers: &[(Part, Arc)], + runs: &[timeline::Run], + slide: (f32, f32), + t: f64, + (w, h): (Px, Px), +) -> AnyElement { + let pieces = layers.iter().filter_map(|(part, image)| { + let full = div().absolute().left(px(0.0)).top(px(0.0)).w(w).h(h); + match part { + Part::Still => Some( + full.child(img(image.clone()).size_full()) + .into_any_element(), + ), + Part::Moving { + hash, + visible, + bounds, + } => { + let (x, y) = (bounds.0 as f32 / slide.0, bounds.1 as f32 / slide.1); + let (bw, bh) = (bounds.2 as f32 / slide.0, bounds.3 as f32 / slide.1); + let look = timeline::object_look(runs, hash, *visible, t, y); + look.visible + .then(|| moved(image, look, (x, y, bw, bh), (w, h))) + } + } + }); + div().size_full().children(pieces).into_any_element() +} + +/// One object's layer, shifted, scaled about its centre and wiped. +fn moved( + image: &Arc, + look: timeline::Look, + (x, y, bw, bh): (f32, f32, f32, f32), + (w, h): (Px, Px), +) -> AnyElement { + let (cx, cy) = (x + bw / 2.0, y + bh / 2.0); + let s = look.scale.max(0.001); + // Scaling the whole layer about the object's centre. + let left = w * ((cx - cx * s) + look.shift.0); + let top = h * ((cy - cy * s) + look.shift.1); + let clip_top = y + bh * (1.0 - look.reveal); + let picture = img(image.clone()).absolute().left(left).w(w * s).h(h * s); + div() + .absolute() + .left(px(0.0)) + .top(h * clip_top) + .w(w) + .h(h * (1.0 - clip_top)) + .overflow_hidden() + .opacity(look.opacity) + .child(picture.top(top - h * clip_top)) + .into_any_element() +} + +/// A left slide, its effects as at second `at`. +pub fn left( + (stack, runs): (&Stack, &[timeline::Run]), + (slide, at): ((f32, f32), f64), + size: (Px, Px), + paper: gpui::Hsla, +) -> AnyElement { + div() + .size_full() + .bg(paper) + .child(effects(stack, runs, slide, at, size)) + .into_any_element() +} diff --git a/apps/ely-show/src/preview/timeline.rs b/apps/ely-show/src/preview/timeline.rs new file mode 100644 index 0000000..94f6c02 --- /dev/null +++ b/apps/ely-show/src/preview/timeline.rs @@ -0,0 +1,214 @@ +//! When each effect runs, and how its object looks meanwhile. + +use ely_lok::show::Effect; + +/// How an object looks at one moment. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Look { + pub visible: bool, + pub opacity: f32, + /// Offset as a share of the slide's width and height. + pub shift: (f32, f32), + /// Scale about the object's centre. + pub scale: f32, + /// Share of the object shown, from its bottom up. + pub reveal: f32, +} + +impl Look { + pub const SHOWN: Look = Look { + visible: true, + opacity: 1.0, + shift: (0.0, 0.0), + scale: 1.0, + reveal: 1.0, + }; + pub const HIDDEN: Look = Look { + visible: false, + ..Look::SHOWN + }; +} + +/// One effect placed on the clock, in seconds. +#[derive(Clone, Debug, PartialEq)] +pub struct Run { + pub hash: String, + pub preset: String, + pub begin: f64, + pub end: f64, +} + +/// Places the effects back to back, as a preview plays. +pub fn schedule(effects: &[Effect]) -> Vec { + let (mut runs, mut last_begin, mut last_end) = (Vec::new(), 0.0f64, 0.0f64); + let mut end_of_all = 0.0f64; + for effect in effects { + // A click waits for all before; previews click at once. + let anchor = match effect.start.as_str() { + "with" => last_begin, + "after" => last_end, + _ => end_of_all, + }; + let begin = anchor + effect.delay; + let end = begin + effect.duration.max(0.0); + runs.push(Run { + hash: effect.hash.clone(), + preset: effect.preset.clone(), + begin, + end, + }); + (last_begin, last_end) = (begin, end); + end_of_all = end_of_all.max(end); + } + runs +} + +/// The ease the kernel's presets mostly use. +fn ease(p: f32) -> f32 { + p * p * (3.0 - 2.0 * p) +} + +/// An entrance at progress `p`: 0 hidden, 1 in place. +fn entrance(kind: &str, p: f32, top: f32) -> Look { + let p = ease(p.clamp(0.0, 1.0)); + match kind { + "fade-in" => Look { + opacity: p, + ..Look::SHOWN + }, + // From below the slide's bottom edge. + "fly-in" => Look { + shift: (0.0, (1.0 - p) * (1.0 - top)), + ..Look::SHOWN + }, + "wipe" => Look { + reveal: p, + ..Look::SHOWN + }, + "zoom" => Look { + scale: p, + ..Look::SHOWN + }, + "float" => Look { + opacity: p, + shift: (0.0, (1.0 - p) * 0.1), + ..Look::SHOWN + }, + // Appear, and effects the preview does not draw. + _ => { + if p > 0.0 { + Look::SHOWN + } else { + Look::HIDDEN + } + } + } +} + +/// How `run`'s object looks at second `t`, once started. +pub fn look(run: &Run, t: f64, top: f32) -> Option { + if t < run.begin { + return None; + } + let span = run.end - run.begin; + let p = if span <= 0.0 { + 1.0 + } else { + ((t - run.begin) / span).min(1.0) as f32 + }; + let (class, kind) = run + .preset + .strip_prefix("ooo-") + .and_then(|rest| rest.split_once('-')) + .unwrap_or(("other", "")); + Some(match (class, kind) { + ("entrance", kind) => entrance(kind, if span <= 0.0 { 1.0 } else { p }, top), + ("exit", kind) => { + let mirrored = kind.replace("-out", "-in").replace("disappear", "appear"); + let gone = entrance(&mirrored, 1.0 - p, top); + if p >= 1.0 { Look::HIDDEN } else { gone } + } + ("emphasis", "grow-and-shrink") => Look { + scale: 1.0 + 0.5 * (1.0 - (2.0 * p - 1.0).abs()), + ..Look::SHOWN + }, + ("emphasis", "blink") => { + if p < 0.5 && p > 0.0 { + Look::HIDDEN + } else { + Look::SHOWN + } + } + _ => Look::SHOWN, + }) +} + +/// The object's look at `t`, from its last started run. +pub fn object_look(runs: &[Run], hash: &str, visible: bool, t: f64, top: f32) -> Look { + let ran = runs + .iter() + .rev() + .filter(|run| run.hash == hash) + .find_map(|run| look(run, t, top)); + ran.unwrap_or(if visible { Look::SHOWN } else { Look::HIDDEN }) +} + +/// When the last effect ends. +pub fn length(runs: &[Run]) -> f64 { + runs.iter().map(|run| run.end).fold(0.0, f64::max) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn effect(preset: &str, start: &str, duration: f64, delay: f64) -> Effect { + Effect { + preset: preset.into(), + label: String::new(), + class: String::new(), + target: Some(0), + hash: preset.into(), + start: start.into(), + duration, + delay, + } + } + + #[test] + fn clicks_wait_with_joins_and_after_follows() { + let runs = schedule(&[ + effect("ooo-entrance-fade-in", "click", 2.0, 0.0), + effect("ooo-entrance-fly-in", "with", 1.0, 0.5), + effect("ooo-entrance-wipe", "after", 1.0, 0.0), + effect("ooo-entrance-zoom", "click", 1.0, 0.0), + ]); + let times: Vec<(f64, f64)> = runs.iter().map(|r| (r.begin, r.end)).collect(); + assert_eq!(times, [(0.0, 2.0), (0.5, 1.5), (1.5, 2.5), (2.5, 3.5)]); + assert_eq!(length(&runs), 3.5); + } + + #[test] + fn entrances_hide_before_and_rest_in_place_after() { + let run = &schedule(&[effect("ooo-entrance-fade-in", "click", 2.0, 1.0)])[0]; + let at = |t| object_look(std::slice::from_ref(run), &run.hash, false, t, 0.3); + assert_eq!(at(0.5), Look::HIDDEN); + assert!((at(2.0).opacity - 0.5).abs() < 0.01); + assert_eq!(at(4.0), Look::SHOWN); + } + + #[test] + fn exits_end_hidden_and_emphasis_returns() { + let runs = schedule(&[ + effect("ooo-exit-fly-out", "click", 1.0, 0.0), + effect("ooo-emphasis-grow-and-shrink", "click", 2.0, 0.0), + ]); + let out = |t| object_look(&runs[..1], &runs[0].hash, true, t, 0.5); + assert_eq!(out(0.0), Look::SHOWN); + assert!(out(0.5).shift.1 > 0.0, "on its way down"); + assert_eq!(out(1.0), Look::HIDDEN); + let grow = |t| object_look(&runs[1..], &runs[1].hash, true, t, 0.5).scale; + assert!((grow(2.0) - 1.5).abs() < 0.01, "biggest halfway"); + assert!((grow(3.0) - 1.0).abs() < 0.01, "back after"); + } +} diff --git a/apps/ely-show/src/preview/transition.rs b/apps/ely-show/src/preview/transition.rs new file mode 100644 index 0000000..2bede5e --- /dev/null +++ b/apps/ely-show/src/preview/transition.rs @@ -0,0 +1,148 @@ +//! How two slides meet in each transition Ely offers. + +/// Where a slide shows: x0, y0, x1, y1 as shares. +pub type Area = (f32, f32, f32, f32); + +const WHOLE: Area = (0.0, 0.0, 1.0, 1.0); + +/// One frame: offsets, shown areas and black over all. +#[derive(Clone, Debug, PartialEq)] +pub struct Frame { + pub from_shift: (f32, f32), + pub to_shift: (f32, f32), + pub to_opacity: f32, + /// Where the new slide shows, over the old. + pub to_areas: Vec, + pub black: f32, + /// The old slide lies over the new one. + pub old_on_top: bool, +} + +/// Dissolve's squares a side. +const GRID: usize = 12; + +/// The new slide alone, in place. +pub fn done() -> Frame { + Frame { + from_shift: (0.0, 0.0), + to_shift: (0.0, 0.0), + to_opacity: 1.0, + to_areas: vec![WHOLE], + black: 0.0, + old_on_top: false, + } +} + +/// The frame of `preset` at progress `p`, 0 to 1. +pub fn frame(preset: &str, p: f32) -> Frame { + let p = p.clamp(0.0, 1.0); + let base = done(); + match preset { + "fade-smoothly" => Frame { + to_opacity: p, + ..base + }, + // Down to black, then up from it. + "fade-through-black" => Frame { + to_opacity: if p < 0.5 { 0.0 } else { 1.0 }, + black: 1.0 - (2.0 * p - 1.0).abs(), + ..base + }, + "push-left" => Frame { + from_shift: (-p, 0.0), + to_shift: (1.0 - p, 0.0), + ..base + }, + "push-up" => Frame { + from_shift: (0.0, -p), + to_shift: (0.0, 1.0 - p), + ..base + }, + "cover-left" => Frame { + to_shift: (1.0 - p, 0.0), + ..base + }, + // The old slide slides away; the new one waits beneath. + "uncover-left" => Frame { + from_shift: (-p, 0.0), + old_on_top: true, + ..base + }, + "wipe-right" => Frame { + to_areas: vec![(0.0, 0.0, p, 1.0)], + ..base + }, + "split-vertical-out" => Frame { + to_areas: vec![(0.5 - p / 2.0, 0.0, 0.5 + p / 2.0, 1.0)], + ..base + }, + "dissolve" => Frame { + to_areas: dissolved(p), + ..base + }, + // No transition, and ones the preview does not draw. + _ => base, + } +} + +/// The new slide's squares shown at `p`, fixed scatter. +fn dissolved(p: f32) -> Vec { + let count = GRID * GRID; + let shown = (p * count as f32).round() as usize; + let side = 1.0 / GRID as f32; + // A stride coprime with the count visits every square once. + (0..shown) + .map(|n| (n * 89) % count) + .map(|cell| { + let (x, y) = ((cell % GRID) as f32 * side, (cell / GRID) as f32 * side); + (x, y, x + side, y + side) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_offered_transition_ends_on_the_new_slide_alone() { + for preset in ely_lok::show::TRANSITIONS { + let end = frame(preset, 1.0); + assert_eq!( + (end.to_shift, end.to_opacity, end.black), + ((0.0, 0.0), 1.0, 0.0), + "{preset}" + ); + let covered: f32 = end.to_areas.iter().map(|a| (a.2 - a.0) * (a.3 - a.1)).sum(); + assert!((covered - 1.0).abs() < 0.001, "{preset} covers {covered}"); + } + } + + #[test] + fn only_uncover_lays_the_old_slide_on_top() { + for preset in ely_lok::show::TRANSITIONS { + assert_eq!( + frame(preset, 0.5).old_on_top, + preset == "uncover-left", + "{preset}" + ); + } + } + + #[test] + fn halfway_frames_mix_both_slides() { + assert_eq!(frame("push-left", 0.5).from_shift, (-0.5, 0.0)); + assert_eq!(frame("wipe-right", 0.5).to_areas, [(0.0, 0.0, 0.5, 1.0)]); + assert_eq!(frame("fade-through-black", 0.5).black, 1.0); + let half = frame("dissolve", 0.5).to_areas; + assert_eq!(half.len(), GRID * GRID / 2); + let mut cells: Vec<_> = frame("dissolve", 1.0) + .to_areas + .iter() + .map(|a| ((a.0 * 100.0) as i32, (a.1 * 100.0) as i32)) + .collect(); + cells.sort(); + cells.dedup(); + assert_eq!(cells.len(), GRID * GRID, "no square twice"); + } +} diff --git a/apps/ely-show/src/remarks.rs b/apps/ely-show/src/remarks.rs new file mode 100644 index 0000000..aba59c7 --- /dev/null +++ b/apps/ely-show/src/remarks.rs @@ -0,0 +1,291 @@ +//! Comments on slides, beside the canvas (SHOW-08). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + collab::{Comment, CommentSidebar, Peer, Thread}, + feedback::InlineMessage, + forms::TextInput, + overlays::PromptDialog, + primitives::Severity, + theme::ControlSize, +}; +use ely_lok::show::Remark; +use ely_office::prelude::*; +use gpui::{ + AnyElement, App, AppContext as _, Context, IntoElement, ParentElement as _, SharedString, + Styled as _, Window, div, prelude::FluentBuilder as _, +}; + +use crate::deck::Deck; +use crate::dialog::Dialog; + +/// Each comment as a thread quoted with its slide. +fn threads(remarks: &[Remark], cx: &App) -> Result, String> { + let authors: Vec<&str> = remarks + .iter() + .map(|remark| remark.author.as_str()) + .collect(); + remarks + .iter() + .map(|remark| { + let at = remark + .at + .parse::() + .and_then(|at| at.to_zoned(jiff::tz::TimeZone::system())) + .map_err(|error| format!("a bad comment time {}: {error}", remark.at))?; + let hue = authors + .iter() + .position(|name| *name == remark.author) + .expect("the author is listed"); + let slide = (remark.part + 1).to_string(); + Ok(Thread { + key: remark.id.clone().into(), + quote: Some(Deck::text(cx, "show.remark.slide", &[("n", &slide)])), + comments: vec![Comment { + author: Peer::new(remark.author.clone(), remark.author.clone(), hue), + at: at.timestamp(), + body: remark.text.clone().into(), + reactions: Vec::new(), + }], + resolved: false, + }) + }) + .collect() +} + +impl Deck { + /// Opens the comment dialog: new, or an edit of `edit`. + pub fn ask_remark( + &mut self, + edit: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(tab) = self.active_tab() else { + return; + }; + let text = match &edit { + None => String::new(), + Some(id) => match tab.extra.remarks.iter().find(|remark| &remark.id == id) { + Some(remark) => remark.text.clone(), + None => return log::warn!("show: comment {id} went before its edit"), + }, + }; + let (tab, label) = (tab.id, Deck::text(cx, "show.remark.text", &[])); + let field = cx.new(|cx| { + let mut input = TextInput::new(window, cx).multi_line(3, 8).label(label); + input.set_text(text, cx); + input + }); + self.dialog = Some(Dialog::Remark { tab, edit, field }); + cx.notify(); + } + + /// Sends the dialog's text: a new comment or an edit. + pub(crate) fn send_remark( + &mut self, + (tab, edit): (u64, Option), + text: &str, + cx: &mut Context, + ) { + let text = text.trim(); + match edit { + None => self.run_in(Some(tab), "comment.insert", &[text], cx), + Some(id) => self.run_in(Some(tab), "remark.edit", &[&id, text], cx), + } + self.force_reread(tab, cx); + cx.notify(); + } + + pub fn remark_dialog( + &self, + (tab, edit): (u64, &Option), + field: &gpui::Entity, + cx: &mut Context, + ) -> AnyElement { + let this = cx.entity(); + let label = |key: &str| Deck::text(cx, key, &[]); + let title = match edit { + None => "show.remark.new_title", + Some(_) => "show.remark.edit_title", + }; + let closing = this.clone(); + let dismiss = move |_: &mut Window, cx: &mut App| { + closing.update(cx, |deck, cx| { + deck.dialog = None; + cx.notify(); + }) + }; + let (edit, blank) = (edit.clone(), label("show.remark.blank")); + PromptDialog::new("show-remark", label(title), field, dismiss) + .submit(label("show.remark.save")) + .check(move |text| match text.trim().is_empty() { + true => Err(blank.clone()), + false => Ok(()), + }) + .on_submit(move |text, _, cx| { + this.update(cx, |deck, cx| { + deck.send_remark((tab, edit.clone()), text, cx) + }) + }) + .into_any_element() + } + + /// The picked comment, while its slide is shown. + pub(crate) fn shown_remark(&self) -> Option { + let tab = self.active_tab()?; + let part = tab.extra.slides.current; + let key = self.active_remark.clone()?; + let here = |remark: &Remark| remark.id == key.as_ref() && remark.part == part; + tab.extra.remarks.iter().any(here).then_some(key) + } + + /// Shows comment `key`'s slide and marks it. + pub(crate) fn pick_remark(&mut self, key: &SharedString, cx: &mut Context) { + self.active_remark = Some(key.clone()); + let Some(tab) = self.active_tab() else { + return; + }; + if let Some(remark) = tab + .extra + .remarks + .iter() + .find(|remark| remark.id == key.as_ref()) + { + let (view, part) = (tab.view.clone(), remark.part); + log::info!("show: comment {key} on slide {part}"); + view.update(cx, |view, _| view.show_part(part)); + } + cx.notify(); + } + + /// The comments column. + pub fn remarks_view(&self, cx: &mut Context) -> AnyElement { + let this = cx.entity(); + let remarks = self + .active_tab() + .map(|tab| tab.extra.remarks.clone()) + .unwrap_or_default(); + let threads = match threads(&remarks, cx) { + Ok(threads) => threads, + Err(error) => { + log::error!("show: {error}"); + let message = InlineMessage::new(Severity::Danger, error); + return div().p_2().child(message).into_any_element(); + } + }; + let active = self.shown_remark(); + let act = |id: &'static str, + key: &str, + on: bool, + run: fn(&mut Deck, String, &mut Window, &mut Context)| { + let (this, active) = (this.clone(), active.clone()); + Button::new(id, Deck::text(cx, key, &[])) + .size(ControlSize::Sm) + .variant(ButtonVariant::Ghost) + .disabled(!on) + .on_click(move |_, window, cx| { + let id = active + .clone() + .map(|key| key.to_string()) + .unwrap_or_default(); + this.update(cx, |deck, cx| run(deck, id, window, cx)) + }) + }; + let some = active.is_some(); + let head = div() + .flex() + .flex_wrap() + .gap_1() + .child(act( + "show-remark-add", + "show.remark.add", + true, + |deck, _, window, cx| deck.ask_remark(None, window, cx), + )) + .child(act( + "show-remark-edit", + "show.remark.edit", + some, + |deck, id, window, cx| deck.ask_remark(Some(id), window, cx), + )) + .child(act( + "show-remark-delete", + "show.remark.delete", + some, + |deck, id, _, cx| deck.delete_remark(&id, cx), + )); + let pick = this.clone(); + let sidebar = CommentSidebar::new("show-remarks", threads) + .active(active, move |key, _, cx| { + pick.update(cx, |deck, cx| deck.pick_remark(key, cx)) + }); + div() + .size_full() + .flex() + .flex_col() + .gap_2() + .p_2() + .child(head) + .child(div().flex_1().min_h_0().child(sidebar)) + .into_any_element() + } + + fn delete_remark(&mut self, id: &str, cx: &mut Context) { + if let Some(tab) = self.core.active { + self.active_remark = None; + self.run_in(Some(tab), "remark.delete", &[id], cx); + self.force_reread(tab, cx); + } + } +} + +/// The comments column as a view of the deck. +pub struct Pane { + pub deck: gpui::WeakEntity, +} + +impl gpui::Render for Pane { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(deck) = self.deck.upgrade() else { + return div().into_any_element(); + }; + deck.update(cx, |deck, cx| deck.remarks_view(cx)) + } +} + +impl Deck { + /// A pin on the shown slide for each comment there. + pub fn remark_layer( + &self, + view: &gpui::Entity, + cx: &App, + ) -> Option { + let tab = self.active_tab()?; + let part = tab.extra.slides.current; + let lok = view.read(cx); + let theme = ely_gpui_component::theme::ActiveTheme::theme(cx); + let pins: Vec = (tab.extra.remarks.iter()) + .filter(|remark| remark.part == part) + .map(|remark| { + let (x, y, _, _) = remark.anchor; + let at = lok.place((x, y)); + let lit = self + .active_remark + .as_ref() + .is_some_and(|key| key == &remark.id); + div() + .absolute() + .left(at.x) + .top(at.y) + .size(gpui::px(14.0)) + .rounded_sm() + .border_1() + .border_color(theme.colors.accent) + .when(lit, |pin| pin.bg(theme.colors.accent)) + .into_any_element() + }) + .collect(); + (!pins.is_empty()).then(|| div().absolute().inset_0().children(pins).into_any_element()) + } +} diff --git a/apps/ely-show/src/slides.rs b/apps/ely-show/src/slides.rs new file mode 100644 index 0000000..5b5226a --- /dev/null +++ b/apps/ely-show/src/slides.rs @@ -0,0 +1,306 @@ +//! The slide strip and the Slide menu (SHOW-01). + +use std::sync::Arc; + +use ely_gpui_component::{ + menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem}, + primitives::{FocusRing as _, Icon, IconName}, + theme::ActiveTheme as _, +}; +use ely_office::prelude::*; +use gpui::{ + Action, App, Context, Div, InteractiveElement as _, IntoElement, Menu, MenuItem, + ParentElement as _, Render, RenderImage, Role, StatefulInteractiveElement as _, Styled as _, + WeakEntity, Window, actions, div, img, prelude::FluentBuilder as _, px, +}; +use image::{Frame, ImageBuffer, Rgba}; + +use crate::deck::Deck; + +actions!( + show, + [ + NewSlide, + DuplicateSlide, + DeleteSlide, + HideSlide, + ShowSlide, + MoveSlideUp, + MoveSlideDown, + MoveSlideFirst, + MoveSlideLast + ] +); + +/// Thumbnail pixels: twice the shown size. +const PICTURE: (u32, u32) = (320, 180); +/// The shown width in pixels. +const WIDTH: f32 = 160.0; + +pub fn menu(cx: &App) -> Menu { + let label = |key: &str| Deck::text(cx, key, &[]); + Menu::new(label("menu.slide")).items([ + MenuItem::action(label("show.slide.new"), NewSlide), + MenuItem::action(label("show.slide.duplicate"), DuplicateSlide), + MenuItem::action(label("show.slide.delete"), DeleteSlide), + MenuItem::separator(), + MenuItem::action(label("show.slide.hide"), HideSlide), + MenuItem::action(label("show.slide.show"), ShowSlide), + MenuItem::separator(), + MenuItem::action(label("show.slide.up"), MoveSlideUp), + MenuItem::action(label("show.slide.down"), MoveSlideDown), + MenuItem::action(label("show.slide.first"), MoveSlideFirst), + MenuItem::action(label("show.slide.last"), MoveSlideLast), + ]) +} + +/// The same commands on a right click. +fn popup(cx: &App) -> Popup { + let item = |key: &str, action: Box| { + PopupItem::new(Deck::text(cx, key, &[])) + .on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx)) + }; + Popup::new() + .item(item("show.slide.new", Box::new(NewSlide))) + .item(item("show.slide.duplicate", Box::new(DuplicateSlide))) + .item(item("show.slide.delete", Box::new(DeleteSlide))) + .separator() + .item(item("show.slide.hide", Box::new(HideSlide))) + .item(item("show.slide.show", Box::new(ShowSlide))) +} + +/// Binds an action to a slide command. +macro_rules! run { + ($root:expr, $cx:expr, $action:ty, $id:literal) => { + $root.on_action($cx.listener(|deck, _: &$action, _, cx| deck.slide_command($id, cx))) + }; +} + +pub fn bind(root: Div, cx: &mut Context) -> Div { + let root = run!(root, cx, NewSlide, "slide.insert"); + let root = run!(root, cx, DuplicateSlide, "slide.duplicate"); + let root = run!(root, cx, DeleteSlide, "slide.delete"); + let root = run!(root, cx, HideSlide, "slide.hide"); + let root = run!(root, cx, ShowSlide, "slide.show"); + let root = run!(root, cx, MoveSlideUp, "slide.up"); + let root = run!(root, cx, MoveSlideDown, "slide.down"); + let root = run!(root, cx, MoveSlideFirst, "slide.first"); + run!(root, cx, MoveSlideLast, "slide.last") +} + +impl Deck { + /// Runs a slide command; masters take none. + fn slide_command(&mut self, id: &str, cx: &mut Context) { + if self.active_tab().is_some_and(|tab| tab.extra.masters) { + let text = Deck::text(cx, "show.masters.busy", &[]); + return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx); + } + self.run_active(id, &[], cx); + } + + /// Paints the next missing or stale slide picture. + pub fn paint_next(&mut self, id: u64, cx: &mut Context) { + let Some(tab) = self.tab(id) else { + return; + }; + let extra = &tab.extra; + if extra.painting { + return; + } + let next = extra.slides.list.iter().enumerate().find(|(_, slide)| { + !extra.failed.contains(&slide.name) + && (!extra.thumbs.contains_key(&slide.name) || extra.stale.contains(&slide.name)) + }); + let Some((part, slide)) = next else { + return; + }; + if !self.settled(id, cx) { + return self.when_settled(id, move |deck, _, cx| deck.paint_next(id, cx)); + } + let (name, master) = (slide.name.clone(), extra.masters); + let failing = name.clone(); + let doc = tab.doc.clone(); + let tab = self.tab_mut(id).expect("the tab is open"); + tab.extra.painting = true; + tab.extra.stale.remove(&name); + ely_lok::read_off( + &self.core.worker, + &doc, + move |worker, doc| doc.paint_part(worker, (part, master), PICTURE), + move |deck: &mut Self, painted, cx| { + let Some(tab) = deck.tab_mut(id) else { + return; + }; + tab.extra.painting = false; + match painted { + Ok(pixels) => { + let image = + ImageBuffer::, _>::from_raw(PICTURE.0, PICTURE.1, pixels) + .expect("the worker paints the asked size"); + let image = Arc::new(RenderImage::new([Frame::new(image)])); + if let Some(old) = tab.extra.thumbs.insert(name, image) { + tab.extra.retired.push(old); + } + } + Err(error) => { + tab.extra.failed.insert(failing); + deck.engine_failed(&error.to_string(), cx) + } + } + cx.notify(); + deck.paint_next(id, cx); + }, + cx, + ); + } +} + +/// The left column: one picture per slide or master. +pub struct Strip { + deck: WeakEntity, +} + +impl Strip { + pub fn new(deck: WeakEntity, cx: &mut Context) -> Self { + if let Some(deck) = deck.upgrade() { + cx.observe(&deck, |_, _, cx| cx.notify()).detach(); + } + Strip { deck } + } +} + +impl Deck { + /// Frees pictures no longer drawn, in every tab. + pub fn release_pictures(&mut self, window: &mut Window) { + self.retired.release(window); + for tab in &mut self.core.tabs { + tab.extra.retired.release(window); + } + } + + /// A closing tab's pictures wait to be freed. + pub fn retire_tab(&mut self, tab: &ely_office::Tab) { + for picture in tab.extra.thumbs.values() { + self.retired.push(picture.clone()); + } + self.retired.append(tab.extra.retired.clone()); + } +} + +impl crate::deck::Extra { + /// Pictures of slides no longer there go. + pub fn keep_pictures(&mut self, names: &std::collections::HashSet<&String>) { + let retired = &mut self.retired; + self.thumbs.retain(|name, picture| { + let kept = names.contains(name); + if !kept { + retired.push(picture.clone()); + } + kept + }); + } + + /// Every picture goes, to be painted again. + pub fn drop_pictures(&mut self) { + for (_, picture) in self.thumbs.drain() { + self.retired.push(picture); + } + } +} + +impl Render for Strip { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(deck) = self.deck.upgrade() else { + return div(); + }; + let colors = cx.theme().colors.clone(); + let read = deck.read(cx); + let Some(tab) = read.active_tab() else { + return div(); + }; + let height = WIDTH * PICTURE.1 as f32 / PICTURE.0 as f32; + let hidden = Deck::text(cx, "show.slide.hidden", &[]); + let count = tab.extra.slides.list.len(); + let slides = tab + .extra + .slides + .list + .iter() + .enumerate() + .map(|(part, slide)| { + let current = part == tab.extra.slides.current; + let picture = tab.extra.thumbs.get(&slide.name).cloned(); + let (view, pressed) = (tab.view.clone(), tab.view.clone()); + let label = format!("{} {}", part + 1, slide.name); + div() + .id(("show-slide", part)) + .tab_index(0) + .focus_ring(cx) + .flex() + .gap_2() + .items_start() + .p_1() + .cursor_pointer() + .on_click(move |_, _, cx| view.update(cx, |view, _| view.show_part(part))) + .on_mouse_down(gpui::MouseButton::Right, move |_, _, cx| { + pressed.update(cx, |view, _| view.show_part(part)) + }) + .child( + div() + .w_5() + .text_xs() + .text_color(colors.fg_subtle) + .child((part + 1).to_string()), + ) + .child( + div() + .w(px(WIDTH)) + .h(px(height)) + .relative() + .border_1() + .border_color(if current { colors.focus } else { colors.border }) + .when(!slide.visible, |frame| frame.opacity(0.5)) + .when_some(picture, |frame, picture| { + frame.child(img(picture).id(("show-picture", part)).size_full()) + }) + .when(!slide.visible, |frame| { + frame.child( + div() + .absolute() + .top_1() + .right_1() + .child(Icon::new(IconName::EyeOff)), + ) + }), + ) + .role(Role::ListItem) + .aria_label(label) + .aria_selected(current) + .aria_position_in_set(part + 1) + .aria_size_of_set(count) + .when(!slide.visible, |row| row.aria_description(hidden.clone())) + }); + let list = div() + .id("show-strip") + .role(Role::List) + .aria_label(Deck::text( + cx, + if tab.extra.masters { + "show.strip.masters" + } else { + "show.strip.slides" + }, + &[], + )) + .size_full() + .overflow_y_scroll() + .flex() + .flex_col() + .gap_2() + .p_2() + .children(slides); + div() + .size_full() + .child(ContextMenu::new("show-strip-menu", popup(cx)).child(list)) + } +} diff --git a/apps/ely-show/src/slideshow.rs b/apps/ely-show/src/slideshow.rs new file mode 100644 index 0000000..2745af5 --- /dev/null +++ b/apps/ely-show/src/slideshow.rs @@ -0,0 +1,350 @@ +//! The show: full screen, presenter view, keys (SHOW-11, 12). + +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use ely_lok::{ + Doc, Worker, + show::{Part, ShowSlide}, +}; +use gpui::{AnyWindowHandle, Context, RenderImage, SharedString, Task, WeakEntity}; + +use crate::deck::Deck; +use crate::preview::timeline::{self, Run}; +use steps::Place; + +mod audience; +mod keys; +mod layers; +mod media; +mod presenter; +mod stage; +pub(crate) mod start; +pub(crate) mod steps; + +pub use keys::{bind, bind_keys, menu}; + +/// A slide's layers as pictures, bottom first. +pub type Stack = Arc)>>; + +/// Clicks long done: a slide shown fully built. +const BUILT: f64 = -1.0e6; + +/// The slide left, as it looked when left. +pub struct Leaving { + pub stack: Stack, + pub runs: Vec, + /// Seconds after its entry. + pub at: f64, +} + +/// How a step reaches its place. +#[derive(Clone, Copy, PartialEq)] +enum How { + /// The next click group on the same slide. + Click, + /// The next slide, with its transition. + Enter, + /// Straight there: jumps, Home, End. + Jump, + /// Back to a slide, all its effects done. + Back, +} + +pub struct Show { + deck: WeakEntity, + worker: Arc>, + /// The copy the show plays; edits never reach it. + doc: Doc, + copy: PathBuf, + pub slides: Vec, + /// The slide in 1/100 mm. + pub slide: (f32, f32), + pixels: (u32, u32), + pub place: Place, + /// When each click group began, seconds after entry. + pub clicks: Vec, + pub entered: Instant, + /// The slide's layers are on their way; its clock waits. + pub waiting: bool, + pub leaving: Option, + pub black: bool, + pub laser: bool, + /// The pointer on the slide, as shares. + pub pointer: Option<(f32, f32)>, + pub began: Instant, + layers: HashMap, + /// Stacks no longer wanted; freed once nothing draws them. + gone: Vec, + /// Per window, audience then presenter: images to free. + retired: [ely_gpui_component::primitives::Retired; 2], + asking: HashSet, + /// Digits typed toward a jump. + pub typed: String, + pub windows: Vec, + /// The presenter view is open. + pub presenter: bool, + /// What went wrong, for the presenter. + pub notice: Option, + advance: Option>, + /// Lets the left slide go once the transition ends. + release: Option>, + media: Option, + ended: bool, +} + +impl Show { + pub fn new( + deck: WeakEntity, + (worker, doc, copy): (Arc>, Doc, PathBuf), + (slides, first): (Vec, usize), + (slide, pixels): ((f32, f32), (u32, u32)), + cx: &mut Context, + ) -> Self { + let mut show = Show { + deck, + worker, + doc, + copy, + slides, + slide, + pixels, + place: Place::Slide { + part: first, + clicks: 0, + }, + clicks: vec![0.0], + entered: Instant::now(), + waiting: true, + leaving: None, + black: false, + laser: false, + pointer: None, + began: Instant::now(), + layers: HashMap::new(), + gone: Vec::new(), + retired: Default::default(), + asking: HashSet::new(), + typed: String::new(), + windows: Vec::new(), + presenter: false, + notice: None, + advance: None, + release: None, + media: None, + ended: false, + }; + show.fetch(cx); + show.plan_advance(cx); + show + } + + pub fn part(&self) -> Option { + match self.place { + Place::Slide { part, .. } => Some(part), + Place::End => None, + } + } + + /// The next slide the show will reach. + pub fn upcoming(&self) -> Option { + let from = self.part()?; + (from + 1..self.slides.len()).find(|at| !self.slides[*at].hidden) + } + + fn go(&mut self, to: Place, how: How, cx: &mut Context) { + self.typed.clear(); + let now = match self.waiting { + true => 0.0, + false => self.entered.elapsed().as_secs_f64(), + }; + match (how, to) { + (How::Click, Place::Slide { part, .. }) => { + let at = steps::click_at(&self.slides[part].effects, &self.clicks, now); + self.clicks.push(at); + } + (_, Place::Slide { part, clicks }) => { + let transition = &self.slides[part].transition; + let moving = how == How::Enter && !transition.preset.is_empty(); + self.leaving = (self.part()) + .filter(|_| moving) + .and_then(|from| { + Some(Leaving { + stack: self.layers.get(&from)?.clone(), + runs: steps::runs(&self.slides[from].effects, &self.clicks), + at: now, + }) + }); + let lead = match self.leaving { + Some(_) => transition.duration, + None => 0.0, + }; + self.entered = Instant::now(); + self.waiting = !self.layers.contains_key(&part); + self.clicks = match how { + How::Back => vec![BUILT; clicks + 1], + _ => vec![lead], + }; + (self.media, self.notice) = (None, None); + } + (_, Place::End) => { + self.leaving = None; + (self.media, self.notice) = (None, None); + } + } + log::info!("show: goes to {to:?}"); + self.place = to; + self.fetch(cx); + self.plan_advance(cx); + self.plan_release(cx); + cx.notify(); + } + + /// The slide's layers came: its clock starts. + pub(super) fn arrived(&mut self, cx: &mut Context) { + if std::mem::replace(&mut self.waiting, false) { + log::info!("show: the slide is drawn; its clock starts"); + self.entered = Instant::now(); + self.plan_advance(cx); + self.plan_release(cx); + } + } + + /// Drops the left slide's layers when the transition ends. + fn plan_release(&mut self, cx: &mut Context) { + self.release = None; + let (Some(part), Some(_)) = (self.part(), &self.leaving) else { + return; + }; + if self.waiting { + return; + } + let rest = self.slides[part].transition.duration - self.entered.elapsed().as_secs_f64(); + self.release = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_secs_f64(rest.max(0.0))) + .await; + let dropped = this.update(cx, |show, cx| { + show.leaving = None; + cx.notify(); + }); + if dropped.is_err() { + log::info!("show: ended before its transition"); + } + })); + } + + pub fn next(&mut self, cx: &mut Context) { + if self.place == Place::End { + return self.close(cx); + } + let to = steps::next(&self.slides, self.place); + let how = match (self.part(), to) { + (Some(from), Place::Slide { part, .. }) if from == part => How::Click, + _ => How::Enter, + }; + self.go(to, how, cx); + } + + pub fn previous(&mut self, cx: &mut Context) { + self.go(steps::previous(&self.slides, self.place), How::Back, cx); + } + + /// Goes to slide `number`, counted from one. + pub fn jump(&mut self, number: usize, cx: &mut Context) { + if (1..=self.slides.len()).contains(&number) { + let to = Place::Slide { + part: number - 1, + clicks: 0, + }; + self.go(to, How::Jump, cx); + } else { + log::info!("show: no slide {number}"); + self.typed.clear(); + cx.notify(); + } + } + + pub fn toggle_black(&mut self, cx: &mut Context) { + self.black = !self.black; + log::info!("show: black screen {}", self.black); + cx.notify(); + } + + pub fn toggle_laser(&mut self, cx: &mut Context) { + self.laser = !self.laser; + self.pointer = None; + log::info!("show: laser pointer {}", self.laser); + cx.notify(); + } + + pub fn point(&mut self, at: Option<(f32, f32)>, cx: &mut Context) { + if self.laser && self.pointer != at { + self.pointer = at; + cx.notify(); + } + } + + /// When the slide's effects have all run. + fn settled_at(&self) -> f64 { + let Some(part) = self.part() else { + return 0.0; + }; + let runs = steps::runs(&self.slides[part].effects, &self.clicks); + timeline::length(&runs).max(self.clicks.iter().copied().fold(0.0, f64::max)) + } + + /// Whether frames still change. + pub fn moving(&self) -> bool { + !self.waiting && self.entered.elapsed().as_secs_f64() < self.settled_at() + } + + /// Advances by itself after the slide's time. + fn plan_advance(&mut self, cx: &mut Context) { + self.advance = None; + let Some(part) = self.part() else { + return; + }; + let transition = &self.slides[part].transition; + if !transition.auto || self.waiting { + return; + } + let wait = (self.settled_at() - self.entered.elapsed().as_secs_f64()).max(0.0) + + transition.time; + self.advance = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_secs_f64(wait)) + .await; + if this.update(cx, |show, cx| show.next(cx)).is_err() { + log::info!("show: ended before it advanced"); + } + })); + } + + /// Closes the windows and the copy. + pub fn close(&mut self, cx: &mut Context) { + if std::mem::replace(&mut self.ended, true) { + return; + } + log::info!("show: ends"); + (self.advance, self.release) = (None, None); + self.media = None; + let (windows, deck, me) = (std::mem::take(&mut self.windows), self.deck.clone(), cx.entity_id()); + start::discard(self.worker.clone(), self.doc.clone(), self.copy.clone(), cx); + // A key may come from a window being closed. + cx.defer(move |cx| { + for window in windows { + if let Err(error) = window.update(cx, |_, window, _| window.remove_window()) { + log::error!("show: a window did not close: {error}"); + } + } + if let Some(deck) = deck.upgrade() { + deck.update(cx, |deck, cx| deck.show_ended(me, cx)); + } + }); + } +} diff --git a/apps/ely-show/src/slideshow/audience.rs b/apps/ely-show/src/slideshow/audience.rs new file mode 100644 index 0000000..851c0d7 --- /dev/null +++ b/apps/ely-show/src/slideshow/audience.rs @@ -0,0 +1,99 @@ +//! The audience screen: the slide alone, full screen. + +use ely_gpui_component::{ + feedback::InlineMessage, + primitives::{FocusScope, Severity}, +}; +use gpui::{ + Context, Entity, FocusHandle, InteractiveElement as _, IntoElement, MouseButton, + ParentElement as _, Render, Styled as _, Window, div, +}; + +use super::{Show, stage}; + +pub struct Audience { + pub show: Entity, + pub focus: FocusHandle, +} + +impl Audience { + pub fn new(show: Entity, cx: &mut Context) -> Self { + cx.observe(&show, |_, _, cx| cx.notify()).detach(); + Audience { + show, + focus: cx.focus_handle(), + } + } +} + +impl Render for Audience { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.show.update(cx, |show, cx| show.media_tick(cx)) { + window.request_animation_frame(); + } + self.show.update(cx, |show, _| show.free(0, window)); + let show = self.show.read(cx); + let room = window.viewport_size(); + let size = stage::fit(show.slide, (room.width, room.height)); + let origin = ((room.width - size.0) / 2.0, (room.height - size.1) / 2.0); + if show.moving() { + window.request_animation_frame(); + } + let shown = stage::stage(show, size, cx); + // Alone, the audience screen says what failed. + let notice = (show.notice.clone()) + .filter(|_| !show.presenter) + .map(|why| { + div() + .absolute() + .bottom_4() + .left_4() + .child(InlineMessage::new(Severity::Danger, why)) + }); + let (keys, click, back, moved) = ( + self.show.clone(), + self.show.clone(), + self.show.clone(), + self.show.clone(), + ); + // Keys reach ancestors of the focused scope. + div() + .size_full() + .on_key_down(move |event, _, cx| { + if keys.update(cx, |show, cx| show.key(&event.keystroke, cx)) { + cx.stop_propagation(); + } + }) + .child( + FocusScope::new(&self.focus).root().size_full().child( + div() + .id("show-audience") + .relative() + .size_full() + .flex() + .items_center() + .justify_center() + .bg(gpui::black()) + .on_mouse_down(MouseButton::Left, move |event, _, cx| { + let x = (event.position.x - origin.0) / size.0; + let y = (event.position.y - origin.1) / size.1; + click.update(cx, |show, cx| match show.media_at((x, y)) { + Some(at) => show.toggle_media(at, cx), + None => show.next(cx), + }) + }) + .on_mouse_down(MouseButton::Right, move |_, _, cx| { + back.update(cx, |show, cx| show.previous(cx)) + }) + .on_mouse_move(move |event, _, cx| { + let x = (event.position.x - origin.0) / size.0; + let y = (event.position.y - origin.1) / size.1; + let inside = (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y); + moved.update(cx, |show, cx| show.point(inside.then_some((x, y)), cx)) + }) + .child(shown) + .children(notice), + ), + ) + } +} diff --git a/apps/ely-show/src/slideshow/keys.rs b/apps/ely-show/src/slideshow/keys.rs new file mode 100644 index 0000000..df4527c --- /dev/null +++ b/apps/ely-show/src/slideshow/keys.rs @@ -0,0 +1,77 @@ +//! The Slide Show menu and a running show's keys. + +use ely_ui::commands; +use gpui::{ + App, Context, Div, InteractiveElement as _, KeyBinding, Keystroke, Menu, MenuItem, actions, +}; + +use super::Show; +use crate::deck::Deck; + +actions!(show, [StartShow, StartShowHere, CheckShow]); + +/// Slide numbers past this many digits are not typed. +const DIGITS: usize = 6; + +/// The Slide Show menu. +pub fn menu(cx: &App) -> Menu { + let label = |key: &str| Deck::text(cx, key, &[]); + Menu::new(label("menu.play")).items([ + MenuItem::action(label("show.play.from_start"), StartShow), + MenuItem::action(label("show.play.from_here"), StartShowHere), + MenuItem::separator(), + MenuItem::action(label("show.check.menu"), CheckShow), + ]) +} + +pub fn bind_keys(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("f5", StartShow, Some(commands::CONTEXT)), + KeyBinding::new("shift-f5", StartShowHere, Some(commands::CONTEXT)), + ]); +} + +pub fn bind(root: Div, cx: &mut Context) -> Div { + root.on_action(cx.listener(|deck, _: &StartShow, _, cx| deck.start_show(false, cx))) + .on_action(cx.listener(|deck, _: &StartShowHere, _, cx| deck.start_show(true, cx))) + .on_action(cx.listener(|deck, _: &CheckShow, _, cx| deck.check_show(cx))) +} + +impl Show { + /// Show keys; true when taken. + pub fn key(&mut self, keystroke: &Keystroke, cx: &mut Context) -> bool { + let held = &keystroke.modifiers; + if held.platform || held.control || held.alt { + return false; + } + match keystroke.key.as_str() { + "right" | "down" | "space" | "pagedown" | "n" => self.next(cx), + "enter" if self.typed.is_empty() => self.next(cx), + "enter" => { + let number = self.typed.parse().expect("typed digits only"); + self.jump(number, cx); + } + "left" | "up" | "pageup" | "backspace" | "p" => self.previous(cx), + "home" => self.jump(1, cx), + "end" => { + let last = (0..self.slides.len()).rev().find(|at| !self.slides[*at].hidden); + self.jump(last.map_or(0, |at| at + 1), cx); + } + "b" | "." => self.toggle_black(cx), + "l" => self.toggle_laser(cx), + "escape" if !self.typed.is_empty() => { + self.typed.clear(); + cx.notify(); + } + "escape" => self.close(cx), + digit if digit.len() == 1 && digit.chars().all(|c| c.is_ascii_digit()) => { + if self.typed.len() < DIGITS { + self.typed.push_str(digit); + cx.notify(); + } + } + _ => return false, + } + true + } +} diff --git a/apps/ely-show/src/slideshow/layers.rs b/apps/ely-show/src/slideshow/layers.rs new file mode 100644 index 0000000..80f6d86 --- /dev/null +++ b/apps/ely-show/src/slideshow/layers.rs @@ -0,0 +1,99 @@ +//! The shown and next slides' layers, from the copy. + +use std::sync::Arc; + +use ely_office::prelude::*; +use gpui::Context; + +use super::{Show, Stack}; +use crate::preview::pictures; + +/// Reads at most this many slides at once. +const AT_ONCE: usize = 2; + +impl Show { + pub fn stack(&self, part: usize) -> Option<&Stack> { + self.layers.get(&part) + } + + fn wanted(&self) -> Vec { + self.part().into_iter().chain(self.upcoming()).collect() + } + + /// Reads wanted layers, drops the rest. + pub(super) fn fetch(&mut self, cx: &mut Context) { + let wanted = self.wanted(); + let gone = &mut self.gone; + self.layers.retain(|part, stack| { + let kept = wanted.contains(part); + if !kept { + gone.push(stack.clone()); + } + kept + }); + for part in wanted { + if self.layers.contains_key(&part) + || self.asking.contains(&part) + || self.asking.len() >= AT_ONCE + { + continue; + } + self.asking.insert(part); + log::info!("show: reads the layers of slide {part}"); + let pixels = self.pixels; + ely_lok::read_off( + &self.worker, + &self.doc, + move |worker, doc| doc.slide_layers(worker, part, pixels), + move |show: &mut Self, read, cx| { + show.asking.remove(&part); + match read { + Ok(layers) if show.wanted().contains(&part) => { + if let Some(old) = show.layers.insert(part, Arc::new(pictures(layers))) { + show.gone.push(old); + } + if show.part() == Some(part) { + show.arrived(cx); + } + cx.notify(); + } + Ok(_) => log::info!("show: slide {part} is no longer wanted"), + Err(error) => return show.fail(&format!("the layers of slide {part}: {error}"), cx), + } + show.fetch(cx); + }, + cx, + ); + } + } + + /// Ends the show and says why in the deck. + pub(super) fn fail(&mut self, why: &str, cx: &mut Context) { + log::error!("show: {why}"); + let (deck, why) = (self.deck.clone(), why.to_string()); + self.close(cx); + cx.defer(move |cx| { + if let Some(deck) = deck.upgrade() { + deck.update(cx, |deck, cx| deck.engine_failed(&why, cx)); + } + }); + } +} + +impl Show { + /// Frees, in window `which`, layers nothing draws any more. + pub(super) fn free(&mut self, which: usize, window: &mut gpui::Window) { + // A stack the leaving slide still holds waits. + let (free, held): (Vec<_>, Vec<_>) = self.gone.drain(..).partition(|stack| Arc::strong_count(stack) == 1); + self.gone = held; + for stack in free { + for (_, image) in stack.iter() { + self.retired[0].push(image.clone()); + if self.presenter { + self.retired[1].push(image.clone()); + } + } + } + self.retired[which].release_all(window); + } +} diff --git a/apps/ely-show/src/slideshow/media.rs b/apps/ely-show/src/slideshow/media.rs new file mode 100644 index 0000000..5645025 --- /dev/null +++ b/apps/ely-show/src/slideshow/media.rs @@ -0,0 +1,109 @@ +//! Audio and video on the shown slide, by a click. + +use ely_platform::media::Player; +use gpui::{Context, SurfaceSource}; + +use super::Show; +use crate::deck::Deck; + +/// The media object playing in the show. +pub struct Playing { + part: usize, + index: usize, + player: Player, + pub frame: Option, +} + +impl Show { + /// The shown slide's media under a point, as shares. + pub fn media_at(&self, (x, y): (f32, f32)) -> Option { + let part = self.part()?; + let (w, h) = self.slide; + self.slides[part].media.iter().position(|media| { + let (left, top, width, height) = media.bounds; + let (x, y) = (x * w, y * h); + (left as f32..(left + width) as f32).contains(&x) + && (top as f32..(top + height) as f32).contains(&y) + }) + } + + /// Stops the media and tells the presenter why. + fn media_failed(&mut self, reason: &str, cx: &mut Context) { + log::warn!("show: media failed: {reason}"); + self.media = None; + self.notice = Some(Deck::text(cx, "show.media.failed", &[("reason", reason)])); + cx.notify(); + } + + /// Plays, pauses or replays media `at` on the shown slide. + pub fn toggle_media(&mut self, at: usize, cx: &mut Context) { + let Some(part) = self.part() else { + return; + }; + if let Some(playing) = self.media.as_ref().filter(|p| (p.part, p.index) == (part, at)) { + match (playing.player.playing(), playing.player.ended()) { + (true, _) => playing.player.pause(), + (false, true) => { + playing.player.seek(0.0); + playing.player.play(); + } + (false, false) => playing.player.play(), + } + return cx.notify(); + } + let media = &self.slides[part].media[at]; + let opened = ely_lok::file_path(&media.file) + .map_err(|error| error.to_string()) + .and_then(|path| Player::open(&path).map_err(|error| error.to_string())); + match opened { + Ok(player) => { + log::info!("show: plays media {at} of slide {part}"); + player.play(); + self.media = Some(Playing { + part, + index: at, + player, + frame: None, + }); + self.notice = None; + cx.notify(); + } + Err(error) => self.media_failed(&error, cx), + } + } + + /// Takes the newest frame; true while frames may change. + pub fn media_tick(&mut self, cx: &mut Context) -> bool { + let Some(playing) = self.media.as_mut() else { + return false; + }; + let repeat = self.slides[playing.part].media[playing.index].repeat; + let ticked = playing.player.tick(repeat); + if let Some(frame) = playing.player.frame() { + #[cfg(target_os = "macos")] + { + playing.frame = Some(frame.into()); + } + #[cfg(not(target_os = "macos"))] + match frame {} + } + self.media_seen(ticked, cx) + } + + /// Draws on, or stops and says why. + pub fn media_seen(&mut self, ticked: Result, cx: &mut Context) -> bool { + ticked.unwrap_or_else(|reason| { + self.media_failed(&reason, cx); + false + }) + } + + /// The playing video's frame and its bounds as shares. + pub fn media_frame(&self) -> Option<(SurfaceSource, (f32, f32, f32, f32))> { + let playing = self.media.as_ref()?; + let (left, top, width, height) = self.slides[playing.part].media[playing.index].bounds; + let (w, h) = self.slide; + let share = (left as f32 / w, top as f32 / h, width as f32 / w, height as f32 / h); + Some((playing.frame.clone()?, share)) + } +} diff --git a/apps/ely-show/src/slideshow/presenter.rs b/apps/ely-show/src/slideshow/presenter.rs new file mode 100644 index 0000000..5fa4b13 --- /dev/null +++ b/apps/ely-show/src/slideshow/presenter.rs @@ -0,0 +1,235 @@ +//! The presenter's screen: slide, next slide, notes, time. + +use ely_gpui_component::{ + buttons::{Button, ToggleButton, ToggleItem}, + feedback::InlineMessage, + primitives::Severity, + primitives::FocusScope, + theme::{ActiveTheme as _, ControlSize, TextSize}, +}; +use std::{cell::Cell, rc::Rc, time::Duration}; + +use gpui::{ + App, Bounds, Context, Entity, FocusHandle, InteractiveElement as _, IntoElement, + ParentElement as _, Pixels, Render, StatefulInteractiveElement as _, Styled as _, Task, Window, + canvas, div, +}; + +use super::{Show, stage}; +use crate::deck::Deck; + +pub struct Presenter { + pub show: Entity, + pub focus: FocusHandle, + /// Where the current slide was last drawn. + shown_at: Rc>>, + _tick: Task<()>, +} + +impl Presenter { + pub fn new(show: Entity, cx: &mut Context) -> Self { + cx.observe(&show, |_, _, cx| cx.notify()).detach(); + // The clock moves once a second. + let tick = cx.spawn(async |this, cx| { + loop { + cx.background_executor().timer(Duration::from_secs(1)).await; + if this.update(cx, |_, cx| cx.notify()).is_err() { + break; + } + } + }); + Presenter { + show, + focus: cx.focus_handle(), + shown_at: Rc::default(), + _tick: tick, + } + } +} + +/// `m:ss`, or `h:mm:ss` past an hour. +pub fn clock(seconds: u64) -> String { + let (h, m, s) = (seconds / 3600, seconds / 60 % 60, seconds % 60); + match h { + 0 => format!("{m}:{s:02}"), + _ => format!("{h}:{m:02}:{s:02}"), + } +} + +fn button( + show: &Entity, + (id, label): (&'static str, gpui::SharedString), + act: fn(&mut Show, &mut Context), +) -> Button { + let show = show.clone(); + Button::new(id, label) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| show.update(cx, act)) +} + +fn toggle( + show: &Entity, + (id, label, on): (&'static str, gpui::SharedString, bool), + act: fn(&mut Show, &mut Context), +) -> ToggleButton { + let show = show.clone(); + ToggleButton::new(id, ToggleItem::new(id).label(label), on) + .size(ControlSize::Sm) + .on_toggle(move |_, _, cx| show.update(cx, act)) +} + +impl Presenter { + fn bar(&self, cx: &App) -> impl IntoElement { + let show = self.show.read(cx); + let label = |key: &str| Deck::text(cx, key, &[]); + let place = match (show.part(), show.typed.is_empty()) { + (_, false) => Deck::text(cx, "show.play.going", &[("number", &show.typed)]), + (Some(part), true) => Deck::text( + cx, + "show.play.place", + &[ + ("number", &(part + 1).to_string()), + ("count", &show.slides.len().to_string()), + ], + ), + (None, true) => label("show.play.ended"), + }; + let place = div() + .flex() + .items_center() + .gap_2() + .child(place) + .children(show.notice.clone().map(|why| InlineMessage::new(Severity::Danger, why))); + let now = jiff::Zoned::now().strftime("%H:%M").to_string(); + let row = || div().flex().items_center().gap_2(); + div() + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + row() + .child(button( + &self.show, + ("show-play-back", label("show.play.back")), + Show::previous, + )) + .child(button( + &self.show, + ("show-play-next", label("show.play.next")), + Show::next, + )) + .child(place), + ) + .child( + row() + .child(clock(show.began.elapsed().as_secs())) + .child(div().text_color(cx.theme().palette().fg_muted).child(now)), + ) + .child( + row() + .child(toggle( + &self.show, + ("show-play-black", label("show.play.black"), show.black), + Show::toggle_black, + )) + .child(toggle( + &self.show, + ("show-play-laser", label("show.play.laser"), show.laser), + Show::toggle_laser, + )) + .child(button( + &self.show, + ("show-play-end", label("show.play.end")), + Show::close, + )), + ) + } +} + +impl Render for Presenter { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.show.update(cx, |show, _| show.free(1, window)); + let show = self.show.read(cx); + let room = window.viewport_size(); + if show.moving() { + window.request_animation_frame(); + } + let theme = cx.theme(); + let big = stage::fit(show.slide, (room.width * 0.6, room.height * 0.8)); + let small = stage::fit(show.slide, (room.width * 0.3, room.height * 0.35)); + let next = match show.upcoming() { + Some(part) => stage::still(show, part, small, cx), + None => div().w(small.0).h(small.1).into_any_element(), + }; + let notes = show.part().map(|part| show.slides[part].notes.clone()); + let (moved, keys) = (self.show.clone(), self.show.clone()); + let (seen, store) = (self.shown_at.clone(), self.shown_at.clone()); + let current = div() + .id("show-presenter-slide") + .relative() + .on_mouse_move(move |event, _, cx| { + let (at, bounds) = (event.position, seen.get()); + let x = (at.x - bounds.origin.x) / bounds.size.width; + let y = (at.y - bounds.origin.y) / bounds.size.height; + let inside = (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y); + moved.update(cx, |show, cx| show.point(inside.then_some((x, y)), cx)) + }) + .child(stage::stage(show, big, cx)) + .child( + canvas(move |bounds, _, _| store.set(bounds), |_, _, _, _| {}) + .absolute() + .size_full(), + ); + let side = div() + .flex() + .flex_col() + .gap_4() + .flex_1() + .min_h_0() + .child(next) + .child( + div() + .id("show-notes") + .flex_1() + .overflow_y_scroll() + .text_size(theme.text_size(TextSize::Lg)) + .children(notes), + ); + // Keys reach ancestors of the focused scope. + div() + .size_full() + .on_key_down(move |event, _, cx| { + if keys.update(cx, |show, cx| show.key(&event.keystroke, cx)) { + cx.stop_propagation(); + } + }) + .child( + FocusScope::new(&self.focus) + .root() + .size_full() + .bg(theme.palette().surface) + .text_color(theme.palette().fg) + .font_family(theme.font_family.clone()) + .child( + div() + .id("show-presenter") + .size_full() + .p_6() + .flex() + .flex_col() + .gap_4() + .child(self.bar(cx)) + .child( + div() + .flex() + .gap_6() + .flex_1() + .min_h_0() + .child(current) + .child(side), + ), + ), + ) + } +} diff --git a/apps/ely-show/src/slideshow/stage.rs b/apps/ely-show/src/slideshow/stage.rs new file mode 100644 index 0000000..faa9479 --- /dev/null +++ b/apps/ely-show/src/slideshow/stage.rs @@ -0,0 +1,85 @@ +//! The shown slide at a size: transition, effects, black, laser. + +use ely_gpui_component::theme::ActiveTheme as _; +use gpui::{AnyElement, App, IntoElement, ParentElement as _, Pixels, Styled as _, div, px}; + +use super::{Show, steps}; +use crate::preview::{compose, transition}; + +/// The laser dot's width as a share of the slide. +const DOT: f32 = 0.012; + +/// The largest slide size within `room`. +pub fn fit(slide: (f32, f32), room: (Pixels, Pixels)) -> (Pixels, Pixels) { + let scale = (f32::from(room.0) / slide.0).min(f32::from(room.1) / slide.1); + (px(slide.0 * scale), px(slide.1 * scale)) +} + +fn black((w, h): (Pixels, Pixels)) -> AnyElement { + div().w(w).h(h).bg(gpui::black()).into_any_element() +} + +/// The show's slide now, as the audience sees it. +pub fn stage(show: &Show, (w, h): (Pixels, Pixels), cx: &App) -> AnyElement { + let Some(part) = show.part().filter(|_| !show.black) else { + return black((w, h)); + }; + let Some(stack) = show.stack(part) else { + return black((w, h)); + }; + let paper = ely_lok::paper(cx); + let t = show.entered.elapsed().as_secs_f64(); + let slide = &show.slides[part]; + let shown = match &show.leaving { + Some(leaving) if t < slide.transition.duration => { + let p = (t / slide.transition.duration) as f32; + let frame = transition::frame(&slide.transition.preset, p); + div() + .bg(gpui::black()) + .size_full() + .child(compose::transition((Some(compose::left((&leaving.stack, &leaving.runs), (show.slide, leaving.at), (w, h), paper)), stack), &frame, (w, h), paper)) + .into_any_element() + } + _ => { + let runs = steps::runs(&slide.effects, &show.clicks); + div() + .bg(paper) + .size_full() + .child(compose::effects(stack, &runs, show.slide, t, (w, h))) + .into_any_element() + } + }; + let dot = (show.pointer) + .filter(|_| show.laser) + .map(|(x, y)| { + let side = w * DOT; + div() + .absolute() + .left(w * x - side / 2.0) + .top(h * y - side / 2.0) + .size(side) + .rounded_full() + .bg(cx.theme().palette().danger) + }); + let video = show.media_frame().map(|(frame, (x, y, vw, vh))| { + crate::media::video(frame, (gpui::point(w * x, h * y), gpui::point(w * vw, h * vh))) + }); + div() + .relative() + .w(w) + .h(h) + .overflow_hidden() + .child(shown) + .children(video) + .children(dot) + .into_any_element() +} + +/// A slide as it first shows, still. +pub fn still(show: &Show, part: usize, size: (Pixels, Pixels), cx: &App) -> AnyElement { + match show.stack(part) { + Some(stack) => compose::slide(stack, |visible| visible, size, ely_lok::paper(cx)) + .into_any_element(), + None => black(size), + } +} diff --git a/apps/ely-show/src/slideshow/start.rs b/apps/ely-show/src/slideshow/start.rs new file mode 100644 index 0000000..30af92f --- /dev/null +++ b/apps/ely-show/src/slideshow/start.rs @@ -0,0 +1,315 @@ +//! Starting a show: a copy read, screens picked, windows open. + +use std::{ + io, + path::PathBuf, + rc::Rc, + sync::{Arc, Mutex}, +}; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Choice, Select, Switch}, + overlays::Dialog as Card, + primitives::Severity, +}; +use ely_lok::{Doc, Worker, show::ShowSlide}; +use ely_office::prelude::*; +use ely_platform::AppId; +use gpui::{ + AnyElement, AnyWindowHandle, App, AppContext as _, Context, Entity, EntityId, IntoElement, + ParentElement as _, PlatformDisplay, Window, WindowBounds, WindowOptions, +}; + +use super::{Show, audience::Audience, presenter::Presenter}; +use crate::deck::Deck; +use crate::dialog::{Dialog, edit}; + +/// Layer pixels never pass this width. +const WIDEST: f32 = 1920.0; + +/// A show waiting for its screens. +pub struct Starting { + tab: u64, + slides: Vec, + first: usize, + /// The copy the show plays, and its file. + doc: Doc, + copy: PathBuf, + /// The audience's screen, by place in the list. + audience: usize, + presenter: bool, +} + +/// Closes and deletes the show's copy, off the window. +pub fn discard(worker: Arc>, doc: Doc, copy: PathBuf, cx: &App) { + cx.background_spawn(async move { + if let Err(error) = worker.lock().expect("the worker lock").close_doc(doc) { + log::error!("show: the copy did not close: {error}"); + } + if let Err(error) = std::fs::remove_file(©) { + log::warn!("show: {} stays: {error}", copy.display()); + } + }) + .detach(); +} + +/// Saves `doc` as the show's copy and reads it. +fn copy( + worker: &mut Worker, + doc: &Doc, + (copy, scheme): (&std::path::Path, Option<&'static str>), +) -> io::Result<(usize, Doc, Vec)> { + let current = doc.sheets(worker)?.current; + let url = ely_lok::file_url(copy); + doc.save_as(worker, &url, "odp")?; + let shown = worker.load(&url)?; + let read = |worker: &mut Worker| -> io::Result> { + if let Some(scheme) = scheme { + worker.run(&shown, "view.theme", &[scheme])?; + } + ely_lok::show::slide_show(&worker.values(&shown, ely_lok::show::SLIDE_SHOW)?) + }; + match read(worker) { + Ok(slides) => Ok((current, shown, slides)), + Err(error) => { + worker.close_doc(shown)?; + Err(error) + } + } +} + +fn options(display: &Rc) -> WindowOptions { + WindowOptions { + window_bounds: Some(WindowBounds::Fullscreen(display.bounds())), + display_id: Some(display.id()), + titlebar: None, + focus: true, + app_id: Some(AppId::Show.slug().into()), + ..Default::default() + } +} + +/// Opens one show window; its root scope holds the keys. +pub fn show_window( + show: &Entity, + display: &Rc, + presenter: bool, + cx: &mut App, +) -> gpui::Result { + let show = show.clone(); + Ok(match presenter { + true => cx + .open_window(options(display), |window, cx| { + let view = cx.new(|cx| Presenter::new(show, cx)); + window.focus(&view.read(cx).focus.clone(), cx); + view + })? + .into(), + false => cx + .open_window(options(display), |window, cx| { + let view = cx.new(|cx| Audience::new(show, cx)); + window.focus(&view.read(cx).focus.clone(), cx); + view + })? + .into(), + }) +} + +impl Deck { + /// Plays a copy from the first or shown slide. + pub fn start_show(&mut self, here: bool, cx: &mut Context) { + if self.show.is_some() || self.show_starting { + return log::info!("show: already playing or starting"); + } + let Some(id) = self.core.active else { + return; + }; + self.show_starting = true; + log::info!("show: starts, here {here}"); + if !self.settled(id, cx) { + return self.when_settled(id, move |deck, _, cx| deck.read_show(id, here, cx)); + } + self.read_show(id, here, cx); + } + + fn read_show(&mut self, id: u64, here: bool, cx: &mut Context) { + let Some(tab) = self.tab(id) else { + self.show_starting = false; + return; + }; + let scheme = tab.view.read(cx).dark().map(ely_lok::scheme); + let path = tab.work.join(format!("show-{}.odp", self.shows)); + self.shows += 1; + let into = path.clone(); + let worker = self.core.worker.clone(); + self.read_in( + id, + move |worker, doc| copy(worker, doc, (&into, scheme)), + move |deck, read, _, cx| { + deck.show_starting = false; + let (current, doc, slides) = match read { + Ok(read) => read, + Err(error) => return deck.engine_failed(&format!("the show: {error}"), cx), + }; + let first = match here { + true => Some(current).filter(|at| *at < slides.len()), + false => slides.iter().position(|slide| !slide.hidden), + }; + let Some(first) = first.filter(|_| deck.core.active == Some(id)) else { + discard(worker, doc, path, cx); + if deck.core.active != Some(id) { + return log::info!("show: tab {id} is no longer shown"); + } + let text = Deck::text(cx, "show.play.none", &[]); + return deck.toast(text, Severity::Warning, cx); + }; + let starting = Starting { + tab: id, + slides, + first, + doc, + copy: path, + audience: 0, + presenter: true, + }; + match cx.displays().len() { + 1 => deck.open_show(starting, cx), + _ => { + deck.show_starting = true; + deck.dialog = Some(Dialog::Show(starting)); + cx.notify(); + } + } + }, + cx, + ); + } + + fn open_show(&mut self, starting: Starting, cx: &mut Context) { + self.show_starting = false; + let worker = self.core.worker.clone(); + let displays = cx.displays(); + let (Some(tab), Some(audience)) = (self.tab(starting.tab), displays.get(starting.audience)) + else { + log::warn!("show: the tab or screen {} is gone", starting.audience); + return discard(worker, starting.doc, starting.copy, cx); + }; + let presenter = (displays.iter()) + .find(|display| display.id() != audience.id()) + .filter(|_| starting.presenter); + let slide = crate::design::mm100(tab.extra.size); + let slide = (slide.0 as f32, slide.1 as f32); + let room = audience.bounds().size; + let fitted = super::stage::fit(slide, (room.width, room.height)); + let width = (f32::from(fitted.0) * 2.0).min(WIDEST).round() as u32; + let pixels = (width, (width as f32 * slide.1 / slide.0).round() as u32); + let deck = cx.weak_entity(); + let played = (worker, starting.doc, starting.copy); + let show = cx.new(|cx| { + Show::new(deck, played, (starting.slides, starting.first), (slide, pixels), cx) + }); + let mut windows = Vec::new(); + // The audience window opens last, so it is in front. + let screens = presenter.map(|d| (d, true)).into_iter().chain([(audience, false)]); + for (display, presenter) in screens { + match show_window(&show, display, presenter, cx) { + Ok(handle) => windows.push(handle), + Err(error) => { + show.update(cx, |show, cx| { + show.windows = windows; + show.close(cx); + }); + return self.engine_failed(&format!("the show window: {error}"), cx); + } + } + } + log::info!("show: {} windows open", windows.len()); + show.update(cx, |show, _| { + show.presenter = windows.len() > 1; + show.windows = windows; + }); + self.show = Some(show); + cx.notify(); + } + + /// Show `id` closed its windows. + pub fn show_ended(&mut self, id: EntityId, cx: &mut Context) { + if self.show.as_ref().is_some_and(|show| show.entity_id() == id) { + self.show = None; + cx.notify(); + } + } + + /// Picks screens before the show opens. + pub fn show_dialog(&self, starting: &Starting, cx: &mut Context) -> AnyElement { + let this = cx.entity(); + let label = |key: &str| Deck::text(cx, key, &[]); + let screens: Vec = (cx.displays().iter().enumerate()) + .map(|(at, display)| { + let size = display.bounds().size; + let name = Deck::text( + cx, + "show.play.screen", + &[ + ("number", &(at + 1).to_string()), + ("width", &f32::from(size.width).round().to_string()), + ("height", &f32::from(size.height).round().to_string()), + ], + ); + Choice::new(at.to_string(), name) + }) + .collect(); + let (pick, flip) = (this.clone(), this.clone()); + let screen = Select::new("show-play-screen", screens) + .label(label("show.play.audience")) + .selected(starting.audience.to_string()) + .on_change(move |value, _, cx| { + let at = value.parse().expect("screens go by number"); + edit(&pick, cx, |dialog, _| { + if let Dialog::Show(starting) = dialog { + starting.audience = at; + } + }) + }); + let presenter = Switch::new("show-play-presenter", starting.presenter) + .name(label("show.play.presenter")) + .on_change(move |on, _, cx| { + edit(&flip, cx, |dialog, _| { + if let Dialog::Show(starting) = dialog { + starting.presenter = on; + } + }) + }); + let rows = DescriptionList::new() + .item(label("show.play.audience"), screen) + .item(label("show.play.presenter"), presenter); + let closing = this.clone(); + let dismiss = move |_: &mut Window, cx: &mut App| { + closing.update(cx, |deck, cx| { + if let Some(Dialog::Show(starting)) = deck.dialog.take() { + deck.show_starting = false; + discard(deck.core.worker.clone(), starting.doc, starting.copy, cx); + } + cx.notify(); + }) + }; + let go = label("show.play.start"); + Card::new("show-play", label("show.play.title"), dismiss) + .child(rows) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("show-play-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| { + this.update(cx, |deck, cx| { + if let Some(Dialog::Show(starting)) = deck.dialog.take() { + deck.open_show(starting, cx); + } + }) + }) + }) + .into_any_element() + } +} diff --git a/apps/ely-show/src/slideshow/steps.rs b/apps/ely-show/src/slideshow/steps.rs new file mode 100644 index 0000000..469404e --- /dev/null +++ b/apps/ely-show/src/slideshow/steps.rs @@ -0,0 +1,171 @@ +//! Where a show stands, and where each key takes it. + +use ely_lok::show::{Effect, ShowSlide}; + +use crate::preview::timeline::{Run, length, schedule}; + +/// Effects in click groups; group 0 runs on entry. +pub fn groups(effects: &[Effect]) -> Vec> { + let mut groups = vec![Vec::new()]; + for effect in effects { + if effect.start == "click" { + groups.push(Vec::new()); + } + groups.last_mut().expect("one group").push(effect.clone()); + } + groups +} + +/// A slide and its clicks done, or the end. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Place { + Slide { part: usize, clicks: usize }, + End, +} + +/// The next place on a click. +pub fn next(slides: &[ShowSlide], place: Place) -> Place { + match place { + Place::Slide { part, clicks } if clicks + 1 < groups(&slides[part].effects).len() => { + Place::Slide { + part, + clicks: clicks + 1, + } + } + Place::Slide { part, .. } => (part + 1..slides.len()) + .find(|at| !slides[*at].hidden) + .map_or(Place::End, |part| Place::Slide { part, clicks: 0 }), + Place::End => Place::End, + } +} + +/// The previous slide, fully built; the first stays. +pub fn previous(slides: &[ShowSlide], place: Place) -> Place { + let from = match place { + Place::Slide { part, .. } => part, + Place::End => slides.len(), + }; + let built = |part: usize| Place::Slide { + part, + clicks: groups(&slides[part].effects).len() - 1, + }; + match (0..from).rev().find(|at| !slides[*at].hidden) { + Some(part) => built(part), + None => place, + } +} + +/// Runs from entry: group `n` starts at `started[n]` seconds. +pub fn runs(effects: &[Effect], started: &[f64]) -> Vec { + (groups(effects).iter().zip(started)) + .flat_map(|(group, at)| { + schedule(group).into_iter().map(move |run| Run { + begin: run.begin + at, + end: run.end + at, + ..run + }) + }) + .collect() +} + +/// A click group starts now or once all before end. +pub fn click_at(effects: &[Effect], clicks: &[f64], now: f64) -> f64 { + let before = clicks.iter().copied().fold(now, f64::max); + length(&runs(effects, clicks)).max(before) +} + +#[cfg(test)] +mod tests { + use super::*; + use ely_lok::show::Transition; + + fn effect(start: &str) -> Effect { + Effect { + preset: "ooo-entrance-fade-in".into(), + label: String::new(), + class: "entrance".into(), + target: Some(0), + hash: start.into(), + start: start.into(), + duration: 1.0, + delay: 0.0, + } + } + + fn slide(hidden: bool, effects: Vec) -> ShowSlide { + ShowSlide { + hidden, + notes: String::new(), + transition: Transition { + preset: String::new(), + duration: 0.0, + auto: false, + time: 0.0, + }, + effects, + media: Vec::new(), + } + } + + #[test] + fn with_and_after_join_the_click_before() { + let sizes = |effects: &[Effect]| groups(effects).iter().map(Vec::len).collect::>(); + assert_eq!(sizes(&[]), [0]); + assert_eq!(sizes(&[effect("with"), effect("click")]), [1, 1]); + assert_eq!(sizes(&[effect("click"), effect("after"), effect("click")]), [0, 2, 1]); + } + + #[test] + fn clicks_play_effects_then_skip_hidden_slides_to_the_end() { + let deck = [ + slide(false, vec![effect("click")]), + slide(true, Vec::new()), + slide(false, Vec::new()), + ]; + let mut place = Place::Slide { part: 0, clicks: 0 }; + let mut seen = Vec::new(); + for _ in 0..4 { + place = next(&deck, place); + seen.push(place); + } + assert_eq!( + seen, + [ + Place::Slide { part: 0, clicks: 1 }, + Place::Slide { part: 2, clicks: 0 }, + Place::End, + Place::End + ] + ); + assert_eq!(previous(&deck, Place::End), Place::Slide { part: 2, clicks: 0 }); + assert_eq!( + previous(&deck, Place::Slide { part: 2, clicks: 0 }), + Place::Slide { part: 0, clicks: 1 }, + "back to a built slide, past the hidden one" + ); + let first = Place::Slide { part: 0, clicks: 0 }; + assert_eq!(previous(&deck, first), first); + } + + #[test] + fn a_quick_click_waits_for_the_groups_and_transition_before() { + let long = |start| Effect { + duration: 10.0, + ..effect(start) + }; + let effects = [long("click"), long("click")]; + assert_eq!(click_at(&effects, &[0.0], 0.1), 0.1, "nothing runs yet"); + assert_eq!(click_at(&effects, &[0.0, 0.1], 0.2), 10.1, "after the first"); + assert_eq!(click_at(&effects, &[2.0], 0.5), 2.0, "after the transition"); + } + + #[test] + fn each_group_runs_from_its_own_click() { + let effects = [effect("with"), effect("click"), effect("after")]; + let times: Vec<(f64, f64)> = runs(&effects, &[0.0, 5.0]) + .iter() + .map(|r| (r.begin, r.end)) + .collect(); + assert_eq!(times, [(0.0, 1.0), (5.0, 6.0), (6.0, 7.0)]); + } +} diff --git a/apps/ely-show/src/table.rs b/apps/ely-show/src/table.rs new file mode 100644 index 0000000..e0983d1 --- /dev/null +++ b/apps/ely-show/src/table.rs @@ -0,0 +1,128 @@ +//! Tables on a slide (SHOW-07). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::{DescriptionList, PropertyGroup}, + forms::NumberInput, + overlays::Dialog as Card, +}; +use ely_office::{ + panel::{button, row}, + prelude::*, +}; +use gpui::{ + AnyElement, App, Context, Entity, IntoElement, ParentElement as _, Styled as _, Window, div, +}; + +use crate::deck::Deck; +use crate::design::Panel; +use crate::dialog::{Dialog, edit}; + +impl Deck { + pub fn ask_table(&mut self, cx: &mut Context) { + if let Some(tab) = self.core.active { + self.dialog = Some(Dialog::Table { + tab, + columns: 3, + rows: 3, + }); + cx.notify(); + } + } + + pub(crate) fn table_asked(&mut self, cx: &mut Context) { + let Some(Dialog::Table { tab, columns, rows }) = self.dialog.take() else { + return; + }; + let (columns, rows) = (columns.to_string(), rows.to_string()); + self.run_in(Some(tab), "table.insert", &[&columns, &rows], cx); + self.force_reread(tab, cx); + cx.notify(); + } + + pub fn table_dialog(&self, (columns, rows): (u16, u16), cx: &mut Context) -> AnyElement { + let this = cx.entity(); + let label = |key: &str| Deck::text(cx, key, &[]); + let count = |id: &'static str, key: &str, now: u16, max: f64, across: bool| { + // A typed draft counts before blur commits it. + let set = move |this: &Entity, n: f64, cx: &mut App| { + edit(this, cx, |dialog, _| { + if let Dialog::Table { columns, rows, .. } = dialog { + *(if across { columns } else { rows }) = n as u16; + } + }) + }; + let (typed, committed) = (this.clone(), this.clone()); + let field = NumberInput::new(id, f64::from(now)) + .label(label(key)) + .range(1.0, max) + .on_change(move |n, _, cx| set(&typed, n, cx)) + .on_commit(move |n, _, cx| set(&committed, n, cx)); + div().w_full().max_w_32().child(field) + }; + let rows = DescriptionList::new() + .item( + label("show.table.columns"), + count( + "show-table-columns", + "show.table.columns", + columns, + 20.0, + true, + ), + ) + .item( + label("show.table.rows"), + count("show-table-rows", "show.table.rows", rows, 50.0, false), + ); + let closing = this.clone(); + let dismiss = move |_: &mut Window, cx: &mut App| { + closing.update(cx, |deck, cx| { + deck.dialog = None; + cx.notify(); + }) + }; + let go = label("show.table.go"); + Card::new("show-table", label("show.table.title"), dismiss) + .child(rows) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("show-table-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |deck, cx| deck.table_asked(cx))) + }) + .into_any_element() + } +} + +impl Panel { + /// Rows, columns, cells and the table itself. + pub(crate) fn table_group(&self, cx: &mut Context) -> PropertyGroup { + let why = "show.table.why"; + let rows = row([ + button(self, "table.row_above", "show.table.row_above", why, cx), + button(self, "table.row_below", "show.table.row_below", why, cx), + button(self, "table.delete_rows", "show.table.delete", why, cx), + ]); + let columns = row([ + button(self, "table.column_left", "show.table.column_left", why, cx), + button( + self, + "table.column_right", + "show.table.column_right", + why, + cx, + ), + button(self, "table.delete_columns", "show.table.delete", why, cx), + ]); + let cells = row([ + button(self, "table.merge", "show.table.merge", why, cx), + button(self, "table.delete", "show.table.delete_table", why, cx), + ]); + let tip = |key: &str| Deck::text(cx, key, &[]); + PropertyGroup::new(tip("show.table.group")) + .row(tip("show.table.rows"), rows) + .row(tip("show.table.columns"), columns) + .row(tip("show.table.cells"), cells) + } +} diff --git a/apps/ely-show/src/tests.rs b/apps/ely-show/src/tests.rs new file mode 100644 index 0000000..8f48bef --- /dev/null +++ b/apps/ely-show/src/tests.rs @@ -0,0 +1,483 @@ +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; + +use ely_office::prelude::*; +use gpui::{AppContext as _, Entity, TestAppContext}; + +use crate::deck::Deck; + +mod motion; +mod objects; +mod output; +mod play; +mod play_more; +mod play_time; +mod review; +mod slides; + +/// An unrendered Deck over a fresh worker, if LOK exists. +pub(super) fn deck(cx: &mut TestAppContext, dir: &Path) -> Option> { + let install = ely_lok::install_dir().ok()?; + let worker = ely_lok::Worker::spawn(&install, &dir.join("profile")).expect("the worker"); + let work = dir.join("work"); + std::fs::create_dir_all(&work).expect("the work dir"); + cx.update(ely_gpui_component::init_for_tests); + cx.update(|cx| { + ely_ui::settings::install_defaults(cx); + ely_ui::i18n::install(cx); + }); + let worker = Arc::new(Mutex::new(worker)); + let window = cx.add_window(|_, _| gpui::Empty); + let deck = cx + .update_window(window.into(), |_, window, app| { + let deck = app.new(|cx| Deck::new(worker, work, cx)); + deck.update(app, |deck, _| { + deck.core.window = Some(window.window_handle()) + }); + deck + }) + .expect("the window lives"); + Some(deck) +} + +/// Steps the scheduler until `done` or ten seconds pass. +#[track_caller] +fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline { + if !cx.executor().tick() { + cx.executor() + .advance_clock(std::time::Duration::from_millis(20)); + } + if cx.update(|app| done(app)) { + return; + } + } + panic!("the state never settled"); +} + +fn update( + cx: &mut TestAppContext, + deck: &Entity, + f: impl FnOnce(&mut Deck, &mut gpui::Context), +) { + cx.update(|app| deck.update(app, f)); +} + +/// Slides and painted pictures of the active deck. +fn counts(app: &gpui::App, deck: &Entity) -> (usize, usize) { + deck.read(app).active_tab().map_or((0, 0), |tab| { + let painted = tab + .extra + .slides + .list + .iter() + .filter(|slide| { + tab.extra.thumbs.contains_key(&slide.name) && !tab.extra.stale.contains(&slide.name) + }) + .count(); + (tab.extra.slides.list.len(), painted) + }) +} + +fn quiet(cx: &TestAppContext, deck: &Entity) { + settle(cx, |app| { + deck.read(app).active_tab().is_some_and(|tab| { + tab.view.read(app).settled() && !tab.extra.reading && !tab.extra.painting + }) + }); +} + +#[test] +fn a_blank_deck_grows_and_every_slide_has_a_picture() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = deck(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + update(&mut cx, &deck, |deck, cx| deck.new_blank(cx)); + settle(&cx, |app| counts(app, &deck) == (1, 1)); + let layout = cx.update(|app| { + deck.read(app) + .active_tab() + .and_then(|tab| tab.extra.design.clone()) + }); + assert_eq!(layout.expect("a design").layout, 0, "a title slide"); + update(&mut cx, &deck, |deck, cx| { + deck.run_active("slide.insert", &[], cx) + }); + update(&mut cx, &deck, |deck, cx| { + deck.run_active("slide.duplicate", &[], cx) + }); + settle(&cx, |app| counts(app, &deck) == (3, 3)); + update(&mut cx, &deck, |deck, cx| { + deck.run_active("slide.hide", &[], cx) + }); + settle(&cx, |app| { + deck.read(app) + .active_tab() + .is_some_and(|tab| tab.extra.slides.list.iter().any(|slide| !slide.visible)) + }); +} + +#[test] +fn design_changes_reach_the_kernel_and_repaint() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = deck(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + update(&mut cx, &deck, |deck, cx| deck.new_blank(cx)); + settle(&cx, |app| counts(app, &deck) == (1, 1)); + let before = cx.update(|app| { + deck.read(app) + .active_tab() + .expect("a tab") + .extra + .thumbs + .values() + .next() + .cloned() + }); + update(&mut cx, &deck, |deck, cx| { + deck.design_command("slide.size", &["25400", "19050", "true"], cx) + }); + settle(&cx, |app| { + deck.read(app).active_tab().is_some_and(|tab| { + let (w, h) = tab.extra.size; + (w - 14400).abs() <= 2 && (h - 10800).abs() <= 2 + }) + }); + quiet(&cx, &deck); + let after = cx.update(|app| { + deck.read(app) + .active_tab() + .expect("a tab") + .extra + .thumbs + .values() + .next() + .cloned() + }); + assert!( + !Arc::ptr_eq(&before.expect("a picture"), &after.expect("a picture")), + "repainted" + ); + update(&mut cx, &deck, |deck, cx| { + deck.design_command("slide.layout", &["20"], cx) + }); + settle(&cx, |app| { + deck.read(app) + .active_tab() + .and_then(|tab| tab.extra.design.as_ref()) + .is_some_and(|design| design.layout == 20) + }); +} + +#[test] +fn templates_open_as_untitled_decks() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = deck(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let templates = ely_lok::templates("presnt").expect("bundled templates"); + assert!(templates.len() >= 5, "{templates:?}"); + update(&mut cx, &deck, |deck, cx| { + deck.new_from_file(&templates[0], cx) + }); + settle(&cx, |app| counts(app, &deck).0 >= 1); + let name = cx.update(|app| deck.read(app).active_tab().expect("a tab").name.clone()); + assert!( + name.contains("Untitled") || name.contains("未命名"), + "{name}" + ); +} + +#[test] +fn the_master_view_comes_and_goes() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = deck(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + update(&mut cx, &deck, |deck, cx| deck.new_blank(cx)); + settle(&cx, |app| counts(app, &deck) == (1, 1)); + let masters = |app: &gpui::App| { + deck.read(app) + .active_tab() + .is_some_and(|tab| tab.extra.masters) + }; + update(&mut cx, &deck, |deck, cx| deck.masters(true, cx)); + settle(&cx, |app| masters(app) && counts(app, &deck) == (1, 1)); + let shown = cx.update(|app| { + deck.read(app) + .active_tab() + .expect("a tab") + .extra + .slides + .list[0] + .name + .clone() + }); + assert_eq!(shown, "Default", "the strip lists masters"); + update(&mut cx, &deck, |deck, cx| deck.masters(false, cx)); + settle(&cx, |app| !masters(app) && counts(app, &deck) == (1, 1)); +} + +/// Each cached picture equals the kernel's fresh one. +fn pictures_match(cx: &mut TestAppContext, deck: &Entity) { + quiet(cx, deck); + cx.update(|app| { + let deck = deck.read(app); + let tab = deck.active_tab().expect("a tab"); + let mut worker = deck.core.worker.lock().expect("the worker lock"); + for (part, slide) in tab.extra.slides.list.iter().enumerate() { + let fresh = tab + .doc + .paint_part(&mut worker, (part, false), (320, 180)) + .expect("paints"); + let cached = tab.extra.thumbs.get(&slide.name).expect("a picture"); + assert_eq!( + cached.as_bytes(0), + Some(&fresh[..]), + "slide {part} is current" + ); + } + }); +} + +#[test] +fn undoing_a_resize_repaints_every_slide() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = deck(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + update(&mut cx, &deck, |deck, cx| deck.new_blank(cx)); + settle(&cx, |app| counts(app, &deck) == (1, 1)); + update(&mut cx, &deck, |deck, cx| { + deck.run_active("slide.duplicate", &[], cx) + }); + settle(&cx, |app| counts(app, &deck) == (2, 2)); + // Width then height, before either reads back. + update(&mut cx, &deck, |deck, cx| deck.resize((25_400, 15_750), cx)); + update(&mut cx, &deck, |deck, cx| { + let (w, _) = deck.active_tab().expect("a tab").extra.shown_size(); + deck.resize((w, 19_050), cx) + }); + settle(&cx, |app| { + deck.read(app).active_tab().is_some_and(|tab| { + let (w, h) = crate::design::mm100(tab.extra.size); + tab.extra.size_asked.is_none() && (w - 25_400).abs() <= 2 && (h - 19_050).abs() <= 2 + }) + }); + pictures_match(&mut cx, &deck); + update(&mut cx, &deck, |deck, cx| { + deck.run_active("edit.undo", &[], cx) + }); + update(&mut cx, &deck, |deck, cx| { + deck.run_active("edit.undo", &[], cx) + }); + settle(&cx, |app| { + deck.read(app).active_tab().is_some_and(|tab| { + let (w, h) = crate::design::mm100(tab.extra.size); + (w - 28_000).abs() <= 2 && (h - 15_750).abs() <= 2 + }) + }); + pictures_match(&mut cx, &deck); +} + +#[test] +fn a_failed_picture_waits_for_the_next_read() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = deck(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + update(&mut cx, &deck, |deck, cx| deck.new_blank(cx)); + settle(&cx, |app| counts(app, &deck) == (1, 1)); + // The kernel loses the document under the deck. + cx.update(|app| { + let deck = deck.read(app); + let doc = deck.active_tab().expect("a tab").doc.clone(); + deck.core + .worker + .lock() + .expect("the worker lock") + .close_doc(doc) + .expect("closes"); + }); + update(&mut cx, &deck, |deck, cx| { + let id = deck.core.active.expect("a tab"); + deck.tab_mut(id).expect("the tab").extra.thumbs.clear(); + deck.paint_next(id, cx); + }); + cx.run_until_parked(); + let failed = cx.update(|app| { + deck.read(app) + .active_tab() + .expect("a tab") + .extra + .failed + .len() + }); + assert_eq!(failed, 1, "one failure, then no more requests"); +} + +/// Undo steps the kernel holds for the active deck. +fn undo_steps(cx: &mut TestAppContext, deck: &Entity) -> usize { + quiet(cx, deck); + cx.update(|app| { + let deck = deck.read(app); + let tab = deck.active_tab().expect("a tab"); + let mut worker = deck.core.worker.lock().expect("the worker lock"); + let json: serde_json::Value = + serde_json::from_str(&worker.values(&tab.doc, ".uno:Undo").expect("reads")) + .expect("parses"); + json["actions"].as_array().map_or(0, Vec::len) + }) +} + +#[test] +fn the_same_size_twice_is_one_step() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = deck(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + update(&mut cx, &deck, |deck, cx| deck.new_blank(cx)); + settle(&cx, |app| counts(app, &deck) == (1, 1)); + let before = undo_steps(&mut cx, &deck); + // Enter, then the blur that follows it. + update(&mut cx, &deck, |deck, cx| deck.resize((30_000, 15_750), cx)); + update(&mut cx, &deck, |deck, cx| deck.resize((30_000, 15_750), cx)); + settle(&cx, |app| { + deck.read(app) + .active_tab() + .is_some_and(|tab| tab.extra.size_asked.is_none()) + }); + assert_eq!(undo_steps(&mut cx, &deck), before + 1); +} + +/// Two slides whose master shows MasterBrand. +fn branded(cx: &mut TestAppContext, dir: &Path) -> Option> { + let deck = deck(cx, dir)?; + update(cx, &deck, |deck, cx| deck.new_blank(cx)); + settle(cx, |app| counts(app, &deck) == (1, 1)); + update(cx, &deck, |deck, cx| { + deck.run_active("slide.duplicate", &[], cx) + }); + settle(cx, |app| counts(app, &deck) == (2, 2)); + let masters = |app: &gpui::App| { + deck.read(app) + .active_tab() + .is_some_and(|tab| tab.extra.masters) + }; + update(cx, &deck, |deck, cx| deck.masters(true, cx)); + settle(cx, |app| masters(app) && counts(app, &deck) == (1, 1)); + cx.update(|app| { + let deck = deck.read(app); + let doc = deck.active_tab().expect("a tab").doc.clone(); + let mut worker = deck.core.worker.lock().expect("the worker lock"); + worker + .paste(&doc, "text/plain;charset=utf-8", b"MasterBrand") + .expect("pastes"); + }); + update(cx, &deck, |deck, cx| deck.masters(false, cx)); + settle(cx, |app| !masters(app) && counts(app, &deck) == (2, 2)); + pictures_match(cx, &deck); + Some(deck) +} + +/// Runs both commands in one update. +fn both(cx: &mut TestAppContext, deck: &Entity, first: &str, then: &str) { + update(cx, deck, |deck, cx| { + deck.run_active(first, &[], cx); + deck.run_active(then, &[], cx); + }); + settle(cx, |app| counts(app, deck) == (2, 2)); + pictures_match(cx, deck); +} + +#[test] +fn undoing_a_master_edit_repaints_its_slides() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = branded(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + update(&mut cx, &deck, |deck, cx| { + deck.run_active("edit.undo", &[], cx) + }); + settle(&cx, |app| counts(app, &deck) == (2, 2)); + pictures_match(&mut cx, &deck); + both(&mut cx, &deck, "edit.redo", "slide.hide"); +} + +#[test] +fn an_edit_right_after_undo_still_repaints() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = branded(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + both(&mut cx, &deck, "edit.undo", "slide.hide"); +} + +#[test] +fn leaving_text_edit_repaints_the_picture() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = deck(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + update(&mut cx, &deck, |deck, cx| deck.new_blank(cx)); + settle(&cx, |app| counts(app, &deck) == (1, 1)); + let caret = |app: &gpui::App| { + deck.read(app) + .active_tab() + .is_some_and(|tab| tab.view.read(app).caret()) + }; + cx.update(|app| { + let deck = deck.read(app); + let doc = deck.active_tab().expect("a tab").doc.clone(); + let mut worker = deck.core.worker.lock().expect("the worker lock"); + for kind in [0, 1] { + let click = ely_lok::Mouse { + kind, + x: 7900, + y: 1100, + count: 1, + buttons: 1, + modifiers: 0, + }; + worker.post_mouse(&doc, click).expect("clicks"); + } + for c in "Plan".chars() { + worker.post_key(&doc, c as u32, 0).expect("types"); + } + }); + settle(&cx, caret); + quiet(&cx, &deck); + cx.update(|app| { + let deck = deck.read(app); + let doc = deck.active_tab().expect("a tab").doc.clone(); + let mut worker = deck.core.worker.lock().expect("the worker lock"); + worker.post_key(&doc, 0, 1281).expect("escapes"); + }); + settle(&cx, |app| !caret(app)); + pictures_match(&mut cx, &deck); +} diff --git a/apps/ely-show/src/tests/motion.rs b/apps/ely-show/src/tests/motion.rs new file mode 100644 index 0000000..fa636dc --- /dev/null +++ b/apps/ely-show/src/tests/motion.rs @@ -0,0 +1,398 @@ +//! Transitions, effects and their previews from the deck (C10.4). + +use ely_lok::show::{Part, Transition}; +use ely_office::prelude::*; +use gpui::{AppContext as _, TestAppContext}; + +use super::objects::{act, blank}; +use super::{settle, update}; +use crate::preview::Playing; + +#[test] +fn a_transition_and_an_effect_land_and_preview() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let motion = |app: &gpui::App| { + deck.read(app) + .active_tab() + .and_then(|tab| tab.extra.motion.clone()) + }; + settle(&cx, |app| motion(app).is_some()); + let fade = Transition { + preset: "fade-smoothly".into(), + duration: 0.5, + auto: false, + time: 0.0, + }; + let json = ely_lok::show::transition_json(&fade, false); + update(&mut cx, &deck, |deck, cx| { + deck.design_command("slide.transition", &[&json], cx) + }); + settle(&cx, |app| motion(app).is_some_and(|(t, _)| t == fade)); + act( + &mut cx, + &deck, + ("draw.shape", &["rectangle", "true"]), + |o| o.selection.is_some(), + ); + let add = serde_json::json!({ "preset": "ooo-entrance-fly-in" }).to_string(); + update(&mut cx, &deck, |deck, cx| { + deck.design_command("animation.add", &[&add], cx) + }); + settle(&cx, |app| { + motion(app).is_some_and(|(_, effects)| effects.len() == 1) + }); + let hash = cx.update(|app| motion(app).expect("read").1[0].hash.clone()); + update(&mut cx, &deck, |deck, cx| deck.preview_effects(cx)); + settle(&cx, |app| deck.read(app).preview.is_some()); + let moving = + cx.update( + |app| match &deck.read(app).preview.as_ref().expect("playing").playing { + Playing::Effects { layers, .. } => layers + .iter() + .filter(|(part, _)| matches!(part, Part::Moving { hash: h, .. } if *h == hash)) + .count(), + Playing::Transition { .. } => 0, + }, + ); + assert_eq!(moving, 1, "the shape plays as its own layer"); + update(&mut cx, &deck, |deck, cx| deck.stop_preview(cx)); + update(&mut cx, &deck, |deck, cx| deck.preview_transition(cx)); + settle(&cx, |app| deck.read(app).preview.is_some()); + let first = cx.update( + |app| match &deck.read(app).preview.as_ref().expect("playing").playing { + Playing::Transition { from, preset, .. } => (from.is_none(), preset.clone()), + Playing::Effects { .. } => (false, String::new()), + }, + ); + assert_eq!( + first, + (true, "fade-smoothly".to_string()), + "the first slide comes from black" + ); +} + +#[test] +fn inserted_media_reads_back_and_is_picked() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/media/clip.mp4"); + let url = ely_lok::file_url(&clip); + update(&mut cx, &deck, |deck, cx| { + let tab = deck.core.active; + deck.run_in(tab, "media.insert", &[&url, "false", "8000", "4500"], cx); + deck.force_reread(tab.expect("a tab"), cx); + }); + let media = |app: &gpui::App| { + deck.read(app) + .active_tab() + .map(|tab| tab.extra.media.clone()) + .unwrap_or_default() + }; + settle(&cx, |app| media(app).len() == 1); + let index = cx.update(|app| media(app)[0].index).to_string(); + act(&mut cx, &deck, ("object.select", &[&index, "false"]), |o| { + o.selection.is_some() + }); + let picked = cx.update(|app| deck.read(app).picked_media()); + assert!(picked.is_some_and(|(_, media)| !media.missing && !media.link)); +} + +#[test] +fn a_stop_cancels_a_preview_on_its_way_and_a_slide_change_ends_one() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let ready = |app: &gpui::App| { + deck.read(app) + .active_tab() + .is_some_and(|tab| tab.extra.motion.is_some() && tab.extra.history.is_some()) + }; + settle(&cx, ready); + update(&mut cx, &deck, |deck, cx| { + deck.preview_transition(cx); + deck.stop_preview(cx); + }); + for _ in 0..100 { + cx.executor() + .advance_clock(std::time::Duration::from_millis(20)); + cx.run_until_parked(); + } + assert!( + cx.update(|app| deck.read(app).preview.is_none()), + "the stopped read never plays" + ); + update(&mut cx, &deck, |deck, cx| deck.preview_transition(cx)); + settle(&cx, |app| deck.read(app).preview.is_some()); + update(&mut cx, &deck, |deck, cx| { + let tab = deck.core.active; + deck.run_in(tab, "slide.insert", &[], cx); + deck.force_reread(tab.expect("a tab"), cx); + }); + settle(&cx, |app| { + deck.read(app) + .active_tab() + .is_some_and(|tab| tab.extra.slides.current == 1) + }); + update(&mut cx, &deck, |deck, cx| deck.drop_stale_preview(cx)); + assert!( + cx.update(|app| deck.read(app).preview.is_none()), + "another slide ends it" + ); +} + +#[test] +fn typing_ends_a_preview_before_any_undo_step() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + act(&mut cx, &deck, ("object.select", &["0", "false"]), |o| { + o.selection.is_some() + }); + let view = cx.update(|app| deck.read(app).active_tab().expect("a tab").view.clone()); + let typed = |cx: &mut TestAppContext, text: &str| { + let before = cx.update(|app| view.read(app).edited()); + cx.update(|app| view.update(app, |view, _| view.insert(text))); + settle(cx, |app| { + view.read(app).edited() > before && view.read(app).settled() + }); + }; + typed(&mut cx, "A"); + let ready = |app: &gpui::App| { + let tab = deck.read(app).active_tab(); + tab.is_some_and(|tab| tab.extra.motion.is_some() && tab.extra.history.is_some()) + }; + settle(&cx, ready); + update(&mut cx, &deck, |deck, cx| deck.preview_transition(cx)); + settle(&cx, |app| deck.read(app).preview.is_some()); + let undo = |app: &mut gpui::App| deck.read(app).active_tab()?.extra.history.clone(); + let steps = cx.update(undo); + typed(&mut cx, "B"); + assert_eq!(cx.update(undo), steps, "no undo step yet"); + update(&mut cx, &deck, |deck, cx| deck.drop_stale_preview(cx)); + assert!( + cx.update(|app| deck.read(app).preview.is_none()), + "the typed text ends it" + ); +} + +#[test] +fn enter_then_blur_sends_one_duration() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let motion = |app: &gpui::App| { + deck.read(app) + .active_tab() + .and_then(|tab| tab.extra.motion.clone()) + }; + settle(&cx, |app| motion(app).is_some()); + let panel = cx.update(|app| { + let weak = deck.downgrade(); + app.new(|cx| crate::design::Panel::new(weak, cx)) + }); + let now = cx.update(|app| motion(app).expect("read").0); + let send = crate::motion::dedupe(panel, "transition.duration", now.duration, |deck, n, cx| { + deck.send_transition(|t| t.duration = n, false, cx) + }); + let steps = super::undo_steps(&mut cx, &deck); + let handle = cx + .update(|app| deck.read(app).core.window) + .expect("a window"); + for _ in 0..2 { + cx.update_window(handle, |_, window, app| send(3.5, window, app)) + .expect("the window lives"); + } + settle(&cx, |app| { + motion(app).is_some_and(|(t, _)| t.duration == 3.5) + }); + assert_eq!( + super::undo_steps(&mut cx, &deck), + steps + 1, + "one step for Enter and blur" + ); +} + +#[test] +fn two_fields_sent_before_a_read_both_land() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let motion = |app: &gpui::App| { + deck.read(app) + .active_tab() + .and_then(|tab| tab.extra.motion.clone()) + }; + settle(&cx, |app| motion(app).is_some()); + update(&mut cx, &deck, |deck, cx| { + deck.send_transition( + |t| (t.auto, t.duration, t.time) = (true, 2.0, 4.0), + false, + cx, + ) + }); + settle(&cx, |app| { + motion(app).is_some_and(|(t, _)| t.auto && t.time == 4.0) + }); + let panel = cx.update(|app| { + let weak = deck.downgrade(); + app.new(|cx| crate::design::Panel::new(weak, cx)) + }); + let duration = + crate::motion::dedupe(panel.clone(), "transition.duration", 2.0, |deck, n, cx| { + deck.send_transition(|t| t.duration = n, false, cx) + }); + let time = crate::motion::dedupe(panel, "transition.time", 4.0, |deck, n, cx| { + deck.send_transition(|t| t.time = n, false, cx) + }); + let handle = cx + .update(|app| deck.read(app).core.window) + .expect("a window"); + cx.update_window(handle, |_, window, app| { + duration(3.5, window, app); + time(7.0, window, app); + }) + .expect("the window lives"); + settle(&cx, |app| { + let asked = deck + .read(app) + .active_tab() + .map(|t| t.extra.transition_asked.is_none()); + asked == Some(true) && motion(app).is_some_and(|(t, _)| t.time == 7.0) + }); + let read = cx.update(|app| motion(app).expect("read").0); + assert_eq!((read.duration, read.time), (3.5, 7.0)); +} + +#[test] +fn an_undone_duration_is_not_sent_again() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let motion = |app: &gpui::App| { + deck.read(app) + .active_tab() + .and_then(|tab| tab.extra.motion.clone()) + }; + let asked = |app: &gpui::App| { + let tab = deck.read(app).active_tab(); + tab.is_some_and(|tab| tab.extra.transition_asked.is_some()) + }; + settle(&cx, |app| motion(app).is_some()); + update(&mut cx, &deck, |deck, cx| { + deck.send_transition( + |t| (t.auto, t.duration, t.time) = (true, 2.0, 4.0), + false, + cx, + ) + }); + settle(&cx, |app| { + !asked(app) && motion(app).is_some_and(|(t, _)| t.time == 4.0) + }); + update(&mut cx, &deck, |deck, cx| { + deck.send_transition(|t| t.duration = 3.5, false, cx); + let tab = deck.core.active; + deck.run_in(tab, "edit.undo", &[], cx); + deck.force_reread(tab.expect("a tab"), cx); + }); + settle(&cx, |app| !asked(app)); + update(&mut cx, &deck, |deck, cx| { + deck.send_transition(|t| t.time = 7.0, false, cx) + }); + settle(&cx, |app| { + !asked(app) && motion(app).is_some_and(|(t, _)| t.time == 7.0) + }); + let read = cx.update(|app| motion(app).expect("read").0); + assert_eq!((read.duration, read.time), (2.0, 7.0)); +} + +#[test] +fn two_effects_take_the_same_value_before_a_read() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let effects = |app: &gpui::App| { + deck.read(app) + .active_tab() + .and_then(|tab| tab.extra.motion.clone()) + .map(|(_, effects)| effects) + .unwrap_or_default() + }; + act( + &mut cx, + &deck, + ("draw.shape", &["rectangle", "true"]), + |o| o.selection.is_some(), + ); + let add = serde_json::json!({ "preset": "ooo-entrance-fade-in" }).to_string(); + for count in 1..=2 { + update(&mut cx, &deck, |deck, cx| { + deck.design_command("animation.add", &[&add], cx) + }); + settle(&cx, |app| effects(app).len() == count); + } + for at in 0..2 { + update(&mut cx, &deck, |deck, cx| { + deck.send_effect(at, serde_json::json!({ "duration": 2.0 }), cx) + }); + } + settle(&cx, |app| effects(app).iter().all(|e| e.duration == 2.0)); + let panel = cx.update(|app| { + let weak = deck.downgrade(); + app.new(|cx| crate::design::Panel::new(weak, cx)) + }); + let (slide, listed) = cx.update(|app| { + let tab = deck.read(app).active_tab().expect("a tab"); + let slide = tab.extra.slides.list[tab.extra.slides.current].name.clone(); + (slide, effects(app)) + }); + let handle = cx + .update(|app| deck.read(app).core.window) + .expect("a window"); + let steps = super::undo_steps(&mut cx, &deck); + cx.update_window(handle, |_, window, app| { + for (at, effect) in listed.iter().enumerate() { + let key = crate::motion::effect_key("duration", &slide, (at, effect)); + let send = crate::motion::dedupe(panel.clone(), key, 2.0, move |deck, n, cx| { + deck.send_effect(at, serde_json::json!({ "duration": n }), cx) + }); + // Enter, then blur. + send(3.5, window, app); + send(3.5, window, app); + } + }) + .expect("the window lives"); + settle(&cx, |app| effects(app).iter().all(|e| e.duration == 3.5)); + assert_eq!( + super::undo_steps(&mut cx, &deck), + steps + 2, + "one step each" + ); +} diff --git a/apps/ely-show/src/tests/objects.rs b/apps/ely-show/src/tests/objects.rs new file mode 100644 index 0000000..0aa6285 --- /dev/null +++ b/apps/ely-show/src/tests/objects.rs @@ -0,0 +1,293 @@ +use ely_lok::show::{GuideKind, Objects, Paint, Selection}; +use ely_office::prelude::*; +use gpui::{Entity, TestAppContext}; + +use super::{counts, deck, pictures_match, settle, undo_steps, update}; +use crate::deck::Deck; + +pub(super) fn objects(app: &gpui::App, deck: &Entity) -> Option { + deck.read(app).active_tab()?.extra.objects.clone() +} + +pub(super) fn selection(app: &gpui::App, deck: &Entity) -> Option { + objects(app, deck)?.selection +} + +/// A blank deck, its first picture painted. +pub(super) fn blank(cx: &mut TestAppContext, dir: &std::path::Path) -> Option> { + let deck = deck(cx, dir)?; + update(cx, &deck, |deck, cx| deck.new_blank(cx)); + settle(cx, |app| counts(app, &deck) == (1, 1)); + Some(deck) +} + +/// Runs `id` and waits until `done` holds. +pub(super) fn act( + cx: &mut TestAppContext, + deck: &Entity, + (id, values): (&str, &[&str]), + done: impl Fn(&Objects) -> bool, +) { + update(cx, deck, |deck, cx| deck.object_command(id, values, cx)); + settle(cx, |app| { + objects(app, deck).is_some_and(|objects| done(&objects)) + }); +} + +#[test] +fn shapes_take_place_look_and_lock_from_the_panel() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let rectangle = ("draw.shape", &["rectangle", "true"][..]); + act(&mut cx, &deck, rectangle, |o| { + o.selection.as_ref().is_some_and(|s| s.kind == "shape") + }); + let x = crate::object::twips(3000).to_string(); + act(&mut cx, &deck, ("object.x", &[&x]), |o| { + o.selection + .as_ref() + .is_some_and(|s| (s.rect.0 - 3000).abs() <= 1) + }); + act(&mut cx, &deck, ("object.fill_none", &["0"]), |o| { + o.selection.as_ref().is_some_and(|s| s.fill == Paint::None) + }); + act(&mut cx, &deck, ("object.fill", &["255"]), |o| { + o.selection + .as_ref() + .is_some_and(|s| s.fill == Paint::Color(255)) + }); + act(&mut cx, &deck, ("object.lock", &["true", "true"]), |o| { + o.selection + .as_ref() + .is_some_and(|s| s.lock_position == Some(true)) + }); + let center = cx + .update(|app| selection(app, &deck)) + .expect("a selection") + .rotation_center; + let twips = crate::object::twips; + let (x, y) = (twips(center.0).to_string(), twips(center.1).to_string()); + act(&mut cx, &deck, ("object.lock", &["false", "false"]), |o| { + o.selection + .as_ref() + .is_some_and(|s| s.lock_size == Some(false)) + }); + act(&mut cx, &deck, ("object.rotate", &["9000", &x, &y]), |o| { + o.selection.as_ref().is_some_and(|s| s.rotation == 9000) + }); + // A panel edit repaints the slide's picture. + pictures_match(&mut cx, &deck); +} + +#[test] +fn two_objects_group_align_and_ungroup() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let count = |n: usize| move |o: &Objects| o.objects.len() == n; + act( + &mut cx, + &deck, + ("draw.shape", &["rectangle", "true"]), + count(3), + ); + let roles = cx.update(|app| { + let read = objects(app, &deck).expect("a read"); + read.objects + .iter() + .map(|o| o.placeholder.clone()) + .collect::>() + }); + assert_eq!(roles, ["title", "body", ""], "placeholders name their role"); + act( + &mut cx, + &deck, + ("draw.shape", &["ellipse", "true"]), + count(4), + ); + update(&mut cx, &deck, |deck, cx| { + deck.select_objects(&["2".into(), "3".into()], cx) + }); + settle(&cx, |app| { + selection(app, &deck).is_some_and(|s| s.count == 2 && s.can_group) + }); + act(&mut cx, &deck, ("object.align_left", &[]), |o| { + o.objects[2].rect.0 == o.objects[3].rect.0 + }); + act(&mut cx, &deck, ("object.group", &[]), |o| { + o.selection + .as_ref() + .is_some_and(|s| s.kind == "group" && s.can_ungroup) + }); + act(&mut cx, &deck, ("object.ungroup", &[]), count(4)); +} + +#[test] +fn guides_add_move_and_go() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + act(&mut cx, &deck, ("guide.add", &["1", "5000", "0"]), |o| { + o.guides.len() == 1 && o.guides[0].kind == GuideKind::Vertical + }); + act(&mut cx, &deck, ("guide.move", &["0", "7000", "0"]), |o| { + o.guides.first().is_some_and(|guide| guide.at.0 == 7000) + }); + // Snapping starts on; the command flips it. + let snapped = cx.update(|app| objects(app, &deck).expect("a read").snap_guides); + act(&mut cx, &deck, ("view.snap_guides", &[]), move |o| o.snap_guides != snapped); + act(&mut cx, &deck, ("guide.delete", &["0", "true"]), |o| { + o.guides.is_empty() + }); +} + +#[test] +fn autofit_moves_between_its_three_rules() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let fit = |rule: &'static str| { + move |o: &Objects| o.selection.as_ref().is_some_and(|s| s.autofit == rule) + }; + act(&mut cx, &deck, ("draw.text", &["true"]), |o| { + o.selection.as_ref().is_some_and(|s| s.kind == "text") + }); + let now = cx + .update(|app| selection(app, &deck)) + .expect("a box") + .autofit; + assert_eq!(now, "grow", "a new text box grows with its text"); + act(&mut cx, &deck, ("text.fit", &["off"]), fit("off")); + act(&mut cx, &deck, ("text.fit", &["shrink"]), fit("shrink")); + act(&mut cx, &deck, ("text.fit", &["grow"]), fit("grow")); +} + +#[test] +fn a_repeated_commit_sends_once() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + act( + &mut cx, + &deck, + ("draw.shape", &["rectangle", "true"]), + |o| o.selection.is_some(), + ); + let before = undo_steps(&mut cx, &deck); + let (panel, now) = cx.update(|app| { + let deck = deck.read(app); + let rect = deck.active_tab().and_then(|tab| tab.extra.objects.clone()); + ( + deck.panel.clone(), + rect.and_then(|o| o.selection).expect("a selection").rect.0, + ) + }); + // Enter, then the blur that follows it. + for _ in 0..2 { + cx.update(|app| { + crate::object::commit( + &panel, + "x", + (now + 1000, Some(now)), + move |deck, cx| { + let x = crate::object::twips(now + 1000).to_string(); + deck.object_command("object.x", &[&x], cx) + }, + app, + ) + }); + } + settle(&cx, |app| { + selection(app, &deck).is_some_and(|s| (s.rect.0 - now - 1000).abs() <= 1) + }); + assert_eq!(undo_steps(&mut cx, &deck), before + 1); +} + +#[test] +fn pictures_insert_replace_and_crop() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let png = |name: &str, rgba| { + let path = dir.path().join(name); + image::RgbaImage::from_pixel(300, 200, image::Rgba(rgba)) + .save(&path) + .expect("the png writes"); + ely_lok::file_url(&path) + }; + let (red, blue) = ( + png("red.png", [255, 0, 0, 255]), + png("blue.png", [0, 0, 255, 255]), + ); + let picture = |o: &Objects| o.selection.as_ref().is_some_and(|s| s.kind == "image"); + act(&mut cx, &deck, ("image.insert", &[&red]), picture); + let steps = undo_steps(&mut cx, &deck); + act(&mut cx, &deck, ("image.replace", &[&blue]), |o| { + o.objects.len() == 3 + }); + settle(&cx, |app| counts(app, &deck) == (1, 1)); + assert_eq!( + undo_steps(&mut cx, &deck), + steps + 1, + "a replace is one step" + ); + // The worker confirms crop mode from the kernel's frame. + cx.update(|app| { + let deck = deck.read(app); + let doc = deck.active_tab().expect("a tab").doc.clone(); + let mut worker = deck.core.worker.lock().expect("the worker lock"); + worker.run(&doc, "image.crop", &[]).expect("crop mode"); + }); +} + +#[test] +fn a_text_box_takes_character_formats() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + act(&mut cx, &deck, ("draw.text", &["true"]), |o| { + o.selection.as_ref().is_some_and(|s| s.kind == "text") + }); + let state = |uno: &'static str, want: &'static str| { + let deck = deck.clone(); + move |app: &gpui::App| { + deck.read(app) + .active_tab() + .is_some_and(|tab| tab.view.read(app).state(uno) == Some(want)) + } + }; + update(&mut cx, &deck, |deck, cx| { + deck.object_command("format.bold", &[], cx) + }); + settle(&cx, state(".uno:Bold", "true")); + update(&mut cx, &deck, |deck, cx| { + deck.object_command("format.size", &["28"], cx) + }); + settle(&cx, state(".uno:FontHeight", "28")); + update(&mut cx, &deck, |deck, cx| { + deck.object_command("list.bullets", &[], cx) + }); + settle(&cx, state(".uno:DefaultBullet", "true")); +} diff --git a/apps/ely-show/src/tests/output.rs b/apps/ely-show/src/tests/output.rs new file mode 100644 index 0000000..998b3e2 --- /dev/null +++ b/apps/ely-show/src/tests/output.rs @@ -0,0 +1,251 @@ +//! PDF, pictures and handouts from the deck (C10.6). + +use std::path::Path; + +use ely_office::prelude::*; +use gpui::{AppContext as _, Entity, TestAppContext}; + +use super::objects::blank; +use super::play::three_slides; +use super::{settle, undo_steps, update}; +use crate::deck::Deck; +use crate::output::{Kind, Output, Range}; + +fn output(cx: &mut TestAppContext, deck: &Entity, kind: Kind, range: Range) -> Output { + cx.update(|app| { + let tab = deck.read(app).active_tab().expect("a tab"); + Output { + tab: tab.id, + print: false, + kind, + range, + per: 2, + current: Some(1), + hidden: tab.extra.slides.list.iter().map(|slide| !slide.visible).collect(), + } + }) +} + +/// Pages of a PDF, by pdftotext's page breaks. +fn pages(pdf: &Path) -> Option { + let out = std::process::Command::new("pdftotext").arg(pdf).arg("-").output().ok()?; + let text = String::from_utf8_lossy(&out.stdout).into_owned(); + Some(text.split('\x0c').count() - 1) +} + +#[test] +fn slides_and_handouts_go_out_and_the_deck_stays_put() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + let steps = undo_steps(&mut cx, &deck); + let (slides, handouts) = (dir.path().join("slides.pdf"), dir.path().join("handouts.pdf")); + let asked = output(&mut cx, &deck, Kind::Slides, Range::All); + update(&mut cx, &deck, |deck, cx| deck.export_pdf(&asked, slides.clone(), cx)); + let asked = output(&mut cx, &deck, Kind::Handouts, Range::Slides(1, 2)); + update(&mut cx, &deck, |deck, cx| deck.export_pdf(&asked, handouts.clone(), cx)); + settle(&cx, |_| slides.exists() && handouts.exists()); + assert_eq!(undo_steps(&mut cx, &deck), steps, "the deck is not touched"); + let (Some(slides), Some(handouts)) = (pages(&slides), pages(&handouts)) else { + eprintln!("SKIP: no pdftotext to count pages"); + return; + }; + assert_eq!(slides, 2, "the hidden slide stays out"); + assert_eq!(handouts, 1, "two slides on one handout page"); +} + +#[test] +fn pictures_are_one_png_a_shown_slide_and_never_overwrite() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + let folder = dir.path().join("out"); + std::fs::create_dir_all(&folder).expect("a folder"); + let asked = output(&mut cx, &deck, Kind::Pictures, Range::All); + let into = (folder.clone(), "deck".to_string()); + update(&mut cx, &deck, |deck, cx| deck.write_pictures(&asked, into, cx)); + let written = |folder: &Path| { + let mut names: Vec = std::fs::read_dir(folder) + .expect("reads") + .map(|entry| entry.expect("an entry").file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names + }; + settle(&cx, |_| written(&folder).len() == 2); + assert_eq!(written(&folder), ["deck-1.png", "deck-2.png"], "slide three is hidden"); + let picture = image::open(folder.join("deck-1.png")).expect("a PNG"); + assert_eq!(picture.width(), 1920); + let before = std::fs::read(folder.join("deck-1.png")).expect("reads"); + let asked = output(&mut cx, &deck, Kind::Pictures, Range::Current); + let into = (folder.clone(), "deck".to_string()); + update(&mut cx, &deck, |deck, cx| deck.write_pictures(&asked, into, cx)); + for _ in 0..50 { + cx.run_until_parked(); + } + assert_eq!(std::fs::read(folder.join("deck-1.png")).expect("reads"), before, "never overwritten"); +} + +#[test] +fn the_check_lists_font_link_and_object_with_their_slides() { + use crate::check::Problem; + use crate::dialog::Dialog; + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = super::deck(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/show/trouble.fodp"); + let path = dir.path().join("trouble.fodp"); + std::fs::copy(&fixture, &path).expect("copies"); + update(&mut cx, &deck, |deck, cx| deck.open_file(path.clone(), cx)); + settle(&cx, |app| { + let tab = deck.read(app).active_tab(); + tab.is_some_and(|tab| !tab.view.read(app).missing_fonts().is_empty()) + }); + update(&mut cx, &deck, |deck, cx| deck.check_show(cx)); + settle(&cx, |app| matches!(deck.read(app).dialog, Some(Dialog::Check(_)))); + let found = cx.update(|app| match &deck.read(app).dialog { + Some(Dialog::Check(found)) => found.clone(), + _ => unreachable!("settled above"), + }); + let short: Vec<(Problem, &str, Vec)> = + found.iter().map(|f| (f.problem, f.name.as_str(), f.slides.clone())).collect(); + assert!(short.contains(&(Problem::Font, "Nowhere Sans", vec![0])), "{short:?}"); + assert!(short.contains(&(Problem::Link, "file:///nowhere/gone.png", vec![1])), "{short:?}"); + assert!(short.contains(&(Problem::Object, "Empty", vec![2])), "{short:?}"); +} + +#[cfg(unix)] +#[test] +fn pictures_claim_every_name_first_and_follow_no_link() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + let folder = dir.path().join("out"); + std::fs::create_dir_all(&folder).expect("a folder"); + // The second name links to a file not there. + std::os::unix::fs::symlink("../unrelated.png", folder.join("deck-2.png")).expect("links"); + let asked = output(&mut cx, &deck, Kind::Pictures, Range::All); + let into = (folder.clone(), "deck".to_string()); + update(&mut cx, &deck, |deck, cx| deck.write_pictures(&asked, into, cx)); + for _ in 0..200 { + cx.run_until_parked(); + } + assert!(!dir.path().join("unrelated.png").exists(), "the link is not followed"); + assert!(!folder.join("deck-1.png").exists(), "nothing of this run stays"); +} + +#[test] +fn master_view_exports_slides_and_has_no_current() { + use crate::dialog::Dialog; + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + update(&mut cx, &deck, |deck, cx| deck.masters(true, cx)); + settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| tab.extra.masters)); + let window = cx.update(|app| deck.read(app).core.window.expect("a window")); + cx.update_window(window, |_, window, app| { + deck.update(app, |deck, cx| deck.on_export(&ely_ui::commands::Export, window, cx)) + }) + .expect("the window lives"); + settle(&cx, |app| matches!(deck.read(app).dialog, Some(Dialog::Output(_)))); + let (current, hidden) = cx.update(|app| match &deck.read(app).dialog { + Some(Dialog::Output(output)) => (output.current, output.hidden.clone()), + _ => unreachable!("settled above"), + }); + assert_eq!((current, hidden), (None, vec![false, false, true])); +} + +#[test] +fn a_late_check_never_covers_a_newer_dialog() { + use crate::dialog::Dialog; + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + // The export dialog is open before the check comes back. + let open = output(&mut cx, &deck, Kind::Slides, Range::All); + update(&mut cx, &deck, |deck, cx| { + deck.check_show(cx); + deck.dialog = Some(Dialog::Output(open)); + }); + for _ in 0..200 { + cx.run_until_parked(); + } + assert!(cx.update(|app| matches!(deck.read(app).dialog, Some(Dialog::Output(_))))); +} + +#[test] +fn a_late_check_stays_away_after_another_dialog_closes() { + use crate::dialog::Dialog; + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let open = output(&mut cx, &deck, Kind::Slides, Range::All); + update(&mut cx, &deck, |deck, cx| { + deck.check_show(cx); + deck.dialog = Some(Dialog::Output(open)); + cx.notify(); + }); + // The other dialog is cancelled before the check returns. + update(&mut cx, &deck, |deck, cx| { + deck.dialog = None; + cx.notify(); + }); + for _ in 0..200 { + cx.run_until_parked(); + } + assert!(cx.update(|app| deck.read(app).dialog.is_none()), "the old check stays away"); +} + +#[test] +fn pictures_follow_the_slides_as_written_not_as_asked() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + // Asked while slide three hid; shown again before writing. + let asked = output(&mut cx, &deck, Kind::Pictures, Range::All); + assert_eq!(asked.hidden, [false, false, true]); + update(&mut cx, &deck, |deck, cx| deck.design_command("slide.show", &[], cx)); + settle(&cx, |app| { + let tab = deck.read(app).active_tab(); + tab.is_some_and(|tab| tab.extra.slides.list.iter().all(|slide| slide.visible)) + }); + let folder = dir.path().join("out"); + std::fs::create_dir_all(&folder).expect("a folder"); + let into = (folder.clone(), "deck".to_string()); + update(&mut cx, &deck, |deck, cx| deck.write_pictures(&asked, into, cx)); + let count = |folder: &Path| std::fs::read_dir(folder).expect("reads").count(); + settle(&cx, |_| count(&folder) == 3); + for n in 1..=3 { + let picture = image::open(folder.join(format!("deck-{n}.png"))).expect("a readable PNG"); + assert_eq!(picture.width(), 1920); + } +} diff --git a/apps/ely-show/src/tests/play.rs b/apps/ely-show/src/tests/play.rs new file mode 100644 index 0000000..9697034 --- /dev/null +++ b/apps/ely-show/src/tests/play.rs @@ -0,0 +1,147 @@ +//! The show: keys, hidden slides, black, laser (C10.5). + +use ely_office::prelude::*; +use gpui::{Entity, Keystroke, TestAppContext}; + +use super::objects::{act, blank}; +use super::{settle, update}; +use crate::deck::Deck; +use crate::slideshow::{Show, steps::Place}; + +fn press(cx: &mut TestAppContext, show: &Entity, key: &str) -> bool { + let key = Keystroke::parse(key).expect("a key"); + cx.update(|app| show.update(app, |show, cx| show.key(&key, cx))) +} + +/// Keys typed into the show's own window. +pub(super) fn typed(cx: &mut TestAppContext, show: &Entity, keys: &str) { + let window = cx.update(|app| show.read(app).windows[0]); + cx.simulate_keystrokes(window, keys); +} + +pub(super) fn place(cx: &mut TestAppContext, show: &Entity) -> Place { + cx.update(|app| show.read(app).place) +} + +/// Slide two: click effect, notes. Three: hidden. +pub(super) fn three_slides(cx: &mut TestAppContext, deck: &Entity) { + let slides = |app: &gpui::App| { + let tab = deck.read(app).active_tab(); + tab.map(|tab| tab.extra.slides.list.iter().map(|s| s.visible).collect::>()) + }; + update(cx, deck, |deck, cx| deck.design_command("slide.insert", &[], cx)); + settle(cx, |app| slides(app).is_some_and(|list| list.len() == 2)); + act(cx, deck, ("draw.shape", &["rectangle", "true"]), |o| { + o.objects.len() == 3 + }); + act(cx, deck, ("object.select", &["2", "false"]), |o| { + o.selection.is_some() + }); + let add = serde_json::json!({ "preset": "ooo-entrance-fade-in" }).to_string(); + let effects = |app: &gpui::App| { + let tab = deck.read(app).active_tab(); + tab.and_then(|tab| tab.extra.motion.as_ref().map(|(_, e)| e.len())) + }; + update(cx, deck, |deck, cx| deck.design_command("animation.add", &[&add], cx)); + settle(cx, |app| effects(app) == Some(1)); + update(cx, deck, |deck, cx| { + deck.design_command("notes.set", &["Say hello", "1"], cx); + deck.design_command("slide.insert", &[], cx); + }); + settle(cx, |app| slides(app).is_some_and(|list| list.len() == 3)); + update(cx, deck, |deck, cx| deck.design_command("slide.hide", &[], cx)); + settle(cx, |app| slides(app) == Some(vec![true, true, false])); +} + +#[test] +fn a_show_plays_clicks_skips_hidden_and_ends() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + update(&mut cx, &deck, |deck, cx| deck.start_show(false, cx)); + settle(&cx, |app| deck.read(app).show.is_some()); + let show = cx.update(|app| deck.read(app).show.clone().expect("playing")); + settle(&cx, |app| { + let show = show.read(app); + show.stack(0).is_some() && show.stack(1).is_some() + }); + let windows = cx.update(|app| show.read(app).windows.len()); + assert_eq!(windows, 1, "one screen: no presenter view"); + assert_eq!(place(&mut cx, &show), Place::Slide { part: 0, clicks: 0 }); + typed(&mut cx, &show, "right"); + assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 0 }); + let notes = cx.update(|app| show.read(app).slides[1].notes.clone()); + assert_eq!(notes, "Say hello"); + press(&mut cx, &show, "space"); + assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 1 }); + press(&mut cx, &show, "right"); + assert_eq!(place(&mut cx, &show), Place::End, "the hidden slide is passed"); + press(&mut cx, &show, "left"); + assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 1 }); + press(&mut cx, &show, "1"); + press(&mut cx, &show, "enter"); + assert_eq!(place(&mut cx, &show), Place::Slide { part: 0, clicks: 0 }); + press(&mut cx, &show, "b"); + press(&mut cx, &show, "l"); + assert!(cx.update(|app| show.read(app).black && show.read(app).laser)); + assert!(!press(&mut cx, &show, "cmd-q"), "app keys pass through"); + let before = cx.update(|app| app.windows().len()); + typed(&mut cx, &show, "escape"); + settle(&cx, |app| deck.read(app).show.is_none()); + assert_eq!(cx.update(|app| app.windows().len()), before - 1, "the show window closes"); +} + +#[test] +fn from_here_starts_on_the_shown_slide_even_hidden() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + update(&mut cx, &deck, |deck, cx| deck.start_show(true, cx)); + settle(&cx, |app| deck.read(app).show.is_some()); + let show = cx.update(|app| deck.read(app).show.clone().expect("playing")); + assert_eq!(place(&mut cx, &show), Place::Slide { part: 2, clicks: 0 }); + press(&mut cx, &show, "right"); + assert_eq!(place(&mut cx, &show), Place::End); + press(&mut cx, &show, "right"); + settle(&cx, |app| deck.read(app).show.is_none()); +} + +#[test] +fn a_click_on_media_finds_it_and_elsewhere_does_not() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/media/clip.mp4"); + let url = ely_lok::file_url(&clip); + update(&mut cx, &deck, |deck, cx| { + deck.design_command("media.insert", &[&url, "false", "8000", "4500"], cx) + }); + settle(&cx, |app| { + deck.read(app).active_tab().is_some_and(|tab| tab.extra.media.len() == 1) + }); + update(&mut cx, &deck, |deck, cx| deck.start_show(true, cx)); + settle(&cx, |app| deck.read(app).show.is_some()); + let show = cx.update(|app| deck.read(app).show.clone().expect("playing")); + let (bounds, size) = cx.update(|app| { + let show = show.read(app); + (show.slides[0].media[0].bounds, show.slide) + }); + let centre = ( + (bounds.0 + bounds.2 / 2) as f32 / size.0, + (bounds.1 + bounds.3 / 2) as f32 / size.1, + ); + assert_eq!(cx.update(|app| show.read(app).media_at(centre)), Some(0)); + assert_eq!(cx.update(|app| show.read(app).media_at((0.01, 0.01))), None); +} diff --git a/apps/ely-show/src/tests/play_more.rs b/apps/ely-show/src/tests/play_more.rs new file mode 100644 index 0000000..aa82cf2 --- /dev/null +++ b/apps/ely-show/src/tests/play_more.rs @@ -0,0 +1,211 @@ +//! Show starts, copies, windows and timing (C10.5 R1). + +use ely_office::prelude::*; +use gpui::{Entity, TestAppContext}; + +use super::objects::{act, blank}; +use super::play::{place, three_slides, typed}; +use super::{settle, update}; +use crate::deck::Deck; +use crate::preview::timeline::{Look, object_look}; +use crate::slideshow::{Show, start::show_window, steps::Place}; + +fn started(cx: &mut TestAppContext, deck: &Entity, here: bool) -> Entity { + update(cx, deck, |deck, cx| deck.start_show(here, cx)); + settle(cx, |app| deck.read(app).show.is_some()); + cx.update(|app| deck.read(app).show.clone().expect("playing")) +} + +fn bytes(cx: &mut TestAppContext, show: &Entity, part: usize) -> Vec { + cx.update(|app| { + let stack = show.read(app).stack(part).expect("read").clone(); + stack.iter().flat_map(|(_, image)| image.as_bytes(0).expect("a frame").to_vec()).collect() + }) +} + +#[test] +fn both_windows_take_keys_and_a_second_start_waits() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + update(&mut cx, &deck, |deck, cx| { + deck.start_show(false, cx); + deck.start_show(false, cx); + }); + settle(&cx, |app| deck.read(app).show.is_some()); + let show = cx.update(|app| deck.read(app).show.clone().expect("playing")); + let before = cx.update(|app| app.windows().len()); + for _ in 0..50 { + cx.run_until_parked(); + } + assert_eq!(cx.update(|app| app.windows().len()), before, "one start, one window"); + // A presenter window beside the audience, as on two screens. + let presenter = cx.update(|app| { + let display = app.primary_display().expect("a screen"); + show_window(&show, &display, true, app).expect("opens") + }); + cx.update(|app| show.update(app, |show, _| show.windows.push(presenter))); + cx.simulate_keystrokes(presenter, "right"); + assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 0 }); + typed(&mut cx, &show, "right"); + assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 1 }); +} + +#[test] +fn from_here_follows_a_slide_picked_just_now() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + update(&mut cx, &deck, |deck, cx| { + let view = deck.active_tab().expect("a tab").view.clone(); + view.update(cx, |view, _| view.show_part(1)); + deck.start_show(true, cx); + }); + settle(&cx, |app| deck.read(app).show.is_some()); + let show = cx.update(|app| deck.read(app).show.clone().expect("playing")); + assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 0 }); +} + +#[test] +fn edits_during_a_show_stay_out_of_it() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + let show = started(&mut cx, &deck, true); + settle(&cx, |app| show.read(app).stack(2).is_some()); + let first = bytes(&mut cx, &show, 2); + act(&mut cx, &deck, ("draw.shape", &["ellipse", "true"]), |o| !o.objects.is_empty()); + typed(&mut cx, &show, "1 enter"); + settle(&cx, |app| show.read(app).stack(2).is_none()); + typed(&mut cx, &show, "3 enter"); + settle(&cx, |app| show.read(app).stack(2).is_some()); + assert_eq!(bytes(&mut cx, &show, 2), first, "the show plays its copy"); +} + +#[test] +fn late_layers_and_jumps_leave_no_trace() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + let fade = r#"{"preset":"fade-smoothly","duration":10,"advance":"click","time":0,"all":false}"#; + update(&mut cx, &deck, |deck, cx| { + let view = deck.active_tab().expect("a tab").view.clone(); + view.update(cx, |view, _| view.show_part(1)); + deck.design_command("slide.transition", &[fade], cx); + }); + settle(&cx, |app| { + let tab = deck.read(app).active_tab(); + tab.and_then(|t| t.extra.motion.as_ref()).is_some_and(|(t, _)| t.duration == 10.0) + }); + let show = started(&mut cx, &deck, false); + settle(&cx, |app| { + let show = show.read(app); + show.stack(0).is_some() && show.stack(1).is_some() + }); + typed(&mut cx, &show, "right"); + assert!(cx.update(|app| show.read(app).leaving.is_some()), "it fades in"); + typed(&mut cx, &show, "2 enter"); + assert!(cx.update(|app| show.read(app).leaving.is_none()), "a jump has no transition"); + typed(&mut cx, &show, "3 enter 1 enter"); + settle(&cx, |app| { + let show = show.read(app); + show.stack(0).is_some() && show.stack(1).is_some() + }); + for _ in 0..100 { + cx.run_until_parked(); + } + assert!(cx.update(|app| show.read(app).stack(2).is_none()), "slide three came late"); +} + +#[test] +fn a_finished_exit_stays_gone_as_the_slide_leaves() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + let cover = r#"{"preset":"cover-left","duration":5,"advance":"click","time":0,"all":true}"#; + update(&mut cx, &deck, |deck, cx| deck.design_command("slide.transition", &[cover], cx)); + update(&mut cx, &deck, |deck, cx| { + let view = deck.active_tab().expect("a tab").view.clone(); + view.update(cx, |view, _| view.show_part(0)); + }); + settle(&cx, |app| { + let tab = deck.read(app).active_tab(); + tab.is_some_and(|t| t.extra.slides.current == 0 && t.extra.objects.is_some()) + }); + act(&mut cx, &deck, ("draw.shape", &["rectangle", "true"]), |o| o.objects.len() == 3); + act(&mut cx, &deck, ("object.select", &["2", "false"]), |o| { + o.selection.is_some() + }); + let exit = serde_json::json!({ "preset": "ooo-exit-disappear" }).to_string(); + update(&mut cx, &deck, |deck, cx| deck.design_command("animation.add", &[&exit], cx)); + settle(&cx, |app| { + let tab = deck.read(app).active_tab(); + tab.and_then(|t| t.extra.motion.as_ref()).is_some_and(|(_, e)| e.len() == 1) + }); + let show = started(&mut cx, &deck, false); + settle(&cx, |app| show.read(app).stack(0).is_some()); + typed(&mut cx, &show, "right"); + std::thread::sleep(std::time::Duration::from_millis(300)); + typed(&mut cx, &show, "right"); + let (look, hash) = cx.update(|app| { + let show = show.read(app); + let hash = show.slides[0].effects[0].hash.clone(); + let leaving = show.leaving.as_ref().expect("covered"); + (object_look(&leaving.runs, &hash, true, leaving.at, 0.5), hash) + }); + assert!(!hash.is_empty()); + assert_eq!(look, Look::HIDDEN, "the exit holds through the transition"); +} + +#[test] +fn media_that_cannot_play_says_why_and_the_show_goes_on() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/media/clip.mp4"); + let url = ely_lok::file_url(&clip); + update(&mut cx, &deck, |deck, cx| { + deck.design_command("media.insert", &[&url, "false", "8000", "4500"], cx) + }); + settle(&cx, |app| { + deck.read(app).active_tab().is_some_and(|tab| tab.extra.media.len() == 1) + }); + let show = started(&mut cx, &deck, true); + // Off the main thread the system player refuses. + cx.update(|app| show.update(app, |show, cx| show.toggle_media(0, cx))); + assert!(cx.update(|app| show.read(app).notice.is_some())); + assert!(cx.update(|app| deck.read(app).show.is_some()), "the show goes on"); + // A later failure says why the same way. + cx.update(|app| show.update(app, |show, _| show.notice = None)); + let seen = cx.update(|app| show.update(app, |show, cx| show.media_seen(Ok(true), cx))); + assert!(seen && cx.update(|app| show.read(app).notice.is_none())); + let failed = Err("Cannot Open".to_string()); + let seen = cx.update(|app| show.update(app, |show, cx| show.media_seen(failed, cx))); + let notice = cx.update(|app| show.read(app).notice.clone()); + assert!(!seen && notice.is_some_and(|n| n.contains("Cannot Open"))); + assert!(cx.update(|app| deck.read(app).show.is_some()), "the show goes on"); +} diff --git a/apps/ely-show/src/tests/play_time.rs b/apps/ely-show/src/tests/play_time.rs new file mode 100644 index 0000000..42107b2 --- /dev/null +++ b/apps/ely-show/src/tests/play_time.rs @@ -0,0 +1,129 @@ +//! The show's clock: back, late layers, released slides (C10.5 R2). + +use std::time::Duration; + +use ely_office::prelude::*; +use gpui::{Entity, TestAppContext}; + +use super::objects::{act, blank}; +use super::play::{place, three_slides, typed}; +use super::{settle, update}; +use crate::deck::Deck; +use crate::preview::timeline::{Look, object_look}; +use crate::slideshow::{Show, steps}; + +fn started(cx: &mut TestAppContext, deck: &Entity) -> Entity { + update(cx, deck, |deck, cx| deck.start_show(false, cx)); + settle(cx, |app| deck.read(app).show.is_some()); + let show = cx.update(|app| deck.read(app).show.clone().expect("playing")); + settle(cx, |app| { + let show = show.read(app); + show.stack(0).is_some() && show.stack(1).is_some() + }); + show +} + +/// Slide one: a rectangle fading in for ten seconds. +fn fading_first(cx: &mut TestAppContext, deck: &Entity) { + update(cx, deck, |deck, cx| { + let view = deck.active_tab().expect("a tab").view.clone(); + view.update(cx, |view, _| view.show_part(0)); + }); + settle(cx, |app| { + let tab = deck.read(app).active_tab(); + tab.is_some_and(|t| t.extra.slides.current == 0 && t.extra.objects.is_some()) + }); + act(cx, deck, ("draw.shape", &["rectangle", "true"]), |o| o.objects.len() == 3); + act(cx, deck, ("object.select", &["2", "false"]), |o| o.selection.is_some()); + let add = serde_json::json!({ "preset": "ooo-entrance-fade-in" }).to_string(); + update(cx, deck, |deck, cx| deck.design_command("animation.add", &[&add], cx)); + let effects = |app: &gpui::App| { + let tab = deck.read(app).active_tab(); + tab.and_then(|t| t.extra.motion.clone()).map(|(_, e)| e).unwrap_or_default() + }; + settle(cx, |app| effects(app).len() == 1); + update(cx, deck, |deck, cx| { + deck.send_effect(0, serde_json::json!({ "start": "with", "duration": 10.0 }), cx) + }); + settle(cx, |app| effects(app).first().is_some_and(|e| e.start == "with" && e.duration == 10.0)); +} + +#[test] +fn back_shows_a_slide_fully_built_even_without_clicks() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + fading_first(&mut cx, &deck); + let show = started(&mut cx, &deck); + typed(&mut cx, &show, "right left"); + assert_eq!(place(&mut cx, &show), steps::Place::Slide { part: 0, clicks: 0 }); + let (look, moving) = cx.update(|app| { + let show = show.read(app); + let effect = &show.slides[0].effects[0]; + let runs = steps::runs(&show.slides[0].effects, &show.clicks); + let t = show.entered.elapsed().as_secs_f64(); + (object_look(&runs, &effect.hash, false, t, 0.5), show.moving()) + }); + assert_eq!((look, moving), (Look::SHOWN, false), "nothing replays"); +} + +#[test] +fn a_slide_starts_its_clock_when_its_layers_come() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + let show = started(&mut cx, &deck); + // Another tab's read holds the worker a while. + let worker = cx.update(|app| deck.read(app).core.worker.clone()); + let (held, hold) = std::sync::mpsc::channel(); + let busy = std::thread::spawn(move || { + let _lock = worker.lock().expect("the worker lock"); + held.send(()).expect("told"); + std::thread::sleep(Duration::from_millis(400)); + }); + hold.recv().expect("held"); + // The read for slide three waits on the lock. + typed(&mut cx, &show, "3 enter"); + settle(&cx, |app| show.read(app).stack(2).is_some()); + busy.join().expect("let go"); + let (waiting, since) = cx.update(|app| { + let show = show.read(app); + (show.waiting, show.entered.elapsed()) + }); + assert!(!waiting); + assert!(since < Duration::from_millis(300), "the clock began late: {since:?}"); +} + +#[test] +fn a_finished_transition_lets_the_left_slide_go() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + three_slides(&mut cx, &deck); + let fade = r#"{"preset":"fade-smoothly","duration":0.1,"advance":"click","time":0,"all":true}"#; + update(&mut cx, &deck, |deck, cx| deck.design_command("slide.transition", &[fade], cx)); + settle(&cx, |app| { + let tab = deck.read(app).active_tab(); + tab.and_then(|t| t.extra.motion.as_ref()).is_some_and(|(t, _)| t.duration == 0.1) + }); + let show = started(&mut cx, &deck); + typed(&mut cx, &show, "right"); + assert!(cx.update(|app| show.read(app).leaving.is_some())); + settle(&cx, |app| show.read(app).leaving.is_none()); + let held = cx.update(|app| { + let show = show.read(app); + (0..3).filter(|at| show.stack(*at).is_some()).count() + }); + assert!(held <= 2, "only the shown and next slides stay"); +} diff --git a/apps/ely-show/src/tests/review.rs b/apps/ely-show/src/tests/review.rs new file mode 100644 index 0000000..45cbfae --- /dev/null +++ b/apps/ely-show/src/tests/review.rs @@ -0,0 +1,273 @@ +//! Regressions for the C10.2 review. + +use ely_lok::show::{Objects, Paint}; +use gpui::{Entity, TestAppContext}; + +use super::objects::{act, blank, objects, selection}; +use super::undo_steps; +use crate::deck::Deck; +use crate::object::{NO_FILL, NO_LINE, commit, twips}; + +/// Commits `value` for `field` the way a panel field does. +fn send( + cx: &mut TestAppContext, + deck: &Entity, + field: &'static str, + command: &'static str, + value: i64, + now: Option, +) { + let panel = cx.update(|app| deck.read(app).panel.clone()); + cx.update(|app| { + commit( + &panel, + field, + (value, now), + move |deck, cx| deck.object_command(command, &[&value.to_string()], cx), + app, + ) + }); +} + +#[test] +fn clear_buttons_clear_fill_and_outline() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + act( + &mut cx, + &deck, + ("draw.shape", &["rectangle", "true"]), + |o| o.selection.is_some(), + ); + for (_, command, value) in [NO_FILL, NO_LINE] { + update(&mut cx, &deck, command, value); + } + super::settle(&cx, |app| { + selection(app, &deck).is_some_and(|s| s.fill == Paint::None && s.line == Paint::None) + }); +} + +fn update(cx: &mut TestAppContext, deck: &Entity, command: &str, value: &str) { + super::update(cx, deck, |deck, cx| { + deck.object_command(command, &[value], cx) + }); +} + +#[test] +fn a_value_sent_again_after_undo_goes_out() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + act( + &mut cx, + &deck, + ("draw.shape", &["rectangle", "true"]), + |o| o.selection.is_some(), + ); + let see = |n: i64| { + move |o: &Objects| { + o.selection + .as_ref() + .is_some_and(|s| s.transparency == Some(n)) + } + }; + send( + &mut cx, + &deck, + "transparency", + "object.transparency", + 50, + Some(0), + ); + super::settle(&cx, |app| objects(app, &deck).as_ref().is_some_and(see(50))); + // Undo may drop the selection; pick the shape again. + super::update(&mut cx, &deck, |deck, cx| { + deck.object_command("edit.undo", &[], cx) + }); + super::update(&mut cx, &deck, |deck, cx| { + deck.select_objects(&["2".into()], cx) + }); + super::settle(&cx, |app| objects(app, &deck).as_ref().is_some_and(see(0))); + send( + &mut cx, + &deck, + "transparency", + "object.transparency", + 50, + Some(0), + ); + super::settle(&cx, |app| objects(app, &deck).as_ref().is_some_and(see(50))); +} + +#[test] +fn twin_objects_each_take_the_same_value() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let count = |n: usize| move |o: &Objects| o.objects.len() == n; + act( + &mut cx, + &deck, + ("draw.shape", &["rectangle", "true"]), + count(3), + ); + act( + &mut cx, + &deck, + ("draw.shape", &["rectangle", "true"]), + count(4), + ); + let x = |at: usize, n: i64| move |o: &Objects| (o.objects[at].rect.0 - n).abs() <= 1; + for at in [2, 3] { + super::update(&mut cx, &deck, move |deck, cx| { + deck.select_objects(&[at.to_string()], cx) + }); + super::settle(&cx, |app| { + objects(app, &deck).is_some_and(|o| o.objects[at].selected) + }); + let now = cx.update(|app| selection(app, &deck)).expect("one").rect.0; + let panel = cx.update(|app| deck.read(app).panel.clone()); + cx.update(|app| { + commit( + &panel, + "x", + (3000, Some(now)), + |deck, cx| deck.object_command("object.x", &[&twips(3000).to_string()], cx), + app, + ) + }); + super::settle(&cx, |app| { + objects(app, &deck).as_ref().is_some_and(x(at, 3000)) + }); + } +} + +#[test] +fn lines_take_an_outline_color_and_width() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + act(&mut cx, &deck, ("draw.line", &[]), |o| { + o.selection.as_ref().is_some_and(|s| s.kind == "line") + }); + act(&mut cx, &deck, ("object.line_color", &["16711680"]), |o| { + o.selection + .as_ref() + .is_some_and(|s| s.line == Paint::Color(0xFF0000)) + }); + act(&mut cx, &deck, ("object.line_width", &["176"]), |o| { + o.selection + .as_ref() + .is_some_and(|s| s.line_width == Some(176)) + }); +} + +#[test] +fn an_empty_list_choice_deselects() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + act( + &mut cx, + &deck, + ("draw.shape", &["rectangle", "true"]), + |o| o.selection.is_some(), + ); + super::update(&mut cx, &deck, |deck, cx| deck.select_objects(&[], cx)); + super::settle(&cx, |app| { + objects(app, &deck).is_some_and(|o| o.selection.is_none()) + }); +} + +#[test] +fn a_mixed_value_can_be_set_to_zero() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let count = |n: usize| move |o: &Objects| o.objects.len() == n; + act( + &mut cx, + &deck, + ("draw.shape", &["rectangle", "true"]), + count(3), + ); + act(&mut cx, &deck, ("object.transparency", &["25"]), |o| { + o.selection + .as_ref() + .is_some_and(|s| s.transparency == Some(25)) + }); + act( + &mut cx, + &deck, + ("draw.shape", &["ellipse", "true"]), + count(4), + ); + super::update(&mut cx, &deck, |deck, cx| { + deck.select_objects(&["2".into(), "3".into()], cx) + }); + super::settle(&cx, |app| { + selection(app, &deck).is_some_and(|s| s.count == 2 && s.transparency.is_none()) + }); + let steps = undo_steps(&mut cx, &deck); + send( + &mut cx, + &deck, + "transparency", + "object.transparency", + 0, + None, + ); + super::settle(&cx, |app| { + selection(app, &deck).is_some_and(|s| s.transparency == Some(0)) + }); + assert_eq!(undo_steps(&mut cx, &deck), steps + 1); +} + +#[test] +fn a_mixed_autofit_takes_one_rule_for_all() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let fit = |rule: &'static str| { + move |o: &Objects| o.selection.as_ref().is_some_and(|s| s.autofit == rule) + }; + super::update(&mut cx, &deck, |deck, cx| { + deck.select_objects(&["0".into()], cx) + }); + act(&mut cx, &deck, ("text.fit", &["shrink"]), fit("shrink")); + super::update(&mut cx, &deck, |deck, cx| { + deck.select_objects(&["1".into()], cx) + }); + act(&mut cx, &deck, ("text.fit", &["grow"]), fit("grow")); + super::update(&mut cx, &deck, |deck, cx| { + deck.select_objects(&["0".into(), "1".into()], cx) + }); + super::settle(&cx, |app| { + objects(app, &deck).as_ref().is_some_and(fit("mixed")) + }); + let steps = undo_steps(&mut cx, &deck); + act(&mut cx, &deck, ("text.fit", &["off"]), fit("off")); + assert_eq!(undo_steps(&mut cx, &deck), steps + 1, "one step for both"); +} diff --git a/apps/ely-show/src/tests/slides.rs b/apps/ely-show/src/tests/slides.rs new file mode 100644 index 0000000..e55e481 --- /dev/null +++ b/apps/ely-show/src/tests/slides.rs @@ -0,0 +1,386 @@ +//! Tables, charts, notes and comments from the deck (C10.3). + +use ely_gpui_component::forms::TextInput; +use ely_lok::show::Objects; +use ely_office::prelude::*; +use gpui::{AppContext as _, Entity, TestAppContext, Window}; + +use super::objects::{act, blank, objects}; +use super::{settle, undo_steps, update}; +use crate::deck::Deck; +use crate::dialog::Dialog; + +/// Runs `f` on the deck inside its window. +fn in_window( + cx: &mut TestAppContext, + deck: &Entity, + f: impl FnOnce(&mut Deck, &mut Window, &mut gpui::Context) -> R, +) -> R { + let handle = cx + .update(|app| deck.read(app).core.window) + .expect("a window"); + cx.update_window(handle, |_, window, app| { + deck.update(app, |deck, cx| f(deck, window, cx)) + }) + .expect("the window lives") +} + +fn type_in(cx: &mut TestAppContext, field: &Entity, text: &str) { + let text = text.to_string(); + cx.update(|app| field.update(app, |field, cx| field.set_text(text, cx))); +} + +fn has(cx: &TestAppContext, deck: &Entity, done: impl Fn(&Objects) -> bool) { + settle(cx, |app| objects(app, deck).as_ref().is_some_and(&done)); +} + +#[test] +fn the_chart_dialog_inserts_edits_and_refuses_words() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let chart_open = |app: &gpui::App| matches!(deck.read(app).dialog, Some(Dialog::Chart(_))); + update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx)); + settle(&cx, chart_open); + update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx)); + has(&cx, &deck, |o| { + o.selection.as_ref().is_some_and(|s| s.kind == "chart") + }); + // Again, with the chart marked: it edits. + update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx)); + settle(&cx, chart_open); + let cell = cx.update(|app| match &deck.read(app).dialog { + Some(Dialog::Chart(editing)) => { + assert!(editing.changing, "the marked chart opens for editing"); + editing.table.as_ref().expect("a table").series[0].1[0].clone() + } + _ => unreachable!("a chart dialog"), + }); + type_in(&mut cx, &cell, "lots"); + update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx)); + let refused = cx.update(|app| match &deck.read(app).dialog { + Some(Dialog::Chart(editing)) => editing.error.clone(), + _ => None, + }); + assert!(refused.is_some(), "a word in a cell keeps the dialog open"); + let steps = undo_steps(&mut cx, &deck); + type_in(&mut cx, &cell, "9"); + update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx)); + settle(&cx, |app| deck.read(app).dialog.is_none()); + assert_eq!(undo_steps(&mut cx, &deck), steps + 1, "one step"); + let value = cx.update(|app| { + let deck = deck.read(app); + let tab = deck.active_tab().expect("a tab"); + let mut worker = deck.core.worker.lock().expect("the worker lock"); + let read = worker + .values(&tab.doc, ely_lok::sheet::CHART) + .expect("reads"); + ely_lok::show::slide_chart(&read) + .expect("parses") + .expect("a chart") + .data + .expect("a table") + .series[0] + .1[0] + }); + assert_eq!(value, Some(9.0)); +} + +#[test] +fn the_table_dialog_inserts_a_table() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + update(&mut cx, &deck, |deck, cx| deck.ask_table(cx)); + update(&mut cx, &deck, |deck, cx| deck.table_asked(cx)); + has(&cx, &deck, |o| { + o.objects.iter().any(|object| object.kind == "table") + }); +} + +#[test] +fn typed_notes_reach_the_slide() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let notes = |app: &gpui::App| { + deck.read(app) + .active_tab() + .and_then(|tab| tab.extra.notes.clone()) + }; + settle(&cx, |app| notes(app).is_some()); + let field = in_window(&mut cx, &deck, |deck, window, cx| { + deck.notes_input(window, cx) + }); + let field = field.expect("the field"); + type_in(&mut cx, &field, "Start with the totals"); + update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx)); + settle(&cx, |app| { + notes(app).is_some_and(|notes| notes.text == "Start with the totals") + }); +} + +#[test] +fn a_comment_lands_on_the_shown_slide() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + in_window(&mut cx, &deck, |deck, window, cx| { + deck.ask_remark(None, window, cx) + }); + let (tab, edit) = cx.update(|app| match &deck.read(app).dialog { + Some(Dialog::Remark { tab, edit, .. }) => (*tab, edit.clone()), + _ => unreachable!("a comment dialog"), + }); + update(&mut cx, &deck, |deck, cx| { + deck.send_remark((tab, edit), " Too much text ", cx) + }); + let remarks = |app: &gpui::App| { + deck.read(app) + .active_tab() + .map(|tab| tab.extra.remarks.clone()) + .unwrap_or_default() + }; + settle(&cx, |app| remarks(app).len() == 1); + let remark = cx.update(|app| remarks(app).remove(0)); + assert_eq!((remark.part, remark.text.as_str()), (0, "Too much text")); +} + +#[test] +fn notes_typed_before_a_slide_change_stay_on_their_slide() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let notes = |app: &gpui::App| { + deck.read(app) + .active_tab() + .and_then(|tab| tab.extra.notes.clone()) + }; + update(&mut cx, &deck, |deck, cx| { + let tab = deck.core.active; + deck.run_in(tab, "slide.insert", &[], cx) + }); + let shown = + |part: usize| move |app: &gpui::App| notes(app).is_some_and(|n| n.part == Some(part)); + settle(&cx, shown(1)); + let view = |deck: &mut Deck| deck.active_tab().expect("a tab").view.clone(); + update(&mut cx, &deck, |deck, cx| { + view(deck).update(cx, |view, _| view.show_part(0)) + }); + settle(&cx, shown(0)); + let field = in_window(&mut cx, &deck, |deck, window, cx| { + deck.notes_input(window, cx) + }); + type_in(&mut cx, &field.expect("the field"), "Only for the first"); + // The switch is queued before the blur's commit. + update(&mut cx, &deck, |deck, cx| { + view(deck).update(cx, |view, _| view.show_part(1)); + deck.commit_notes(cx); + }); + settle(&cx, |app| { + notes(app).is_some_and(|n| n.part == Some(1) && n.text.is_empty()) + }); + update(&mut cx, &deck, |deck, cx| { + view(deck).update(cx, |view, _| view.show_part(0)) + }); + settle(&cx, |app| { + notes(app).is_some_and(|n| n.part == Some(0) && n.text == "Only for the first") + }); +} + +#[test] +fn a_chart_read_that_lands_late_opens_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx)); + settle(&cx, |app| { + matches!(deck.read(app).dialog, Some(Dialog::Chart(_))) + }); + update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx)); + has(&cx, &deck, |o| { + o.selection.as_ref().is_some_and(|s| s.kind == "chart") + }); + let first = cx.update(|app| deck.read(app).core.active).expect("a tab"); + update(&mut cx, &deck, |deck, cx| deck.new_blank(cx)); + settle(&cx, |app| deck.read(app).core.tabs.len() == 2); + let second = cx.update(|app| deck.read(app).core.active).expect("a tab"); + update(&mut cx, &deck, |deck, _| deck.core.active = Some(first)); + // Another tab comes forward before the read comes back. + update(&mut cx, &deck, |deck, cx| { + deck.ask_chart(cx); + deck.core.active = Some(second); + }); + for _ in 0..100 { + cx.executor() + .advance_clock(std::time::Duration::from_millis(20)); + cx.run_until_parked(); + } + assert!( + cx.update(|app| deck.read(app).dialog.is_none()), + "no dialog for a hidden tab" + ); +} + +#[test] +fn a_chart_read_never_shows_another_chart_on_the_same_frame() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let charts = |o: &Objects| -> Vec { + let at = o.objects.iter().enumerate(); + at.filter(|(_, o)| o.kind == "chart") + .map(|(at, _)| at) + .collect() + }; + let title = |app: &gpui::App| match &deck.read(app).dialog { + Some(Dialog::Chart(editing)) => Some(editing.fields.title.read(app).text().to_string()), + _ => None, + }; + for (n, name) in [(1, "First"), (2, "Second")] { + act(&mut cx, &deck, ("object.deselect", &["false"]), |o| { + o.selection.is_none() + }); + update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx)); + settle(&cx, |app| title(app).is_some()); + let field = cx.update(|app| match &deck.read(app).dialog { + Some(Dialog::Chart(editing)) => editing.fields.title.clone(), + _ => unreachable!("a chart dialog"), + }); + type_in(&mut cx, &field, name); + update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx)); + has(&cx, &deck, |o| charts(o).len() == n); + } + let both = cx.update(|app| charts(&objects(app, &deck).expect("objects"))); + let first = both[0].to_string(); + act(&mut cx, &deck, ("object.select", &[&first, "false"]), |o| { + o.objects[both[0]].selected + }); + // The kernel picks the other chart before the read runs. + let (worker, doc) = cx.update(|app| { + let deck = deck.read(app); + ( + deck.core.worker.clone(), + deck.active_tab().expect("a tab").doc.clone(), + ) + }); + let mut held = worker.lock().expect("the worker lock"); + update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx)); + let second = both[1].to_string(); + let doc = held.now(&doc).expect("the state"); + held.run(&doc, "object.select", &[&second, "false"]) + .expect("selects"); + drop(held); + for _ in 0..100 { + cx.executor() + .advance_clock(std::time::Duration::from_millis(20)); + cx.run_until_parked(); + } + let shown = cx.update(|app| title(app)); + assert!( + shown.is_none() || shown.as_deref() == Some("First"), + "{shown:?}" + ); +} + +#[test] +fn a_scatter_with_words_for_x_keeps_its_draft_and_names_the_cell() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx)); + let open = |app: &gpui::App| matches!(deck.read(app).dialog, Some(Dialog::Chart(_))); + settle(&cx, open); + update(&mut cx, &deck, |deck, cx| { + if let Some(Dialog::Chart(editing)) = &mut deck.dialog { + editing.look.kind = "scatter".into(); + } + deck.chart_asked(cx); + }); + let error = cx.update(|app| match &deck.read(app).dialog { + Some(Dialog::Chart(editing)) => editing.error.clone(), + _ => None, + }); + let first = cx.update(|app| Deck::text(app, "show.chart.category", &[("n", "1")])); + assert!( + error.is_some_and(|e| e.contains(first.as_ref())), + "the dialog stays and names it" + ); + let fields = cx.update(|app| match &deck.read(app).dialog { + Some(Dialog::Chart(editing)) => editing.table.as_ref().expect("a table").categories.clone(), + _ => unreachable!("a chart dialog"), + }); + for (at, field) in fields.iter().enumerate() { + type_in(&mut cx, field, &(10 * (at + 1)).to_string()); + } + update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx)); + has(&cx, &deck, |o| { + o.selection.as_ref().is_some_and(|s| s.kind == "chart") + }); +} + +#[test] +fn a_comment_picked_on_another_slide_shows_its_slide_again() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(deck) = blank(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let tab = cx.update(|app| deck.read(app).core.active).expect("a tab"); + update(&mut cx, &deck, |deck, cx| { + deck.send_remark((tab, None), "Here", cx) + }); + let remark = |app: &gpui::App| { + let tab = deck.read(app).active_tab()?; + tab.extra.remarks.first().map(|remark| remark.id.clone()) + }; + settle(&cx, |app| remark(app).is_some()); + let key: gpui::SharedString = cx.update(|app| remark(app)).expect("a comment").into(); + let current = |app: &gpui::App| { + deck.read(app) + .active_tab() + .map(|tab| tab.extra.slides.current) + }; + update(&mut cx, &deck, |deck, cx| deck.pick_remark(&key, cx)); + assert_eq!( + cx.update(|app| deck.read(app).shown_remark()), + Some(key.clone()) + ); + update(&mut cx, &deck, |deck, cx| { + deck.run_in(Some(tab), "slide.insert", &[], cx) + }); + settle(&cx, |app| current(app) == Some(1)); + assert_eq!( + cx.update(|app| deck.read(app).shown_remark()), + None, + "pickable again" + ); + update(&mut cx, &deck, |deck, cx| deck.pick_remark(&key, cx)); + settle(&cx, |app| current(app) == Some(0)); + assert_eq!(cx.update(|app| deck.read(app).shown_remark()), Some(key)); +} diff --git a/apps/ely-show/src/text.rs b/apps/ely-show/src/text.rs new file mode 100644 index 0000000..6512704 --- /dev/null +++ b/apps/ely-show/src/text.rs @@ -0,0 +1,151 @@ +//! The text group of the object panel (SHOW-03). + +use ely_gpui_component::{ + data_display::PropertyGroup, + forms::{Choice, Select}, + primitives::IconName, + theme::ControlSize, +}; +use ely_lok::show::Selection; +use ely_office::panel::{self as kernel, font_box, palette, row, size_menu, toggle}; +use gpui::{AnyElement, Context, IntoElement, SharedString, Window}; + +use crate::deck::Deck; +use crate::design::{Panel, on_deck}; + +impl Panel { + pub(crate) fn words( + &self, + sel: &Selection, + window: &mut Window, + cx: &mut Context, + ) -> PropertyGroup { + let why = "show.object.why_text"; + let effects = row([ + toggle( + self, + "format.bold", + (Some(IconName::Bold), ""), + ("", why), + cx, + ), + toggle( + self, + "format.italic", + (Some(IconName::Italic), ""), + ("", why), + cx, + ), + toggle( + self, + "format.underline", + (Some(IconName::Underline), ""), + ("", why), + cx, + ), + toggle( + self, + "format.strikeout", + (Some(IconName::Strikethrough), ""), + ("", why), + cx, + ), + ]); + let align = row([ + toggle( + self, + "paragraph.left", + (Some(IconName::AlignLeft), ""), + ("", why), + cx, + ), + toggle( + self, + "paragraph.center", + (Some(IconName::AlignCenter), ""), + ("", why), + cx, + ), + toggle( + self, + "paragraph.right", + (Some(IconName::AlignRight), ""), + ("", why), + cx, + ), + toggle( + self, + "paragraph.justify", + (Some(IconName::AlignJustify), ""), + ("", why), + cx, + ), + ]); + let lists = row([ + toggle( + self, + "list.bullets", + (Some(IconName::List), ""), + ("", why), + cx, + ), + toggle( + self, + "list.numbering", + (Some(IconName::ListOrdered), ""), + ("", why), + cx, + ), + ]); + let this = cx.entity(); + let ink = kernel::color_state(self, ".uno:FontColor", cx); + let fonts = self.fonts(cx); + let font = font_box(self, "show-font", &fonts, window, cx); + let size = size_menu(self, "show-size", cx); + let fit = self.fit(&sel.autofit, cx); + let tip = |key: &str| Deck::text(cx, key, &[]); + PropertyGroup::new(tip("show.object.text")) + .row(tip("show.object.font"), font) + .row(tip("show.object.font_size"), size) + .row(tip("show.object.effects"), effects) + .row( + tip("show.object.color"), + palette( + &this, + "format.color", + (ink, "office.props.automatic"), + None, + cx, + ), + ) + .row(tip("show.object.align"), align) + .row(tip("show.object.lists"), lists) + .row(tip("show.object.autofit"), fit) + } + + /// Off, shrink on overflow, or grow the box. + fn fit(&self, now: &str, cx: &mut Context) -> AnyElement { + let label = |key: &str| Deck::text(cx, key, &[]); + let mut choices: Vec = ["off", "shrink", "grow"] + .iter() + .map(|key| Choice::new(*key, label(&format!("show.fit.{key}")))) + .collect(); + if now == "mixed" { + choices.push(Choice::new("mixed", label("office.props.mixed"))); + } + let deck = self.deck.clone(); + Select::new("show-fit", choices) + .label(label("show.object.autofit")) + .selected(SharedString::from(now.to_string())) + .size(ControlSize::Sm) + .on_change(move |to, _, cx| { + let to = to.to_string(); + if to != "mixed" { + on_deck(&deck, cx, |deck, cx| { + deck.object_command("text.fit", &[&to], cx) + }) + } + }) + .into_any_element() + } +} diff --git a/apps/ely-show/tests/compose.rs b/apps/ely-show/tests/compose.rs new file mode 100644 index 0000000..47322c5 --- /dev/null +++ b/apps/ely-show/tests/compose.rs @@ -0,0 +1,134 @@ +//! Transition frames drawn by Metal, read back as pixels. + +#[cfg(target_os = "macos")] +#[path = "../src/preview/transition.rs"] +#[allow(dead_code, unused_imports)] +mod transition; + +#[cfg(target_os = "macos")] +#[path = "../src/preview/timeline.rs"] +#[allow(dead_code, unused_imports)] +mod timeline; + +#[cfg(target_os = "macos")] +#[path = "../src/preview/compose.rs"] +mod compose; + +#[cfg(target_os = "macos")] +fn main() { + use std::sync::Arc; + + use ely_lok::show::Part; + use gpui::{ + AppContext as _, Context, IntoElement, ParentElement as _, Render, RenderImage, + Styled as _, VisualTestAppContext, Window, div, px, size, + }; + use image::{Frame, ImageBuffer, Rgba, RgbaImage}; + + type Stack = Vec<(Part, Arc)>; + // Pixels go in as BGRA; `fill` picks the painted columns. + let picture = |bgra: [u8; 4], fill: fn(u32) -> bool| { + let image = ImageBuffer::, _>::from_fn(16, 8, |x, _| match fill(x) { + true => Rgba(bgra), + false => Rgba([0, 0, 0, 0]), + }); + Arc::new(RenderImage::new([Frame::new(image)])) + }; + let plain = |bgra: [u8; 4]| vec![(Part::Still, picture(bgra, |_| true))]; + struct Shot { + old: Stack, + new: Stack, + preset: &'static str, + /// The old slide's effects, as when it left. + runs: Option>, + } + impl Render for Shot { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let frame = transition::frame(self.preset, 0.5); + let (w, h) = (px(200.0), px(100.0)); + let old = match &self.runs { + Some(runs) => compose::left( + (&self.old, runs), + ((1600.0, 800.0), 1.0), + (w, h), + gpui::green(), + ), + None => { + compose::slide(&self.old, |_| true, (w, h), gpui::green()).into_any_element() + } + }; + let shown = compose::transition((Some(old), &self.new), &frame, (w, h), gpui::green()); + div().w(w).h(h).relative().child(shown) + } + } + let mut cx = VisualTestAppContext::new(gpui_platform::current_platform(false)); + let mut shoot = |shot: Shot| -> RgbaImage { + let window = cx + .open_offscreen_window(size(px(200.0), px(100.0)), |_, cx| cx.new(|_| shot)) + .expect("opens a window"); + // Pictures load after the first frame. + for _ in 0..3 { + cx.run_until_parked(); + cx.update_window(window.into(), |_, window, cx| window.draw(cx).clear(cx)) + .expect("draws"); + } + cx.capture_screenshot(window.into()).expect("draws") + }; + let at = |shot: &RgbaImage, x: f32| { + *shot.get_pixel((shot.width() as f32 * x) as u32, shot.height() / 2) + }; + for preset in ["uncover-left", "cover-left", "push-left"] { + let (old, new) = (plain([0, 0, 255, 255]), plain([255, 0, 0, 255])); + let shot = shoot(Shot { + old, + new, + preset, + runs: None, + }); + let (left, right) = (at(&shot, 0.25), at(&shot, 0.75)); + // Halfway, the old slide is left, the new one right. + assert!(left[0] > 200 && left[2] < 50, "{preset}: left is {left:?}"); + assert!( + right[2] > 200 && right[0] < 50, + "{preset}: right is {right:?}" + ); + } + // A red object, its exit done, covered from the right. + let object = || { + let moving = Part::Moving { + hash: "gone".into(), + visible: true, + bounds: (0, 0, 400, 800), + }; + vec![ + (Part::Still, picture([0, 0, 0, 0], |_| true)), + (moving, picture([0, 0, 255, 255], |x| x < 4)), + ] + }; + let exit = timeline::Run { + hash: "gone".into(), + preset: "ooo-exit-disappear".into(), + begin: 0.0, + end: 0.5, + }; + for (runs, red) in [(vec![exit], false), (Vec::new(), true)] { + let shot = shoot(Shot { + old: object(), + new: plain([255, 0, 0, 255]), + preset: "cover-left", + runs: Some(runs), + }); + let pixel = at(&shot, 0.1); + assert_eq!( + pixel[0] > 200 && pixel[1] < 50, + red, + "the object is {pixel:?}" + ); + } + println!("compose: halfway frames show both slides in order; exits stay gone"); +} + +#[cfg(not(target_os = "macos"))] +fn main() { + println!("SKIP: the pixel check runs on macOS"); +} diff --git a/apps/ely-table/Cargo.toml b/apps/ely-table/Cargo.toml new file mode 100644 index 0000000..26b5cb9 --- /dev/null +++ b/apps/ely-table/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "ely-table" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +encoding_rs.workspace = true +ely-gpui-component.workspace = true +serde_json.workspace = true +ely-lok = { workspace = true, features = ["view"] } +ely-office.workspace = true +gpui.workspace = true +log.workspace = true +ely-platform.workspace = true +ely-ui.workspace = true + +[dev-dependencies] +ely-ui = { workspace = true, features = ["test-support"] } +tempfile.workspace = true +gpui = { workspace = true, features = ["test-support"] } + +[lints] +workspace = true diff --git a/apps/ely-table/src/analysis.rs b/apps/ely-table/src/analysis.rs new file mode 100644 index 0000000..2d755cd --- /dev/null +++ b/apps/ely-table/src/analysis.rs @@ -0,0 +1,202 @@ +//! The Insert menu: pivot tables and charts (TABLE-10, 11). + +use ely_gpui_component::forms::TextInput; +use ely_lok::sheet::{self, Chart, Layout, Pivot, Selection}; +use ely_office::prelude::*; +use gpui::{ + App, Context, Div, Entity, InteractiveElement as _, Menu, MenuItem, Window, + actions, +}; + +use crate::book::Book; +use crate::dialog::Dialog; + +mod dialogs; +pub use dialogs::{chart_dialog, pivot_dialog}; + +actions!(table, [MakePivot, RefreshPivot, MakeChart]); + +pub fn menu(cx: &App) -> Menu { + let label = |key: &str| Book::text(cx, key, &[]); + Menu::new(label("menu.insert")).items([ + MenuItem::action(label("table.pivot.make"), MakePivot), + MenuItem::action(label("table.pivot.refresh"), RefreshPivot), + MenuItem::separator(), + MenuItem::action(label("table.chart.make"), MakeChart), + ]) +} + +pub fn bind(root: Div, cx: &mut Context) -> Div { + root.on_action(cx.listener(|book, _: &MakePivot, _, cx| book.ask(Asked::Pivot, cx))) + .on_action(cx.listener(|book, _: &RefreshPivot, _, cx| book.ask(Asked::Refresh, cx))) + .on_action(cx.listener(|book, _: &MakeChart, _, cx| book.ask(Asked::Chart, cx))) +} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum Asked { + Pivot, + Refresh, + Chart, +} + +/// A chart dialog's text fields. +pub struct ChartFields { + pub range: Entity, + pub look: ely_office::chart::LookFields, +} + +/// What the kernel holds where the user asked. +enum Found { + Pivot(Option), + Chart(Option), +} + +impl Book { + pub(crate) fn ask(&mut self, asked: Asked, cx: &mut Context) { + let Some(id) = self.core.active else { + return self.warn_key("command.blocked.no_document", cx); + }; + self.ask_here(id, asked, cx); + } + + /// Reads the pivot table or chart here, after input. + fn ask_here(&mut self, id: u64, asked: Asked, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |book, _, cx| book.ask_here(id, asked, cx)); + } + self.ask_in( + id, + move |worker, doc| match asked { + Asked::Chart => Ok(Found::Chart(sheet::chart( + &worker.values(doc, sheet::CHART)?, + )?)), + _ => Ok(Found::Pivot(sheet::pivot( + &worker.values(doc, sheet::PIVOT)?, + )?)), + }, + move |book, read, window, cx| match (asked, read) { + (_, Err(error)) => book.engine_failed(&error.to_string(), cx), + (Asked::Refresh, Ok(Found::Pivot(Some(_)))) => { + book.run_in(Some(id), "pivot.refresh", &[], cx) + } + (Asked::Refresh, Ok(_)) => book.warn_key("table.pivot.none", cx), + (_, Ok(Found::Pivot(Some(pivot)))) => { + book.dialog = Some(Dialog::Pivot { + tab: id, + source: pivot.source, + fields: pivot.fields, + layout: pivot.layout, + editing: true, + }); + cx.notify(); + } + (_, Ok(Found::Chart(Some(chart)))) => { + book.chart_dialog(id, Some(chart), "", window, cx) + } + (_, Ok(_)) => book.data_of( + id, + true, + move |book, picked, _, window, cx| book.new_dialog(id, asked, picked, window, cx), + cx, + ), + }, + cx, + ); + } + + /// A create dialog over the picked data. + fn new_dialog( + &mut self, + id: u64, + asked: Asked, + picked: Selection, + window: &mut Window, + cx: &mut Context, + ) { + log::info!("table: {asked:?} from {}", picked.range); + if let Asked::Chart = asked { + return self.chart_dialog(id, None, &picked.data, window, cx); + } + if picked.top.iter().any(String::is_empty) { + return self.warn_key("table.pivot.headers", cx); + } + self.dialog = Some(Dialog::Pivot { + tab: id, + source: picked.data, + fields: picked.top, + layout: Layout::default(), + editing: false, + }); + cx.notify(); + } + + fn chart_dialog( + &mut self, + id: u64, + chart: Option, + range: &str, + window: &mut Window, + cx: &mut Context, + ) { + let look = chart + .as_ref() + .map_or_else(ely_office::chart::fresh, |chart| chart.look.clone()); + let range = chart.as_ref().map_or(range, |chart| &chart.range); + let fields = ChartFields { + range: ely_office::chart::field(range, "table.chart.range", window, cx), + look: ely_office::chart::LookFields::new(&look, window, cx), + }; + self.dialog = Some(Dialog::Chart { + tab: id, + chart: chart.map(Box::new), + look: Box::new(look), + fields: Box::new(fields), + }); + cx.notify(); + } + + /// Makes or changes the pivot table the dialog describes. + pub(crate) fn pivot_asked(&mut self, cx: &mut Context) { + let Some(Dialog::Pivot { + tab, + source, + layout, + editing, + .. + }) = self.dialog.take() + else { + return; + }; + let source = if editing { "" } else { source.as_str() }; + self.run_in(Some(tab), "pivot.make", &[&layout.to_json(source, "")], cx); + } + + /// Inserts or changes the chart the dialog describes. + pub(crate) fn chart_asked(&mut self, cx: &mut Context) { + let Some(Dialog::Chart { + tab, + chart, + look, + fields, + }) = self.dialog.take() + else { + return; + }; + let look = fields.look.read(&look, cx).to_json(); + let range = fields.range.read(cx).text().trim().to_string(); + match chart { + Some(chart) => { + let headers = |on: bool| if on { "true" } else { "false" }; + let values = [ + chart.name.as_str(), + &range, + headers(chart.col_headers), + headers(chart.row_headers), + &look, + ]; + self.run_in(Some(tab), "chart.change", &values, cx) + } + None => self.run_in(Some(tab), "chart.insert", &[&range, "false", &look], cx), + } + } +} diff --git a/apps/ely-table/src/analysis/dialogs.rs b/apps/ely-table/src/analysis/dialogs.rs new file mode 100644 index 0000000..41a6f35 --- /dev/null +++ b/apps/ely-table/src/analysis/dialogs.rs @@ -0,0 +1,202 @@ +//! The pivot table and chart dialogs. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Choice, Input, Select}, + layout::ScrollArea, + overlays::Dialog as Card, +}; +use ely_lok::sheet::{Chart, Layout, Look, PIVOT_FUNCTIONS}; +use gpui::{App, Entity, ParentElement as _, Styled as _, Window, div}; + +use super::ChartFields; +use crate::book::Book; +use crate::dialog::{Dialog, edit}; + +/// Where a field goes; empty for nowhere. +const ROLES: [(&str, &str); 5] = [ + ("", "table.pivot.unused"), + ("rows", "table.pivot.rows"), + ("columns", "table.pivot.columns"), + ("pages", "table.pivot.pages"), + ("data", "table.pivot.data"), +]; + +fn role_of(layout: &Layout, field: &str) -> &'static str { + let has = |list: &[String]| list.iter().any(|name| name == field); + if has(&layout.rows) { + "rows" + } else if has(&layout.columns) { + "columns" + } else if has(&layout.pages) { + "pages" + } else if layout.data.iter().any(|(name, _)| name == field) { + "data" + } else { + "" + } +} + +/// Moves `field` to the end of `role`'s list. +fn place(layout: &mut Layout, field: &str, role: &str) { + for list in [&mut layout.rows, &mut layout.columns, &mut layout.pages] { + list.retain(|name| name != field); + } + layout.data.retain(|(name, _)| name != field); + let field = field.to_string(); + match role { + "rows" => layout.rows.push(field), + "columns" => layout.columns.push(field), + "pages" => layout.pages.push(field), + "data" => layout.data.push((field, "sum".into())), + _ => {} + } +} + +fn layout_of(dialog: &mut Dialog) -> &mut Layout { + match dialog { + Dialog::Pivot { layout, .. } => layout, + _ => unreachable!("a pivot dialog"), + } +} + +/// One field's area and, for values, its function. +fn field_row(this: &Entity, at: usize, field: &str, layout: &Layout, cx: &App) -> gpui::Div { + let label = |key: &str| Book::text(cx, key, &[]); + let role = role_of(layout, field); + let (picking, name) = (this.clone(), field.to_string()); + let roles = Select::new( + ("table-pivot-role", at), + ROLES + .iter() + .map(|(value, key)| Choice::new(*value, label(key))), + ) + .label(field.to_string()) + .selected(role.to_string()) + .on_change(move |value, _, cx| { + let value = value.to_string(); + edit(&picking, cx, |dialog, _, _| { + place(layout_of(dialog), &name, &value) + }) + }); + let mut row = div().flex().gap_2().child(div().w_full().max_w_32().child(roles)); + if let Some((_, function)) = layout.data.iter().find(|(name, _)| name == field) { + let mut functions: Vec<&str> = PIVOT_FUNCTIONS.to_vec(); + // The kernel's own function stays until changed. + if !functions.contains(&function.as_str()) { + functions.push("other"); + } + let (picking, name) = (this.clone(), field.to_string()); + let shown = if PIVOT_FUNCTIONS.contains(&function.as_str()) { + function.clone() + } else { + "other".into() + }; + let choices = functions + .into_iter() + .map(|value| Choice::new(value, label(&format!("table.pivot.{value}")))); + row = row.child( + div().w_full().max_w_32().child( + Select::new(("table-pivot-function", at), choices) + .label(Book::text( + cx, + "table.pivot.function_of", + &[("field", field)], + )) + .selected(shown) + .on_change(move |value, _, cx| { + let value = value.to_string(); + edit(&picking, cx, |dialog, _, _| { + let data = &mut layout_of(dialog).data; + if let Some(entry) = data.iter_mut().find(|(field, _)| *field == name) { + entry.1 = value; + } + }) + }), + ), + ); + } + row +} + +pub fn pivot_dialog( + this: Entity, + (source, fields, layout, editing): (&str, &[String], &Layout, bool), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let label = |key: &str| Book::text(cx, key, &[]); + let rows = fields + .iter() + .enumerate() + .fold(DescriptionList::new(), |rows, (at, field)| { + rows.item(field.clone(), field_row(&this, at, field, layout, cx)) + }); + let go = label(if editing { + "table.pivot.apply" + } else { + "table.pivot.go" + }); + let off = layout.data.is_empty() || layout.data.iter().any(|(_, f)| f == "other"); + Card::new("table-pivot", label("table.pivot.title"), dismiss) + .detail(Book::text(cx, "table.pivot.source", &[("range", source)])) + .child( + div().max_h_96().child( + ScrollArea::new("table-pivot-scroll") + .size_full() + .child(rows), + ), + ) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("table-pivot-go", go) + .variant(ButtonVariant::Primary) + .disabled(off) + .on_click(move |_, _, cx| this.update(cx, |book, cx| book.pivot_asked(cx))) + }) +} + +fn look_of(dialog: &mut Dialog) -> &mut Look { + match dialog { + Dialog::Chart { look, .. } => look, + _ => unreachable!("a chart dialog"), + } +} + +pub fn chart_dialog( + this: Entity, + (chart, look, fields): (Option<&Chart>, &Look, &ChartFields), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let label = |key: &str| Book::text(cx, key, &[]); + let (picking, toggling) = (this.clone(), this.clone()); + let range = DescriptionList::new().item( + label("table.chart.range"), + div().w_full().max_w_48().child(Input::new(&fields.range)), + ); + let (rows, legend) = ely_office::chart::look_rows( + "table-chart", + range, + (look, &fields.look), + move |kind, cx| edit(&picking, cx, |dialog, _, _| look_of(dialog).kind = kind), + move |on, cx| edit(&toggling, cx, |dialog, _, _| look_of(dialog).legend = on), + cx, + ); + let (title, go) = if chart.is_some() { + ("office.chart.edit_title", "office.chart.apply") + } else { + ("office.chart.insert_title", "office.chart.go") + }; + let go = label(go); + Card::new("table-chart", label(title), dismiss) + .child(rows) + .child(legend) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("table-chart-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |book, cx| book.chart_asked(cx))) + }) +} diff --git a/apps/ely-table/src/bars.rs b/apps/ely-table/src/bars.rs new file mode 100644 index 0000000..1010868 --- /dev/null +++ b/apps/ely-table/src/bars.rs @@ -0,0 +1,148 @@ +//! The name box and the formula bar (TABLE-03). + +use ely_gpui_component::{ + forms::{Input, InputEvent, TextInput}, + theme::{ActiveTheme as _, ControlSize}, +}; +use ely_lok::LokView; +use ely_office::prelude::*; +use gpui::{ + AnyElement, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + KeyDownEvent, ParentElement as _, Styled as _, Window, div, +}; + +use crate::book::Book; + +pub struct Bars { + pub name: Entity, + pub formula: Entity, + /// Tab, address and content the bars last took. + shown: Option<(u64, String, String)>, + /// A bar the user is typing in keeps its text. + editing: Option, +} + +#[derive(Clone, Copy, PartialEq)] +enum Bar { + Name, + Formula, +} + +impl Bars { + pub fn new(window: &mut Window, cx: &mut Context) -> Bars { + let (name, formula) = ( + Book::text(cx, "table.name_box", &[]), + Book::text(cx, "table.formula", &[]), + ); + let name = cx.new(|cx| TextInput::new(window, cx).label(name)); + let formula = cx.new(|cx| TextInput::new(window, cx).label(formula)); + for (input, bar) in [(&name, Bar::Name), (&formula, Bar::Formula)] { + cx.subscribe_in( + input, + window, + move |book, input, event, window, cx| match event { + InputEvent::Focus => book.bars.editing = Some(bar), + InputEvent::Blur => { + book.bars.editing = None; + book.bars.shown = None; + book.refill(cx); + } + InputEvent::Submit => { + let text = input.read(cx).text().to_string(); + book.submit(bar, text, window, cx); + } + InputEvent::Changed => {} + }, + ) + .detach(); + } + Bars { + name, + formula, + shown: None, + editing: None, + } + } + + pub fn render(&self, cx: &mut Context) -> AnyElement { + div() + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .border_b_1() + .border_color(cx.theme().colors.border) + .on_key_down(cx.listener(|book, event: &KeyDownEvent, window, cx| { + if event.keystroke.key == "escape" { + book.leave_bars(window, cx); + } + })) + .child( + div() + .id("table-name-box") + .w_32() + .child(Input::new(&self.name).size(ControlSize::Sm)), + ) + .child( + div() + .id("table-formula") + .flex_1() + .child(Input::new(&self.formula).size(ControlSize::Sm)), + ) + .into_any_element() + } +} + +impl Book { + /// Shows the active cell, except in a bar being typed. + pub fn sync_bars(&mut self, view: &Entity, cx: &mut Context) { + let Some(id) = self.core.active else { + return; + }; + let cell = view.read(cx).cell(); + let key = (id, cell.address.clone(), cell.formula.clone()); + if self.bars.shown.as_ref() == Some(&key) { + return; + } + let editing = self.bars.editing; + if editing != Some(Bar::Name) { + self.bars + .name + .update(cx, |input, cx| input.set_text(key.1.clone(), cx)); + } + if editing != Some(Bar::Formula) { + self.bars + .formula + .update(cx, |input, cx| input.set_text(key.2.clone(), cx)); + } + self.bars.shown = Some(key); + } + + /// Shows the active tab's cell again. + pub(crate) fn refill(&mut self, cx: &mut Context) { + if let Some(view) = self.active_tab().map(|tab| tab.view.clone()) { + self.sync_bars(&view, cx); + } + } + + /// Enter goes to the address or enters the content. + fn submit(&mut self, bar: Bar, text: String, window: &mut Window, cx: &mut Context) { + match bar { + Bar::Name => self.run_active("cell.goto", &[&text], cx), + Bar::Formula => self.run_active("cell.enter", &[&text], cx), + } + self.leave_bars(window, cx); + } + + /// Escape or Enter hands the keys back to the grid. + fn leave_bars(&mut self, window: &mut Window, cx: &mut Context) { + self.bars.editing = None; + self.bars.shown = None; + self.refill(cx); + if let Some(tab) = self.active_tab() { + let focus = tab.view.read(cx).focus_handle().clone(); + window.focus(&focus, cx); + } + } +} diff --git a/apps/ely-table/src/book.rs b/apps/ely-table/src/book.rs new file mode 100644 index 0000000..95e457a --- /dev/null +++ b/apps/ely-table/src/book.rs @@ -0,0 +1,419 @@ +//! ElyTable's window: workbooks over one LOK worker (§9.1). + +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use ely_gpui_component::{ + feedback::{Banner, EmptyState}, + primitives::{IconName, Severity}, + shell::{StatusBar, StatusBarItem}, +}; +use ely_lok::{LokView, Sheets, Worker, sheet::Geometry}; +use ely_office::{Core, Kind, prelude::*}; +use ely_platform::AppId; +use ely_ui::{Root, Shell, commands}; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, AnyView, App, AppContext as _, Context, Entity, InteractiveElement as _, + IntoElement, KeyBinding, ParentElement as _, Render, SharedString, Styled as _, Window, div, +}; + +use crate::bars::Bars; +use crate::format::{FormatPanel, Panel}; +use crate::grid::Drag; + +/// A cell: sheet count, address, edit count. +pub type Spot = (u64, String, u64); + +/// What ElyTable keeps per workbook. +#[derive(Default)] +pub struct Extra { + pub sheets: Sheets, + /// The document as the last read saw it. + pub read_at: Option, + pub geometry: Geometry, + /// The view's sheet and header counts at the last read. + pub read: Option<(u64, u64)>, + /// A sheet read is on its way. + pub reading: bool, + /// The kernel requests already taken. + pub requests: u64, + /// The cell the rule belongs to, and the rule. + pub rule: Option<(Spot, ely_lok::sheet::Validation)>, + /// The cell whose rule was last asked for. + pub ruled: Option, + /// A rule read is on its way. + pub ruling: bool, + /// Linked sources shown as saved. + pub links: Vec, + /// The edit count the links were last asked at. + pub linked: Option, + /// A links read is on its way. + pub linking: bool, +} + +pub struct Book { + pub core: Core, + pub dialog: Option, + pub bars: Bars, + pub drag: Option, + /// The sheet menu's request: number and place. + pub sheet_menu: Option<(u64, gpui::Point)>, + pub panel: Entity, + pub panel_shown: bool, + pub choices: Option, + /// Read text files waiting for the import dialog. + pub imports: std::collections::VecDeque, +} + +impl Host for Book { + type Extra = Extra; + type Engine = ely_office::Lok; + const KIND: Kind = Kind { + app: AppId::Table, + kept: &["xlsx", "ods", "xls", "fods"], + save_as: &["xlsx", "ods"], + macros: Some(&["xlsm", "xltm"]), + }; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + fn read_at(&self, tab: u64, _: &gpui::App) -> Option { + self.tab(tab).and_then(|tab| tab.extra.read_at.clone()) + } + + fn observed(&mut self, id: u64, view: &Entity, cx: &mut Context) { + self.reread(id, view, cx); + self.freeze(id, view, cx); + self.found(id, view, cx); + self.requested(id, view, cx); + self.follow_rule(id, view, cx); + self.read_links(id, view, cx); + if self.core.active == Some(id) { + self.sync_bars(view, cx); + } + } +} + +/// The window root with a blank workbook. +pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { + ely_lok::init(cx); + let (worker, work) = match ely_office::start(app, cx) { + Ok(started) => started, + Err(error) => ely_platform::startup::fail(app, &format!("{error}")), + }; + let book = cx.new(|cx| { + let mut host = Book::new(worker, work, window, cx); + // Recovery points, crash return, autosave (§6.2, §6.3). + ely_office::Recovering::start_recovery(&mut host, cx); + ::start_autosave(cx); + host + }); + cx.bind_keys([ + KeyBinding::new( + "secondary-f", + ely_office::FindReplace, + Some(commands::CONTEXT), + ), + KeyBinding::new( + "secondary-shift-v", + crate::data::PasteValues, + Some(commands::CONTEXT), + ), + ]); + ely_office::bind_zoom_keys(cx); + let menus = vec![ + gpui::Menu::new(ely_ui::i18n::text(cx, "menu.view", &[])).items(ely_office::zoom_items(false, cx)), + crate::sheets::menu(cx), + crate::cells::menu(cx), + crate::analysis::menu(cx), + crate::format::menu(cx), + crate::formulas::menu(cx), + crate::data::menu(cx), + crate::page::menu(cx), + ]; + let root = ely_ui::document_root(app, book.clone().into(), &ely_ui::commands::FILE, menus, window, cx); + book.update(cx, |book, cx| { + book.core.root = root.downgrade(); + book.core.window = Some(window.window_handle()); + window.focus(&book.core.focus, cx); + }); + let launched = ely_ui::launch_files(cx); + book.update(cx, |book, cx| match launched.is_empty() { + true => book.new_blank(cx), + false => launched.into_iter().for_each(|path| book.open_asked(path, cx)), + }); + let opening = book.clone(); + ely_ui::on_open(cx, move |path, cx| { + opening.update(cx, |book, cx| book.open_asked(path, cx)) + }); + let closing = book.clone(); + window.on_window_should_close(cx, move |window, cx| { + closing.update(cx, |book, cx| book.should_close(window, cx)) + }); + root +} + +impl Book { + pub fn new( + worker: Arc>, + work: PathBuf, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let book = cx.weak_entity(); + let panel = cx.new(|cx| Panel::new(book, Arc::clone(&worker), window, cx)); + // A closed dialog lets the next import ask. + cx.observe_self(|book, cx| book.next_import(cx)).detach(); + Book { + core: Core::new(worker, work, cx), + dialog: None, + bars: Bars::new(window, cx), + drag: None, + sheet_menu: None, + panel, + panel_shown: true, + choices: None, + imports: Default::default(), + } + } + + pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString { + ely_ui::i18n::text(cx, key, args) + } + + /// A new untitled workbook (TABLE-01). + pub fn new_blank(&mut self, cx: &mut Context) { + self.new_from_text("blank.fods", &crate::template::blank(cx), cx); + } + + /// Rereads sheets and geometry when the kernel moves them. + fn reread(&mut self, id: u64, view: &Entity, cx: &mut Context) { + let cell = view.read(cx).cell(); + let seen = (cell.sheets, cell.headers); + let Some(tab) = self.tab(id) else { + return; + }; + // Numbered commands need the list read at the latest revision. + let behind = tab.extra.read_at.as_ref().is_some_and(|at| view.read(cx).read_at().newer_than(at)); + if tab.extra.reading || tab.extra.read == Some(seen) && !behind { + return; + } + let doc = tab.doc.clone(); + // Marked read now: a failure waits for the next change. + let extra = &mut self.tab_mut(id).expect("the tab is open").extra; + (extra.reading, extra.read) = (true, Some(seen)); + ely_lok::read_off( + &self.core.worker, + &doc, + |worker, doc| { + let at = worker.now(doc)?; + let sheets = doc.sheets(worker)?; + let json = worker.values(doc, ely_lok::sheet::GEOMETRY)?; + Ok((sheets, ely_lok::sheet::geometry(&json)?, at)) + }, + move |book: &mut Self, read, cx| { + let Some(tab) = book.tab_mut(id) else { + return; + }; + tab.extra.reading = false; + let view = tab.view.clone(); + match read { + Ok((sheets, geometry, at)) => { + log::info!("table: tab {id} has {} sheets", sheets.list.len()); + view.update(cx, |view, _| view.set_part(sheets.current)); + let extra = &mut tab.extra; + extra.sheets = sheets; + extra.geometry = geometry; + extra.read_at = Some(at); + } + Err(error) => book.engine_failed(&error.to_string(), cx), + } + cx.notify(); + // The kernel may have moved again meanwhile. + book.reread(id, &view, cx); + }, + cx, + ); + } + + /// Rereads the linked sources after each edit. + fn read_links(&mut self, id: u64, view: &Entity, cx: &mut Context) { + let edited = view.read(cx).edited(); + let Some(tab) = self.tab_mut(id) else { + return; + }; + if tab.extra.linking || tab.extra.linked == Some(edited) { + return; + } + (tab.extra.linking, tab.extra.linked) = (true, Some(edited)); + let doc = tab.doc.clone(); + ely_lok::read_off( + &self.core.worker, + &doc, + |worker, doc| ely_lok::sheet::links(&worker.values(doc, ely_lok::sheet::LINKS)?), + move |book: &mut Self, read, cx| { + let Some(tab) = book.tab_mut(id) else { + return; + }; + tab.extra.linking = false; + let view = tab.view.clone(); + match read { + Ok(links) => { + log::info!("table: tab {id} links {} sources", links.len()); + tab.extra.links = links; + } + Err(error) => book.engine_failed(&error.to_string(), cx), + } + cx.notify(); + // An edit may have landed meanwhile. + book.read_links(id, &view, cx); + }, + cx, + ); + } + + /// Linked values stay as saved; the banner says so (§9.3). + fn links_banner(&self, cx: &mut Context) -> Option { + let links = &self.active_tab()?.extra.links; + if links.is_empty() { + return None; + } + let sources = links.join(", "); + let text = Self::text(cx, "table.links.stale", &[("sources", &sources)]); + Some(Banner::new("table-links", Severity::Info, text)) + } + + /// Holds the kernel's frozen rows and columns in place. + fn freeze(&mut self, id: u64, view: &Entity, cx: &mut Context) { + let Some(tab) = self.tab(id) else { + return; + }; + let read = view.read(cx); + let index = |uno: &str| frozen_index(read.state(uno)); + let geometry = &tab.extra.geometry; + let frozen = ( + index(".uno:FreezePanesColumn").map_or(0, |at| geometry.columns.start(at)), + index(".uno:FreezePanesRow").map_or(0, |at| geometry.rows.start(at)), + ); + view.update(cx, |view, cx| view.set_frozen(frozen, cx)); + } + + /// The save state, and the kernel's statistics of the selection. + fn status(&self, cx: &mut Context) -> StatusBar { + let state = self.save_state(cx); + let mut status = StatusBar::new().left(StatusBarItem::new("table-state").label(state)); + if self.automatic(cx) == Some(false) { + let manual = Self::text(cx, "table.calc.manual", &[]); + status = status.left(StatusBarItem::new("table-manual").label(manual)); + } + let figures = self + .active_tab() + .and_then(|tab| tab.view.read(cx).state(".uno:StateTableCell")) + .filter(|figures| !figures.is_empty()) + .map(|figures| crate::figures::label(figures, cx)); + let status = match figures { + Some(figures) => status.right(StatusBarItem::new("table-figures").label(figures)), + None => status, + }; + status.when_some(self.zoom_control(cx), |status, zoom| status.right(zoom)) + } + + fn body(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + if self.active_tab().is_none() { + let title = match self.core.loading.first() { + Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]), + None => Self::text(cx, "command.blocked.no_document", &[]), + }; + return EmptyState::new("table-empty", IconName::Table, title).into_any_element(); + } + let risk = self.stopped_banner(cx).or_else(|| self.risk_banner(cx)); + div() + .size_full() + .flex() + .flex_col() + .children(risk) + .children(self.links_banner(cx)) + .child(self.bars.render(cx)) + .child(self.grid(window, cx)) + .child(self.sheet_strip(cx)) + .into_any_element() + } +} + +impl Render for Book { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Another workbook may have become active. + self.refill(cx); + let title = self + .active_tab() + .map_or_else(|| AppId::Table.name().into(), |tab| tab.name.clone()); + let shell = Shell::new(title, &self.core.focus) + .tabs(self.tab_strip(cx)) + .right(self.side_view(cx)) + .status(self.status(cx)) + .child(self.body(window, cx)); + let root = div() + .size_full() + .key_context(commands::CONTEXT) + .track_focus(&self.core.focus) + .on_action(cx.listener(|book, _: &commands::New, _, cx| book.new_blank(cx))) + .on_action(cx.listener(Self::on_open_file)) + .on_action(cx.listener(Self::on_save)) + .on_action(cx.listener(Self::on_save_as)) + .on_action(cx.listener(Self::on_quit)) + .on_action(cx.listener(Self::on_close_tab)) + .on_action(cx.listener(Self::on_export)) + .on_action(cx.listener(Self::on_print)) + .child(shell) + .children(self.core_dialog(cx)) + .children(self.dialog_element(cx)); + let root = Self::bind_zoom(crate::cells::bind(crate::sheets::bind(root, cx), cx), cx); + let root = crate::data::bind(crate::formulas::bind(root, cx), cx); + let root = crate::page::bind(crate::protect::bind(root, cx), cx); + crate::analysis::bind(crate::rules::bind(root, cx), cx) + .on_action(cx.listener(Self::on_find)) + .on_action(cx.listener(|book, _: &FormatPanel, _, cx| { + book.panel_shown = !book.panel_shown; + cx.notify(); + })) + } +} + +impl Book { + /// The format panel for the active workbook, while shown. + fn side_view(&mut self, cx: &mut Context) -> Option { + let shown = self + .active_tab() + .map(|tab| (tab.view.clone(), tab.doc.clone())); + let tab = shown.as_ref().map(|(view, doc)| (view, doc)); + self.panel.update(cx, |panel, cx| panel.show(tab, cx)); + (shown.is_some() && self.panel_shown).then(|| self.panel.clone().into()) + } +} + +/// The first unfrozen index; None for none. +fn frozen_index(state: Option<&str>) -> Option { + let index: u32 = state?.split('/').next()?.trim().parse().ok()?; + (index > 0).then_some(index) +} + +#[cfg(test)] +mod tests { + #[test] + fn freeze_states_read_their_index() { + assert_eq!(super::frozen_index(Some("2 / 0")), Some(2)); + assert_eq!(super::frozen_index(Some("0 / 1")), None); + assert_eq!(super::frozen_index(None), None); + } +} + +impl Zooming for Book {} diff --git a/apps/ely-table/src/cells.rs b/apps/ely-table/src/cells.rs new file mode 100644 index 0000000..4379f88 --- /dev/null +++ b/apps/ely-table/src/cells.rs @@ -0,0 +1,255 @@ +//! The Cells menu: rows, columns, merges, sizes, freeze (TABLE-02). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + forms::TextInput, + overlays::{Dialog as Card, PromptDialog}, +}; +use ely_office::prelude::*; +use gpui::{ + App, AppContext as _, Context, Div, Entity, InteractiveElement as _, Menu, MenuItem, Window, + actions, +}; + +use crate::book::Book; +use crate::dialog::Dialog; + +actions!( + table, + [ + RowsAbove, + RowsBelow, + ColumnsBefore, + ColumnsAfter, + InsertDown, + InsertRight, + DeleteRows, + DeleteColumns, + DeleteUp, + DeleteLeft, + Merge, + Split, + RowHeight, + ColumnWidth, + FitRows, + FitColumns, + HideRows, + ShowRows, + HideColumns, + ShowColumns, + Freeze + ] +); + +pub fn menu(cx: &App) -> Menu { + let label = |key: &str| Book::text(cx, key, &[]); + Menu::new(label("menu.cells")).items([ + MenuItem::action(label("table.cells.rows_above"), RowsAbove), + MenuItem::action(label("table.cells.rows_below"), RowsBelow), + MenuItem::action(label("table.cells.columns_before"), ColumnsBefore), + MenuItem::action(label("table.cells.columns_after"), ColumnsAfter), + MenuItem::action(label("table.cells.insert_down"), InsertDown), + MenuItem::action(label("table.cells.insert_right"), InsertRight), + MenuItem::separator(), + MenuItem::action(label("table.cells.delete_rows"), DeleteRows), + MenuItem::action(label("table.cells.delete_columns"), DeleteColumns), + MenuItem::action(label("table.cells.delete_up"), DeleteUp), + MenuItem::action(label("table.cells.delete_left"), DeleteLeft), + MenuItem::separator(), + MenuItem::action(label("table.cells.merge"), Merge), + MenuItem::action(label("table.cells.split"), Split), + MenuItem::separator(), + MenuItem::action(label("table.cells.row_height"), RowHeight), + MenuItem::action(label("table.cells.column_width"), ColumnWidth), + MenuItem::action(label("table.cells.fit_rows"), FitRows), + MenuItem::action(label("table.cells.fit_columns"), FitColumns), + MenuItem::separator(), + MenuItem::action(label("table.cells.hide_rows"), HideRows), + MenuItem::action(label("table.cells.show_rows"), ShowRows), + MenuItem::action(label("table.cells.hide_columns"), HideColumns), + MenuItem::action(label("table.cells.show_columns"), ShowColumns), + MenuItem::separator(), + MenuItem::action(label("table.cells.freeze"), Freeze), + ]) +} + +/// Binds an action to a command with fixed values. +macro_rules! run { + ($root:expr, $cx:expr, $action:ty, $id:literal $(, $value:literal)*) => { + $root.on_action($cx.listener(|book, _: &$action, _, cx| { + book.run_active($id, &[$($value),*], cx) + })) + }; +} + +pub fn bind(root: Div, cx: &mut Context) -> Div { + let root = run!(root, cx, RowsAbove, "rows.insert_above"); + let root = run!(root, cx, RowsBelow, "rows.insert_below"); + let root = run!(root, cx, ColumnsBefore, "columns.insert_before"); + let root = run!(root, cx, ColumnsAfter, "columns.insert_after"); + let root = run!(root, cx, InsertDown, "cells.insert", "V"); + let root = run!(root, cx, InsertRight, "cells.insert", ">"); + let root = run!(root, cx, DeleteRows, "rows.delete"); + let root = run!(root, cx, DeleteColumns, "columns.delete"); + let root = run!(root, cx, DeleteUp, "cells.delete", "U"); + let root = run!(root, cx, DeleteLeft, "cells.delete", "L"); + let root = run!(root, cx, Split, "cells.split"); + let root = run!(root, cx, FitRows, "rows.optimal", "0"); + let root = run!(root, cx, FitColumns, "columns.optimal", "0"); + let root = run!(root, cx, HideRows, "rows.hide"); + let root = run!(root, cx, ShowRows, "rows.show"); + let root = run!(root, cx, HideColumns, "columns.hide"); + let root = run!(root, cx, ShowColumns, "columns.show"); + let root = run!(root, cx, Freeze, "view.freeze"); + root.on_action(cx.listener(|book, _: &Merge, _, cx| book.merge(book.core.active, cx))) + .on_action(cx.listener(|book, _: &RowHeight, window, cx| book.ask_size(false, window, cx))) + .on_action(cx.listener(|book, _: &ColumnWidth, window, cx| book.ask_size(true, window, cx))) +} + +impl Book { + /// Merges; asks first when more than one cell holds data. + pub(crate) fn merge(&mut self, id: Option, cx: &mut Context) { + let Some(tab) = id.and_then(|id| self.tab(id)) else { + return self.run_in(id, "cells.merge", &["false"], cx); + }; + if !self.settled(tab.id, cx) { + return self.when_settled(tab.id, move |book, _, cx| book.merge(id, cx)); + } + let tab = tab.id; + self.ask_in( + tab, + |worker, doc| doc.selection_text(worker), + move |book, text, _, cx| match text { + Ok(text) if filled(&text) > 1 => { + book.dialog = Some(Dialog::Merge { tab }); + cx.notify(); + } + Ok(_) => book.run_in(id, "cells.merge", &["false"], cx), + Err(error) => book.engine_failed(&error.to_string(), cx), + }, + cx, + ); + } + + /// Sets the asked size, in 1/100 mm, in its workbook. + pub(crate) fn size_asked(&mut self, size: i32, cx: &mut Context) { + let Some(Dialog::Size { tab, column, .. }) = self.dialog.take() else { + return; + }; + let id = if column { + "columns.width_selected" + } else { + "rows.height_selected" + }; + self.run_in(Some(tab), id, &[&size.to_string()], cx); + } + + fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.core.active else { + let text = Book::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx); + }; + let field = cx.new(|cx| TextInput::new(window, cx)); + self.dialog = Some(Dialog::Size { tab, column, field }); + cx.notify(); + } +} + +/// How many cells of a tab-joined selection hold something. +fn filled(text: &str) -> usize { + text.lines() + .flat_map(|line| line.split('\t')) + .filter(|cell| !cell.is_empty()) + .count() +} + +/// Centimeters as 1/100 mm, within what the kernel takes. +fn hundredths(text: &str) -> Option { + let centimeters: f64 = text.trim().replace(',', ".").parse().ok()?; + let hundredths = (centimeters * 1000.0).round(); + (1.0..=65535.0) + .contains(&hundredths) + .then_some(hundredths as i32) +} + +pub fn size_dialog( + this: Entity, + (column, field): (bool, &Entity), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> PromptDialog { + let title = Book::text( + cx, + if column { + "table.size.column" + } else { + "table.size.row" + }, + &[], + ); + let bad = Book::text(cx, "table.size.bad", &[]); + PromptDialog::new("table-size", title, field, dismiss) + .label(Book::text(cx, "table.size.label", &[])) + .submit(Book::text(cx, "table.size.apply", &[])) + .check(move |text| hundredths(text).map(|_| ()).ok_or_else(|| bad.clone())) + .on_submit(move |text, _, cx| { + let size = hundredths(text).expect("checked"); + this.update(cx, |book, cx| book.size_asked(size, cx)) + }) +} + +pub fn merge_dialog( + this: Entity, + tab: u64, + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let choice = |id: &'static str, key: &str, moves: &'static str, variant| { + let (this, label) = (this.clone(), Book::text(cx, key, &[])); + move |_| { + Button::new(id, label) + .variant(variant) + .on_click(move |_, _, cx| { + this.update(cx, |book, cx| { + book.dialog = None; + book.run_in(Some(tab), "cells.merge", &[moves], cx); + }) + }) + } + }; + Card::new( + "table-merge", + Book::text(cx, "table.merge.title", &[]), + dismiss, + ) + .detail(Book::text(cx, "table.merge.detail", &[])) + .action(ely_office::cancel(cx)) + .action(choice( + "table-merge-keep", + "table.merge.keep", + "false", + ButtonVariant::Secondary, + )) + .action(choice( + "table-merge-move", + "table.merge.move", + "true", + ButtonVariant::Primary, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filled_cells_and_sizes() { + assert_eq!(filled("1\tb\n\t\n"), 2); + assert_eq!(filled("\t\n"), 0); + assert_eq!(hundredths("2.54"), Some(2540)); + assert_eq!(hundredths("1,5"), Some(1500)); + assert_eq!(hundredths("0"), None); + assert_eq!(hundredths("70"), None, "past 65.535 cm"); + assert_eq!(hundredths("x"), None); + } +} diff --git a/apps/ely-table/src/data.rs b/apps/ely-table/src/data.rs new file mode 100644 index 0000000..e7497ee --- /dev/null +++ b/apps/ely-table/src/data.rs @@ -0,0 +1,487 @@ +//! The Data menu: sort, filter, duplicates, columns, pastes (TABLE-08). + +use ely_gpui_component::{ + forms::TextInput, + primitives::Severity, + tables::{FilterRule, SortKey, Test}, +}; +use ely_lok::{ + Request, + sheet::{self, Entry, Selection}, +}; +use ely_office::prelude::*; +use ely_platform::clipboard::{self, Clip}; +use gpui::{ + App, AppContext as _, Context, Div, InteractiveElement as _, Menu, MenuItem, SharedString, + Window, actions, +}; + +use crate::book::Book; +use crate::dialog::Dialog; + +/// The state a selection was read in, and its sheet. +pub(crate) type ReadState = (ely_lok::Doc, usize); + +mod dialogs; +pub use dialogs::{column_dialog, columns_dialog, dedupe_dialog, filter_dialog, sort_dialog}; + +actions!( + table, + [ + Sort, + AutoFilter, + StandardFilter, + ClearFilter, + RemoveDuplicates, + TextToColumns, + PasteValues, + PasteFormulas + ] +); + +pub fn menu(cx: &App) -> Menu { + let label = |key: &str| Book::text(cx, key, &[]); + Menu::new(label("menu.data")).items([ + MenuItem::action(label("table.data.sort"), Sort), + MenuItem::separator(), + MenuItem::action(label("table.data.autofilter"), AutoFilter), + MenuItem::action(label("table.data.filter"), StandardFilter), + MenuItem::action(label("table.data.unfilter"), ClearFilter), + MenuItem::separator(), + MenuItem::action(label("table.data.dedupe"), RemoveDuplicates), + MenuItem::action(label("table.data.columns"), TextToColumns), + MenuItem::separator(), + MenuItem::action(label("office.find.open"), ely_office::FindReplace), + MenuItem::action(label("table.data.paste_values"), PasteValues), + MenuItem::action(label("table.data.paste_formulas"), PasteFormulas), + MenuItem::separator(), + MenuItem::action(label("table.rules.validation"), crate::rules::Validate), + MenuItem::action(label("table.rules.unvalidate"), crate::rules::Unvalidate), + ]) +} + +pub fn bind(root: Div, cx: &mut Context) -> Div { + root.on_action(cx.listener(|book, _: &Sort, _, cx| book.ask_data(Asked::Sort, cx))) + .on_action(cx.listener(|book, _: &StandardFilter, _, cx| book.ask_data(Asked::Filter, cx))) + .on_action( + cx.listener(|book, _: &RemoveDuplicates, _, cx| book.ask_data(Asked::Dedupe, cx)), + ) + .on_action(cx.listener(|book, _: &TextToColumns, _, cx| book.ask_columns(cx))) + .on_action( + cx.listener(|book, _: &AutoFilter, _, cx| book.run_active("data.autofilter", &[], cx)), + ) + .on_action( + cx.listener(|book, _: &ClearFilter, _, cx| book.run_active("data.unfilter", &[], cx)), + ) + .on_action(cx.listener(|book, _: &PasteValues, _, cx| book.paste_only("paste.values", cx))) + .on_action( + cx.listener(|book, _: &PasteFormulas, _, cx| book.paste_only("paste.formulas", cx)), + ) +} + +/// Which data dialog to open. +#[derive(Clone, Copy, Debug)] +pub enum Asked { + Sort, + Filter, + Dedupe, +} + +/// The kernel's operator for a builder test. +fn op(test: Test) -> &'static str { + match test { + Test::Contains => "contains", + Test::Equals => "equals", + Test::NotEquals => "notequals", + Test::Above => "greater", + Test::Below => "less", + Test::Empty => "empty", + } +} + +/// The `data.filter` query for builder rules. +pub fn query(rules: &[FilterRule], any: bool) -> String { + let conditions: Vec<_> = rules + .iter() + .enumerate() + .map(|(at, rule)| { + serde_json::json!({ + "field": rule.column.parse::().expect("columns are indexes"), + "op": op(rule.test), + "value": rule.value.as_ref(), + "or": any && at > 0, + }) + }) + .collect(); + serde_json::json!({ "conditions": conditions }).to_string() +} + +/// The `data.filter` query keeping `picked` of a column. +pub fn column_query(column: u32, entries: &[Entry], picked: &[SharedString]) -> String { + if entries + .iter() + .all(|entry| picked.iter().any(|p| *p == entry.text)) + { + return serde_json::json!({ "column": column }).to_string(); + } + let values: Vec<&str> = picked.iter().map(SharedString::as_ref).collect(); + serde_json::json!({ + "column": column, + "conditions": [{ "op": "values", "values": values }], + }) + .to_string() +} + +/// The `data.sort` values for up to three keys. +pub fn sort_values(header: bool, keys: &[SortKey]) -> Vec { + let mut values = vec![header.to_string()]; + for at in 0..3 { + match keys.get(at) { + Some(key) => { + let column: u32 = key.column.parse().expect("columns are indexes"); + values.push((column + 1).to_string()); + values.push(key.rising.to_string()); + } + None => values.extend(["0".to_string(), "true".to_string()]), + } + } + values +} + +/// A column's title: its header text, else its letters. +pub fn title(picked: &Selection, column: u32, header: bool) -> SharedString { + let (first, ..) = picked.area.expect("a data area"); + let text = picked.top[(column - first) as usize].trim(); + if header && !text.is_empty() { + text.to_string().into() + } else { + sheet::column_name(column).into() + } +} + +impl Book { + pub(crate) fn warn_key(&self, key: &str, cx: &mut Context) { + let text = Book::text(cx, key, &[]); + self.toast(text, Severity::Warning, cx); + } + + /// Reads tab `id`'s selection; a lone cell grows to data. + pub(crate) fn data_of( + &mut self, + id: u64, + grow: bool, + then: impl FnOnce(&mut Self, Selection, ReadState, &mut Window, &mut Context) + 'static, + cx: &mut Context, + ) { + self.ask_in( + id, + move |worker, doc| { + let read = |worker: &mut ely_lok::Worker| { + sheet::selected_range(&worker.values(doc, sheet::SELECTED)?) + }; + let picked = match read(worker) { + // The kernel's data area around the cell. + Ok(Some(lone)) if grow && !lone.range.contains(':') => { + worker.run(doc, "select.data", &[])?; + let grown = read(worker)?; + // No data: unmark, or Enter stays in the cell. + if grown.as_ref().is_some_and(|picked| picked.area.is_none()) { + worker.run(doc, "cell.goto", &[&lone.range])?; + } + Ok(grown) + } + other => other, + }?; + // Numbers in the picked range hold in this state. + Ok((picked, (worker.now(doc)?, doc.sheets(worker)?.current))) + }, + move |book, read, window, cx| match read { + Ok((Some(picked), state)) if picked.area.is_some() => then(book, picked, state, window, cx), + Ok((Some(_), _)) => book.warn_key("table.data.empty", cx), + Ok((None, _)) => book.warn_key("table.data.one_range", cx), + Err(error) => book.engine_failed(&error.to_string(), cx), + }, + cx, + ); + } + + pub(crate) fn ask_data(&mut self, asked: Asked, cx: &mut Context) { + let Some(id) = self.core.active else { + return self.warn_key("command.blocked.no_document", cx); + }; + self.data_in(id, asked, cx); + } + + /// The data dialog for tab `id`, once its input lands. + fn data_in(&mut self, id: u64, asked: Asked, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |book, _, cx| book.data_in(id, asked, cx)); + } + self.data_of( + id, + true, + move |book, picked, state, _, cx| book.data_dialog(id, asked, picked, state, cx), + cx, + ); + } + + fn data_dialog(&mut self, id: u64, asked: Asked, picked: Selection, state: ReadState, cx: &mut Context) { + log::info!("table: {asked:?} on {}", picked.range); + let header = picked.header; + self.dialog = Some(match asked { + Asked::Sort => { + let (first, ..) = picked.area.expect("a data area"); + Dialog::Sort { + tab: id, + keys: vec![SortKey { + column: first.to_string().into(), + rising: true, + }], + header, + picked, + } + } + Asked::Filter => Dialog::Filter { + tab: id, + rules: Vec::new(), + any: false, + picked, + }, + Asked::Dedupe => Dialog::Dedupe { + tab: id, + header, + picked, + state, + }, + }); + cx.notify(); + } + + /// Sorts, filters or removes duplicates as asked. + pub(crate) fn data_asked(&mut self, cx: &mut Context) { + let Some(dialog) = self.dialog.take() else { + return; + }; + match dialog { + Dialog::Sort { + tab, keys, header, .. + } => { + let values = sort_values(header, &keys); + let values: Vec<&str> = values.iter().map(String::as_str).collect(); + self.run_in(Some(tab), "data.sort", &values, cx); + } + Dialog::Filter { + tab, rules, any, .. + } => self.run_in(Some(tab), "data.filter", &[&query(&rules, any)], cx), + Dialog::Dedupe { + tab, + header, + picked, + state: (at, sheet), + } => { + let (c1, r1, c2, r2) = picked.area.expect("a data area"); + let sheet = sheet.to_string(); + let corners = [c1, r1, c2, r2].map(|n| n.to_string()); + let header = header.to_string(); + let mut values = vec!["true", header.as_str(), "true"]; + values.extend(corners.iter().map(String::as_str)); + values.push(&sheet); + self.run_on(tab, &at, "data.dedupe", &values, cx); + } + other => self.dialog = Some(other), + } + } + + /// Splits one column of text by the chosen separators. + pub(crate) fn ask_columns(&mut self, cx: &mut Context) { + let Some(id) = self.core.active else { + return self.warn_key("command.blocked.no_document", cx); + }; + self.columns_in(id, cx); + } + + /// The columns dialog for tab `id`, once its input lands. + pub(crate) fn columns_in(&mut self, id: u64, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |book, _, cx| book.columns_in(id, cx)); + } + self.data_of( + id, + false, + move |book, picked, _, window, cx| { + let (c1, _, c2, _) = picked.area.expect("a data area"); + if c1 != c2 { + return book.warn_key("table.columns.one", cx); + } + let other = cx.new(|cx| TextInput::new(window, cx)); + book.dialog = Some(Dialog::Columns { + tab: id, + separators: vec!["9".into()], + other, + }); + cx.notify(); + }, + cx, + ); + } + + /// Runs the split the columns dialog describes. + pub(crate) fn columns_asked(&mut self, cx: &mut Context) { + let Some(Dialog::Columns { + tab, + separators, + other, + }) = self.dialog.take() + else { + return; + }; + let mut codes: Vec = separators.iter().map(ToString::to_string).collect(); + if let Some(c) = other.read(cx).text().chars().next() { + codes.push(u32::from(c).to_string()); + } + // Separators, quote, UTF-8, first row. + let options = format!("{},34,76,1", codes.join("/")); + self.run_in(Some(tab), "data.columns", &[&options], cx); + } + + fn paste_only(&mut self, id: &'static str, cx: &mut Context) { + let Some(tab) = self.core.active else { + return self.warn_key("command.blocked.no_document", cx); + }; + self.paste_only_in(tab, id, cx); + } + + /// Values or formulas alone, from cells this workbook copied. + pub(crate) fn paste_only_in(&mut self, tab: u64, id: &'static str, cx: &mut Context) { + if !self.settled(tab, cx) { + return self.when_settled(tab, move |book, _, cx| book.paste_only_in(tab, id, cx)); + } + let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else { + return; + }; + let html = match clipboard::read() { + Ok(Some(Clip::Html { html, .. })) => Some(html), + Ok(_) => None, + Err(error) => { + let text = Book::text(cx, "table.data.clipboard", &[("error", &error.to_string())]); + return self.toast(text, Severity::Danger, cx); + } + }; + // The kernel keeps only its own cells' values and formulas. + if !html.is_some_and(|html| view.read(cx).holds_copy(&html)) { + return self.warn_key("table.data.paste_foreign", cx); + } + log::info!("table: {id}"); + view.update(cx, |view, _| view.paste_only(id)); + } + + /// The filter menu a column button asks for. + pub(crate) fn ask_column(&mut self, id: u64, request: &Request, cx: &mut Context) { + let Request::Filter { column, row } = *request else { + return; + }; + self.ask_in( + id, + move |worker, doc| { + let json = worker.values(doc, &sheet::cell_query(sheet::FILTER, column, row))?; + sheet::filter_values(&json) + }, + move |book, read, _, cx| match read { + Ok(filter) => { + let picked = filter + .entries + .iter() + .filter(|entry| entry.selected) + .map(|entry| SharedString::from(entry.text.clone())) + .collect(); + book.dialog = Some(Dialog::Column { + tab: id, + column, + entries: filter.entries, + picked, + }); + cx.notify(); + } + Err(error) => book.engine_failed(&error.to_string(), cx), + }, + cx, + ); + } + + /// Keeps the picked entries, or sorts by the column. + pub(crate) fn column_asked(&mut self, sort: Option, cx: &mut Context) { + let Some(Dialog::Column { + tab, + column, + entries, + picked, + }) = self.dialog.take() + else { + return; + }; + match sort { + Some(rising) => { + let key = SortKey { + column: column.to_string().into(), + rising, + }; + let values = sort_values(true, &[key]); + let values: Vec<&str> = values.iter().map(String::as_str).collect(); + self.run_in(Some(tab), "data.sort", &values, cx); + } + None => { + let query = column_query(column, &entries, &picked); + self.run_in(Some(tab), "data.filter", &[&query], cx); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rules_and_keys_become_kernel_values() { + let rules = [ + FilterRule { + column: "2".into(), + test: Test::Above, + value: "5".into(), + }, + FilterRule { + column: "0".into(), + test: Test::Empty, + value: "".into(), + }, + ]; + let json: serde_json::Value = serde_json::from_str(&query(&rules, true)).expect("json"); + assert_eq!(json["conditions"][0]["op"], "greater"); + assert_eq!(json["conditions"][1]["or"], true); + let keys = [SortKey { + column: "1".into(), + rising: false, + }]; + assert_eq!( + sort_values(true, &keys), + ["true", "2", "false", "0", "true", "0", "true"] + ); + let entries = [ + Entry { + text: "a".into(), + hidden: false, + selected: true, + }, + Entry { + text: "b".into(), + hidden: false, + selected: true, + }, + ]; + assert_eq!( + column_query(3, &entries, &["a".into(), "b".into()]), + r#"{"column":3}"# + ); + assert!(column_query(3, &entries, &["a".into()]).contains(r#""values":["a"]"#)); + } +} diff --git a/apps/ely-table/src/data/dialogs.rs b/apps/ely-table/src/data/dialogs.rs new file mode 100644 index 0000000..4681d23 --- /dev/null +++ b/apps/ely-table/src/data/dialogs.rs @@ -0,0 +1,311 @@ +//! The data dialogs: sort, filter, duplicates, columns. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + forms::{Checkbox, CheckboxGroup, Choice, Input, Switch, TextInput}, + layout::ScrollArea, + overlays::{Close, Dialog as Card}, + primitives::Severity, + tables::{FilterBuilder, FilterRule, SortBuilder, SortKey}, + theme::ControlSize, +}; +use ely_lok::sheet::{self, Entry, Selection}; +use ely_office::prelude::*; +use gpui::{App, Entity, ParentElement as _, SharedString, Styled as _, Window, div}; + +use super::title; +use crate::book::Book; +use crate::dialog::{Dialog, edit}; + +/// The kernel sorts by up to three keys. +const KEYS: usize = 3; + +/// The primary button that runs what the dialog asks. +fn go( + this: Entity, + (id, label): (&'static str, SharedString), + off: bool, + run: fn(&mut Book, &mut gpui::Context), +) -> impl FnOnce(Close) -> Button + 'static { + move |_| { + Button::new(id, label) + .variant(ButtonVariant::Primary) + .disabled(off) + .on_click(move |_, _, cx| this.update(cx, run)) + } +} + +fn header_switch(this: &Entity, id: &'static str, on: bool, cx: &App) -> Switch { + let this = this.clone(); + Switch::new(id, on) + .label(Book::text(cx, "table.data.header", &[])) + .on_change(move |on, _, cx| { + edit(&this, cx, |dialog, _, _| match dialog { + Dialog::Sort { header, .. } | Dialog::Dedupe { header, .. } => *header = on, + _ => {} + }) + }) +} + +pub fn sort_dialog( + this: Entity, + (picked, keys, header): (&Selection, &[SortKey], bool), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let (first, _, last, _) = picked.area.expect("a data area"); + let columns: Vec<_> = (first..=last) + .map(|column| (column.to_string(), title(picked, column, header))) + .collect(); + let keyed = this.clone(); + let builder = SortBuilder::new("table-sort-keys", columns) + .keys(keys.to_vec()) + .on_change(move |keys, _, cx| { + let mut keys = keys.to_vec(); + let over = keys.len() > KEYS; + keys.truncate(KEYS); + edit(&keyed, cx, |dialog, book, cx| { + if let Dialog::Sort { keys: held, .. } = dialog { + *held = keys; + } + if over { + let text = Book::text(cx, "table.data.three", &[]); + book.toast(text, Severity::Info, cx); + } + }) + }); + let label = Book::text(cx, "table.data.sort_go", &[]); + Card::new( + "table-sort", + Book::text(cx, "table.data.sort_title", &[]), + dismiss, + ) + .child( + div() + .flex() + .flex_col() + .gap_3() + .child(header_switch(&this, "table-sort-header", header, cx)) + .child(builder), + ) + .action(ely_office::cancel(cx)) + .action(go( + this, + ("table-sort-go", label), + keys.is_empty(), + Book::data_asked, + )) +} + +pub fn filter_dialog( + this: Entity, + (picked, rules, any): (&Selection, &[FilterRule], bool), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let (first, _, last, _) = picked.area.expect("a data area"); + let columns: Vec<_> = (first..=last) + .map(|column| (column.to_string(), title(picked, column, true))) + .collect(); + let ruled = this.clone(); + let builder = FilterBuilder::new("table-filter-rules", columns) + .rules(rules.to_vec(), any) + .on_change(move |rules, any, _, cx| { + let rules = rules.to_vec(); + edit(&ruled, cx, |dialog, _, _| { + if let Dialog::Filter { + rules: held, + any: joined, + .. + } = dialog + { + *held = rules; + *joined = any; + } + }) + }); + let label = Book::text(cx, "table.data.filter_go", &[]); + Card::new( + "table-filter", + Book::text(cx, "table.data.filter_title", &[]), + dismiss, + ) + .child(builder) + .action(ely_office::cancel(cx)) + .action(go( + this, + ("table-filter-go", label), + rules.is_empty(), + Book::data_asked, + )) +} + +pub fn dedupe_dialog( + this: Entity, + (picked, header): (&Selection, bool), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let detail = Book::text(cx, "table.data.dedupe_detail", &[("range", &picked.range)]); + let label = Book::text(cx, "table.data.dedupe_go", &[]); + let removing = this.clone(); + Card::new( + "table-dedupe", + Book::text(cx, "table.data.dedupe_title", &[]), + dismiss, + ) + .detail(detail) + .child(header_switch(&this, "table-dedupe-header", header, cx)) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("table-dedupe-go", label) + .variant(ButtonVariant::Danger) + .on_click(move |_, _, cx| removing.update(cx, |book, cx| book.data_asked(cx))) + }) +} + +/// Separators as kernel character codes. +const SEPARATORS: [(&str, &str); 4] = [ + ("9", "table.columns.tab"), + ("44", "table.columns.comma"), + ("59", "table.columns.semicolon"), + ("32", "table.columns.space"), +]; + +pub fn columns_dialog( + this: Entity, + (separators, other): (&[SharedString], &Entity), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let choices = SEPARATORS + .iter() + .map(|(code, key)| Choice::new(*code, Book::text(cx, key, &[]))); + let picked = this.clone(); + let group = CheckboxGroup::new("table-columns-separators", choices) + .selected(separators.to_vec()) + .on_change(move |values, _, cx| { + let values = values.to_vec(); + edit(&picked, cx, |dialog, _, _| { + if let Dialog::Columns { separators, .. } = dialog { + *separators = values; + } + }) + }); + let other_row = div() + .flex() + .items_center() + .gap_2() + .child(Book::text(cx, "table.columns.other", &[])) + .child(div().w_16().child(Input::new(other).size(ControlSize::Sm))); + let none = separators.is_empty() && other.read(cx).text().is_empty(); + let label = Book::text(cx, "table.columns.go", &[]); + Card::new( + "table-columns", + Book::text(cx, "table.columns.title", &[]), + dismiss, + ) + .child( + div() + .flex() + .flex_col() + .gap_3() + .child(group) + .child(other_row), + ) + .action(ely_office::cancel(cx)) + .action(go( + this, + ("table-columns-go", label), + none, + Book::columns_asked, + )) +} + +pub fn column_dialog( + this: Entity, + (column, entries, picked): (u32, &[Entry], &[SharedString]), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let empty = Book::text(cx, "table.filter.empty", &[]); + let choices = entries.iter().map(|entry| { + let label = match entry.text.is_empty() { + true => empty.clone(), + false => entry.text.clone().into(), + }; + Choice::new(entry.text.clone(), label) + }); + let checked = this.clone(); + let group = CheckboxGroup::new("table-column-entries", choices) + .selected(picked.to_vec()) + .on_change(move |values, _, cx| { + let values = values.to_vec(); + edit(&checked, cx, |dialog, _, _| { + if let Dialog::Column { picked, .. } = dialog { + *picked = values; + } + }) + }); + let every = this.clone(); + let all: Vec = entries + .iter() + .map(|entry| entry.text.clone().into()) + .collect(); + let whole = Checkbox::new("table-column-all", picked.len() == entries.len()) + .label(Book::text(cx, "table.filter.all", &[])) + .on_change(move |on, _, cx| { + let values = if on { all.clone() } else { Vec::new() }; + edit(&every, cx, |dialog, _, _| { + if let Dialog::Column { picked, .. } = dialog { + *picked = values; + } + }) + }); + let sort = |id: &'static str, key: &str, rising: bool| { + let this = this.clone(); + Button::new(id, Book::text(cx, key, &[])) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + this.update(cx, |book, cx| book.column_asked(Some(rising), cx)) + }) + }; + let title = Book::text( + cx, + "table.filter.title", + &[("column", &sheet::column_name(column))], + ); + let label = Book::text(cx, "table.filter.go", &[]); + let (applying, none) = (this.clone(), picked.is_empty()); + Card::new("table-column", title, dismiss) + .child( + div() + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .gap_2() + .child(sort("table-column-up", "table.filter.up", true)) + .child(sort("table-column-down", "table.filter.down", false)), + ) + .child(whole) + .child( + div().h_64().child( + ScrollArea::new("table-column-scroll") + .size_full() + .child(group), + ), + ), + ) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("table-column-go", label) + .variant(ButtonVariant::Primary) + .disabled(none) + .on_click(move |_, _, cx| { + applying.update(cx, |book, cx| book.column_asked(None, cx)) + }) + }) +} diff --git a/apps/ely-table/src/dialog.rs b/apps/ely-table/src/dialog.rs new file mode 100644 index 0000000..24afa0a --- /dev/null +++ b/apps/ely-table/src/dialog.rs @@ -0,0 +1,296 @@ +//! ElyTable's one open dialog. + +use ely_gpui_component::{ + forms::TextInput, + tables::{FilterRule, SortKey}, +}; +use ely_lok::sheet::{Entry, Format, References, Selection, Validation}; +use gpui::{AnyElement, App, Context, Entity, IntoElement, SharedString, Window}; + +use crate::book::Book; +use crate::{analysis, data, rules}; + +/// Changes the open dialog's state. +pub(crate) fn edit( + this: &Entity, + cx: &mut App, + change: impl FnOnce(&mut Dialog, &mut Book, &mut Context), +) { + this.update(cx, |book, cx| { + if let Some(mut dialog) = book.dialog.take() { + change(&mut dialog, book, cx); + book.dialog = Some(dialog); + } + cx.notify(); + }); +} + +/// What the open dialog asks, for which workbook. +pub enum Dialog { + Rename { + tab: u64, + /// The state the sheet number was read in. + at: ely_lok::Doc, + index: usize, + field: Entity, + }, + Show { + tab: u64, + hidden: Vec, + chosen: String, + }, + Delete { + tab: u64, + /// The state the sheet number was read in. + at: ely_lok::Doc, + index: usize, + name: String, + references: References, + }, + Size { + tab: u64, + column: bool, + field: Entity, + }, + Merge { + tab: u64, + }, + Define { + tab: u64, + name: Entity, + range: Entity, + scope: String, + }, + Names { + tab: u64, + names: Vec, + chosen: usize, + range: Entity, + }, + Sort { + tab: u64, + picked: Selection, + keys: Vec, + header: bool, + }, + Filter { + tab: u64, + picked: Selection, + rules: Vec, + any: bool, + }, + Dedupe { + tab: u64, + picked: Selection, + header: bool, + state: crate::data::ReadState, + }, + Columns { + tab: u64, + separators: Vec, + other: Entity, + }, + /// A column button's filter menu. + Column { + tab: u64, + column: u32, + entries: Vec, + picked: Vec, + }, + Validation { + tab: u64, + rule: Box, + fields: Box, + }, + Conditional { + tab: u64, + kind: &'static str, + operator: &'static str, + style: &'static str, + preset: usize, + value1: Entity, + value2: Entity, + }, + Formats { + tab: u64, + formats: Vec, + chosen: usize, + }, + Pivot { + tab: u64, + source: String, + fields: Vec, + layout: ely_lok::sheet::Layout, + /// Changes the pivot table at the cursor. + editing: bool, + }, + Chart { + tab: u64, + /// The selected chart; none inserts one. + chart: Option>, + look: Box, + fields: Box, + }, + /// A text file before it opens. + Import(Box), + Export { + tab: u64, + format: crate::output::Format, + }, + Page { + tab: u64, + layout: Box, + landscape: bool, + scale: ely_lok::sheet::Scale, + fields: Box, + }, + Protect { + tab: u64, + protecting: Box, + }, +} + +impl Book { + /// The define dialog's sheets: its own workbook's. + pub(crate) fn define_scopes(&self) -> Option> { + let Some(Dialog::Define { tab, .. }) = &self.dialog else { + return None; + }; + self.sheet_in(*tab).map(|(_, _, names)| names) + } + + /// ElyTable's open dialog, if any. + pub fn dialog_element(&mut self, cx: &mut Context) -> Option { + let this = cx.entity(); + let dismiss = { + let this = this.clone(); + move |_: &mut Window, cx: &mut App| { + this.update(cx, |book, cx| { + book.dialog = None; + cx.notify(); + }) + } + }; + let element = match self.dialog.as_ref()? { + Dialog::Rename { tab, index, field, .. } => { + let names = self.sheet_in(*tab)?.2; + crate::sheets::dialogs::rename(this, (*index, field, names), dismiss, cx) + .into_any_element() + } + Dialog::Show { + tab, + hidden, + chosen, + } => crate::sheets::dialogs::show(this, (*tab, hidden, chosen), dismiss, cx) + .into_any_element(), + Dialog::Delete { + name, references, .. + } => crate::sheets::dialogs::delete(this, (name, references), dismiss, cx) + .into_any_element(), + Dialog::Size { column, field, .. } => { + crate::cells::size_dialog(this, (*column, field), dismiss, cx).into_any_element() + } + Dialog::Merge { tab } => { + crate::cells::merge_dialog(this, *tab, dismiss, cx).into_any_element() + } + Dialog::Define { + name, range, scope, .. + } => crate::formulas::define_dialog( + this, + (name, range, scope, self.define_scopes()?), + dismiss, + cx, + ) + .into_any_element(), + Dialog::Names { + names, + chosen, + range, + .. + } => crate::formulas::names_dialog(this, (names, *chosen, range), dismiss, cx) + .into_any_element(), + Dialog::Sort { + picked, + keys, + header, + .. + } => data::sort_dialog(this, (picked, keys, *header), dismiss, cx).into_any_element(), + Dialog::Filter { + picked, rules, any, .. + } => data::filter_dialog(this, (picked, rules, *any), dismiss, cx).into_any_element(), + Dialog::Dedupe { picked, header, .. } => { + data::dedupe_dialog(this, (picked, *header), dismiss, cx).into_any_element() + } + Dialog::Columns { + separators, other, .. + } => data::columns_dialog(this, (separators, other), dismiss, cx).into_any_element(), + Dialog::Column { + column, + entries, + picked, + .. + } => data::column_dialog(this, (*column, entries, picked), dismiss, cx) + .into_any_element(), + Dialog::Validation { rule, fields, .. } => { + rules::validation_dialog(this, (rule, fields), dismiss, cx).into_any_element() + } + Dialog::Conditional { + kind, + operator, + style, + preset, + value1, + value2, + .. + } => { + let shown = rules::Shown { + kind, + operator, + style, + preset: *preset, + value1, + value2, + }; + rules::conditional_dialog(this, shown, dismiss, cx).into_any_element() + } + Dialog::Formats { + formats, chosen, .. + } => rules::formats_dialog(this, (formats, *chosen), dismiss, cx).into_any_element(), + Dialog::Pivot { + source, + fields, + layout, + editing, + .. + } => analysis::pivot_dialog(this, (source, fields, layout, *editing), dismiss, cx) + .into_any_element(), + Dialog::Chart { + chart, + look, + fields, + .. + } => analysis::chart_dialog(this, (chart.as_deref(), look, fields), dismiss, cx) + .into_any_element(), + Dialog::Import(pending) => { + crate::text::import_dialog(this, pending, dismiss, cx).into_any_element() + } + Dialog::Export { tab, format } => { + let sheet = self.sheet_in(*tab)?.1; + crate::output::export_dialog(this, (*format, &sheet), dismiss, cx) + .into_any_element() + } + Dialog::Page { + layout, + landscape, + scale, + fields, + .. + } => crate::page::page_dialog(this, (layout, *landscape, *scale, fields), dismiss, cx) + .into_any_element(), + Dialog::Protect { protecting, .. } => { + crate::protect::protect_dialog(this, protecting, dismiss, cx).into_any_element() + } + }; + Some(element) + } +} diff --git a/apps/ely-table/src/figures.rs b/apps/ely-table/src/figures.rs new file mode 100644 index 0000000..9aab803 --- /dev/null +++ b/apps/ely-table/src/figures.rs @@ -0,0 +1,97 @@ +//! The kernel's status texts in the interface language (TABLE-03, 05). + +use gpui::{App, SharedString}; + +use crate::book::Book; + +/// The kernel's English names of the selection figures. +const FIGURES: [(&str, &str); 7] = [ + ("Sum", "table.figure.sum"), + ("Selection count", "table.figure.selected"), + ("Count", "table.figure.count"), + ("CountA", "table.figure.counta"), + ("Average", "table.figure.average"), + ("Max", "table.figure.max"), + ("Min", "table.figure.min"), +]; + +/// The kernel's explanations of a cell's error. +const ERRORS: [(&str, &str); 25] = [ + ("Error: Division by zero", "table.error.div_zero"), + ("Error: No value", "table.error.no_value"), + ("Error: Not a valid reference", "table.error.no_ref"), + ("Error: Invalid name", "table.error.no_name"), + ("Error: Invalid numeric value", "table.error.number"), + ("Error: Value not available", "table.error.not_available"), + ("Error: Add-in not found", "table.error.no_addin"), + ("Error: Macro not found", "table.error.no_macro"), + ("Internal syntactical error", "table.error.syntax"), + ("Error: No code or intersection", "table.error.no_code"), + ("Error: Invalid argument", "table.error.argument"), + ("Error in parameter list", "table.error.parameters"), + ("Error: Invalid character", "table.error.character"), + ("Error: in bracketing", "table.error.brackets"), + ("Error: Operator missing", "table.error.operator"), + ("Error: Variable missing", "table.error.variable"), + ("Error: Formula overflow", "table.error.formula_overflow"), + ("Error: String overflow", "table.error.string_overflow"), + ("Error: Internal overflow", "table.error.internal_overflow"), + ("Error: Array or matrix size", "table.error.array_size"), + ("Error: Circular reference", "table.error.circular"), + ( + "Error: Calculation does not converge", + "table.error.converge", + ), + ( + "Nested arrays are not supported.", + "table.error.nested_array", + ), + ( + "Unsupported inline array content.", + "table.error.array_content", + ), + ("External content disabled.", "table.error.external"), +]; + +/// The kernel's cell status: its error, else the selection figures. +pub fn label(status: &str, cx: &App) -> SharedString { + if let Some((_, key)) = ERRORS.iter().find(|(known, _)| *known == status) { + return Book::text(cx, key, &[]); + } + let parts: Vec = status + .split("; ") + .map(|part| { + let known = part.split_once(": ").and_then(|(name, value)| { + let (_, key) = FIGURES.iter().find(|(known, _)| *known == name)?; + Some(Book::text(cx, key, &[("value", value.trim())]).to_string()) + }); + known.unwrap_or_else(|| { + log::warn!("table: the kernel status {part:?} has no label"); + part.to_string() + }) + }) + .collect(); + parts.join(" ").into() +} + +#[cfg(test)] +mod tests { + #[test] + fn kernel_status_takes_the_interface_language() { + let cx = gpui::TestAppContext::single(); + cx.update(|app| { + ely_ui::settings::install_defaults(app); + ely_ui::i18n::install(app); + let label = super::label("Average: 21; Sum: 42; Odd: 1", app); + let expect = format!( + "{} {} Odd: 1", + super::Book::text(app, "table.figure.average", &[("value", "21")]), + super::Book::text(app, "table.figure.sum", &[("value", "42")]) + ); + assert_eq!(label.as_ref(), expect, "known names relabel, others stay"); + for (english, key) in super::ERRORS { + assert_eq!(super::label(english, app), super::Book::text(app, key, &[])); + } + }); + } +} diff --git a/apps/ely-table/src/format.rs b/apps/ely-table/src/format.rs new file mode 100644 index 0000000..b5879cf --- /dev/null +++ b/apps/ely-table/src/format.rs @@ -0,0 +1,466 @@ +//! The format panel: numbers, alignment, borders, fill (TABLE-07). + +use std::sync::{Arc, Mutex}; + +use ely_gpui_component::{ + buttons::{Button, ToggleButton, ToggleItem}, + data_display::{PropertyGrid, PropertyGroup}, + forms::{Choice, ColorPalette, ColorSwatch, Input, InputEvent, Select, TextInput}, + layout::ScrollArea, + overlays::Popover, + primitives::{IconName, Severity}, + theme::ControlSize, +}; +use ely_lok::{Doc, LokView, Reported, Worker, sheet}; +use ely_office::prelude::*; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, Hsla, IntoElement, Menu, MenuItem, + ParentElement as _, Render, SharedString, Styled as _, Subscription, WeakEntity, Window, + actions, div, rgb, +}; + +use crate::book::{Book, Spot}; + +actions!(table, [FormatPanel]); + +pub fn menu(cx: &App) -> Menu { + let label = |key: &str| Book::text(cx, key, &[]); + Menu::new(label("menu.format")).items([ + MenuItem::action(label("table.format.panel"), FormatPanel), + MenuItem::separator(), + MenuItem::action(label("table.rules.conditional"), crate::rules::Conditional), + MenuItem::action( + label("table.rules.conditionals"), + crate::rules::Conditionals, + ), + MenuItem::separator(), + MenuItem::action(label("table.protect.lock"), crate::protect::LockCells), + MenuItem::action(label("table.protect.unlock"), crate::protect::UnlockCells), + ]) +} + +/// Number kinds the panel sets, and their commands. +const KINDS: [(&str, &str, &str); 5] = [ + ("general", "number.general", "table.format.general"), + ("number", "number.decimal", "table.format.decimal"), + ("currency", "number.currency", "table.format.currency"), + ("percent", "number.percent", "table.format.percent"), + ("date", "number.date", "table.format.date"), +]; + +/// Fill colors, 0xRRGGBB. +const FILLS: [u32; 10] = [ + 0xFFFFFF, 0xD9D9D9, 0xFFC7CE, 0xFFEB9C, 0xC6EFCE, 0xBDD7EE, 0xF8CBAD, 0xE4DFEC, 0x808080, + 0x000000, +]; + +/// The workbook the panel formats. +struct Shown { + view: Entity, + doc: Doc, + _observe: Subscription, +} + +pub struct Panel { + book: WeakEntity, + worker: Arc>, + shown: Option, + code: Entity, + /// The code field has the user's typing. + editing: bool, + /// The cell of the last read. + read: Option, + /// A read is on its way. + reading: bool, + /// The cell's format code and kind. + pub(crate) number: Option<(String, String)>, + _code: Subscription, +} + +impl Panel { + pub fn new( + book: WeakEntity, + worker: Arc>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let name = Book::text(cx, "table.format.code", &[]); + let code = cx.new(|cx| TextInput::new(window, cx).label(name)); + let subscription = cx.subscribe(&code, |panel, code, event, cx| match event { + InputEvent::Focus => panel.editing = true, + InputEvent::Blur => { + panel.editing = false; + panel.read = None; + panel.reread(cx); + } + InputEvent::Submit => { + let text = code.read(cx).text().trim().to_string(); + panel.apply_code(&text, cx); + } + InputEvent::Changed => {} + }); + Panel { + book, + worker, + shown: None, + code, + editing: false, + read: None, + reading: false, + number: None, + _code: subscription, + } + } + + /// Follows the active workbook. + pub fn show(&mut self, tab: Option<(&Entity, &Doc)>, cx: &mut Context) { + let id = tab.map(|(view, _)| view.entity_id()); + if self.shown.as_ref().map(|shown| shown.view.entity_id()) == id { + return; + } + self.shown = tab.map(|(view, doc)| Shown { + view: view.clone(), + doc: doc.clone(), + _observe: cx.subscribe(view, |panel, _, _: &Reported, cx| { + panel.reread(cx); + cx.notify(); + }), + }); + self.read = None; + self.reread(cx); + } + + /// Rereads the number format after a move or edit. + fn reread(&mut self, cx: &mut Context) { + let Some(shown) = &self.shown else { + return; + }; + let view = shown.view.read(cx); + let at: Spot = ( + view.cell().sheets, + view.cell().address.clone(), + view.edited(), + ); + if self.reading || !view.settled() || self.read.as_ref() == Some(&at) { + return; + } + (self.read, self.reading) = (Some(at), true); + let reader = shown.view.entity_id(); + ely_lok::read_off( + &self.worker, + &shown.doc, + |worker, doc| sheet::number_format(&worker.values(doc, sheet::NUMBER)?), + move |panel: &mut Self, read, cx| { + panel.reading = false; + if panel.shown.as_ref().map(|shown| shown.view.entity_id()) != Some(reader) { + return panel.reread(cx); + } + match read { + Ok(number) => { + if !panel.editing { + let code = number.0.clone(); + panel.code.update(cx, |input, cx| input.set_text(code, cx)); + } + panel.number = Some(number); + } + Err(error) => panel.toast(&format!("{error}"), cx), + } + cx.notify(); + // The cell may have moved meanwhile. + panel.reread(cx); + }, + cx, + ); + } + + fn toast(&self, error: &str, cx: &mut Context) { + if let Some(book) = self.book.upgrade() { + book.update(cx, |book, cx| book.engine_failed(error, cx)); + } + } + + /// Sets a typed format code once the kernel takes it. + pub(crate) fn apply_code(&mut self, code: &str, cx: &mut Context) { + let Some(shown) = &self.shown else { + return; + }; + // The workbook that asked, not the one active later. + let Some(tab) = self + .book + .upgrade() + .and_then(|book| book.read(cx).core.active) + else { + return; + }; + let code = code.to_string(); + let asked = code.clone(); + ely_lok::read_off( + &self.worker, + &shown.doc, + move |worker, doc| { + sheet::number_valid(&worker.values(doc, &sheet::number_check_query(&asked))?) + }, + move |panel: &mut Self, check, cx| match check { + Ok(true) => { + if let Some(book) = panel.book.upgrade() { + book.update(cx, |book, cx| { + book.run_in(Some(tab), "number.code", &[&code], cx) + }); + } + } + Ok(false) => { + if let Some(book) = panel.book.upgrade() { + book.update(cx, |book, cx| { + let text = Book::text(cx, "table.format.invalid", &[("code", &code)]); + book.toast(text, Severity::Warning, cx); + }); + } + } + Err(error) => panel.toast(&error.to_string(), cx), + }, + cx, + ); + } + + fn state(&self, uno: &str, cx: &App) -> Option { + let view = self.shown.as_ref()?.view.read(cx); + view.state(uno).map(str::to_string) + } + + /// A kernel-lit toggle for `command`. + fn toggle( + &self, + command: &'static str, + icon: Option, + key: &str, + cx: &Context, + ) -> AnyElement { + let uno = ely_lok::commands::find(command) + .expect("a table command") + .uno; + let on = self.state(uno, cx).as_deref() == Some("true"); + let mut item = ToggleItem::new(command).tooltip(Book::text(cx, key, &[])); + item = match icon { + Some(icon) => item.icon(icon), + None => item.label(Book::text(cx, key, &[])), + }; + let book = self.book.clone(); + ToggleButton::new(SharedString::from(command), item, on) + .size(ControlSize::Sm) + .on_toggle(move |_, _, cx| { + let wrap = (!on).to_string(); + let values: &[&str] = if command == "cells.wrap" { + &[&wrap] + } else { + &[] + }; + run(&book, command, values, cx); + }) + .into_any_element() + } + + fn button(&self, command: &'static str, key: &str, cx: &Context) -> AnyElement { + let book = self.book.clone(); + Button::new(SharedString::from(command), Book::text(cx, key, &[])) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| run(&book, command, &[], cx)) + .into_any_element() + } + + fn number_group(&self, cx: &Context) -> PropertyGroup { + let label = |key: &str| Book::text(cx, key, &[]); + let kind = self + .number + .as_ref() + .map_or("general", |(_, kind)| kind.as_str()); + let listed = KINDS.iter().any(|(value, ..)| *value == kind); + let mut choices: Vec = KINDS + .iter() + .map(|(value, _, key)| Choice::new(*value, label(key))) + .collect(); + if !listed { + choices.push(Choice::new(kind.to_string(), label("table.format.custom"))); + } + let book = self.book.clone(); + let kinds = Select::new("table-format-kind", choices) + .label(label("table.format.kind")) + .selected(kind.to_string()) + .size(ControlSize::Sm) + .on_change(move |value, _, cx| { + if let Some((_, command, _)) = + KINDS.iter().find(|(kind, ..)| *kind == value.as_ref()) + { + run(&book, command, &[], cx); + } + }); + PropertyGroup::new(label("table.format.number")) + .row(label("table.format.kind"), kinds) + .row( + label("table.format.code"), + Input::new(&self.code).size(ControlSize::Sm), + ) + } + + fn align_group(&self, cx: &Context) -> PropertyGroup { + let label = |key: &str| Book::text(cx, key, &[]); + let across = row([ + self.toggle( + "align.left", + Some(IconName::AlignLeft), + "table.format.left", + cx, + ), + self.toggle( + "align.center", + Some(IconName::AlignCenter), + "table.format.center", + cx, + ), + self.toggle( + "align.right", + Some(IconName::AlignRight), + "table.format.right", + cx, + ), + self.toggle( + "align.justify", + Some(IconName::AlignJustify), + "table.format.justify", + cx, + ), + ]); + let down = row([ + self.toggle( + "align.top", + Some(IconName::AlignStartHorizontal), + "table.format.top", + cx, + ), + self.toggle( + "align.middle", + Some(IconName::AlignCenterHorizontal), + "table.format.middle", + cx, + ), + self.toggle( + "align.bottom", + Some(IconName::AlignEndHorizontal), + "table.format.bottom", + cx, + ), + ]); + PropertyGroup::new(label("table.format.alignment")) + .row(label("table.format.across"), across) + .row(label("table.format.down"), down) + .row( + label("table.format.text"), + self.toggle("cells.wrap", None, "table.format.wrap", cx), + ) + } + + fn cell_group(&self, cx: &Context) -> PropertyGroup { + let label = |key: &str| Book::text(cx, key, &[]); + let borders = row([ + self.button("cells.borders_all", "table.format.borders_all", cx), + self.button("cells.borders_outer", "table.format.borders_outer", cx), + self.button("cells.borders_none", "table.format.borders_none", cx), + ]); + PropertyGroup::new(label("table.format.cell")) + .row(label("table.format.borders"), borders) + .row(label("table.format.fill"), self.fill(cx)) + } + + /// The cell's fill as a swatch; it opens the palette. + fn fill(&self, cx: &Context) -> AnyElement { + let current = self + .state(".uno:BackgroundColor", cx) + .and_then(|color| color.parse::().ok()) + .and_then(|color| u32::try_from(color).ok()); + let (shown, tip) = match current { + Some(color) => (Hsla::from(rgb(color)), format!("#{color:06X}").into()), + None => ( + gpui::transparent_black(), + Book::text(cx, "table.format.no_fill", &[]), + ), + }; + let (book, none) = ( + self.book.clone(), + Book::text(cx, "table.format.no_fill", &[]), + ); + Popover::with_opener( + "table-fill-popover", + move |toggle| { + ColorSwatch::new("table-fill-swatch", shown) + .tooltip(tip) + .on_click(move |window, cx| toggle(window, cx)) + }, + move |close, _, _| { + let swatches = FILLS.map(|color| (format!("#{color:06X}"), Hsla::from(rgb(color)))); + let picked = book.clone(); + let mut palette = ColorPalette::new("table-fill", swatches.clone()) + .columns(5) + .on_change({ + let close = close.clone(); + move |color, window, cx| { + let at = swatches + .iter() + .position(|(_, swatch)| *swatch == color) + .expect("the palette hands back its own colors"); + close(window, cx); + run(&picked, "cells.fill", &[&FILLS[at].to_string()], cx); + } + }); + if let Some(color) = current.filter(|color| FILLS.contains(color)) { + palette = palette.selected(rgb(color)); + } + let book = book.clone(); + let clear = Button::new("table-fill-none", none.clone()) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + close(window, cx); + run(&book, "cells.fill", &["-1"], cx); + }); + div() + .flex() + .flex_col() + .gap_2() + .p_2() + .child(palette) + .child(clear) + }, + ) + .into_any_element() + } +} + +/// Runs a command on the active workbook. +fn run(book: &WeakEntity, id: &str, values: &[&str], cx: &mut App) { + if let Some(book) = book.upgrade() { + book.update(cx, |book, cx| book.run_active(id, values, cx)); + } +} + +/// Controls side by side, wrapping when narrow. +fn row(items: impl IntoIterator) -> AnyElement { + div() + .flex() + .flex_wrap() + .items_center() + .gap_1() + .children(items) + .into_any_element() +} + +impl Render for Panel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let grid = PropertyGrid::new("table-format") + .group(self.number_group(cx)) + .group(self.align_group(cx)) + .group(self.cell_group(cx)); + div().size_full().child( + ScrollArea::new("table-format-scroll").vertical() + .size_full() + .child(grid), + ) + } +} diff --git a/apps/ely-table/src/formulas.rs b/apps/ely-table/src/formulas.rs new file mode 100644 index 0000000..bc87156 --- /dev/null +++ b/apps/ely-table/src/formulas.rs @@ -0,0 +1,272 @@ +//! The Formula menu: calculation, names, tracing (TABLE-03..06). + +use ely_gpui_component::{forms::TextInput, primitives::Severity}; +use ely_lok::sheet; +use ely_office::prelude::*; +use gpui::{App, AppContext as _, Context, Div, InteractiveElement as _, Menu, MenuItem, actions}; + +use crate::book::Book; +use crate::dialog::Dialog; + +mod dialogs; +pub use dialogs::{define_dialog, names_dialog}; + +actions!( + table, + [ + AutoCalculate, + Recalculate, + RecalculateAll, + DefineName, + ManageNames, + TraceError, + TracePrecedents, + TraceDependents, + RemoveArrows + ] +); + +pub fn menu(cx: &App) -> Menu { + let label = |key: &str| Book::text(cx, key, &[]); + Menu::new(label("menu.formula")).items([ + MenuItem::action(label("table.calc.auto"), AutoCalculate), + MenuItem::action(label("table.calc.now"), Recalculate), + MenuItem::action(label("table.calc.all"), RecalculateAll), + MenuItem::separator(), + MenuItem::action(label("table.names.define"), DefineName), + MenuItem::action(label("table.names.manage"), ManageNames), + MenuItem::separator(), + MenuItem::action(label("table.trace.error"), TraceError), + MenuItem::action(label("table.trace.precedents"), TracePrecedents), + MenuItem::action(label("table.trace.dependents"), TraceDependents), + MenuItem::action(label("table.trace.clear"), RemoveArrows), + ]) +} + +/// Binds an action to a command without values. +macro_rules! run { + ($root:expr, $cx:expr, $action:ty, $id:literal) => { + $root.on_action($cx.listener(|book, _: &$action, _, cx| book.run_active($id, &[], cx))) + }; +} + +pub fn bind(root: Div, cx: &mut Context) -> Div { + let root = run!(root, cx, Recalculate, "calc.now"); + let root = run!(root, cx, RecalculateAll, "calc.all"); + let root = run!(root, cx, TraceError, "trace.error"); + let root = run!(root, cx, TracePrecedents, "trace.precedents"); + let root = run!(root, cx, TraceDependents, "trace.dependents"); + let root = run!(root, cx, RemoveArrows, "trace.clear"); + root.on_action(cx.listener(|book, _: &AutoCalculate, _, cx| book.toggle_calculation(cx))) + .on_action(cx.listener(|book, _: &DefineName, _, cx| book.ask_define(cx))) + .on_action(cx.listener(|book, _: &ManageNames, _, cx| book.ask_names(cx))) +} + +impl Book { + /// Whether the active workbook calculates by itself. + pub fn automatic(&self, cx: &App) -> Option { + let tab = self.active_tab()?; + match tab.view.read(cx).state(".uno:AutomaticCalculation")? { + "true" => Some(true), + "false" => Some(false), + other => { + log::warn!("table: calculation state {other:?}"); + None + } + } + } + + fn toggle_calculation(&mut self, cx: &mut Context) { + let Some(automatic) = self.automatic(cx) else { + let text = Book::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + let next = (!automatic).to_string(); + self.run_active("calc.auto", &[&next], cx); + } + + /// Asks for a name, prefilled with the selection. + pub(crate) fn ask_define(&mut self, cx: &mut Context) { + let Some(id) = self.core.active else { + let text = Book::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + self.define_in(id, cx); + } + + /// The define dialog for tab `id`, once its input lands. + fn define_in(&mut self, id: u64, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |book, _, cx| book.define_in(id, cx)); + } + self.ask_in( + id, + |worker, doc| sheet::selected_range(&worker.values(doc, sheet::SELECTED)?), + move |book, read, window, cx| { + let refers = match read { + Ok(Some(picked)) => picked.range, + Ok(None) => { + // Several ranges: the person types it. + let text = Book::text(cx, "table.names.unresolved", &[]); + book.toast(text, Severity::Info, cx); + String::new() + } + Err(error) => return book.engine_failed(&error.to_string(), cx), + }; + let name = cx.new(|cx| TextInput::new(window, cx)); + let range = cx.new(|cx| TextInput::new(window, cx)); + range.update(cx, |range, cx| range.set_text(refers, cx)); + book.dialog = Some(Dialog::Define { + tab: id, + name, + range, + scope: String::new(), + }); + cx.notify(); + }, + cx, + ); + } + + /// Defines the asked name once the kernel takes it (TABLE-06). + pub(crate) fn define(&mut self, cx: &mut Context) { + let Some(Dialog::Define { + tab, + name, + range, + scope, + }) = &self.dialog + else { + return; + }; + let (tab, scope) = (*tab, scope.clone()); + let name = name.read(cx).text().trim().to_string(); + let range = range.read(cx).text().trim().to_string(); + let asked = name.clone(); + self.read_in( + tab, + move |worker, doc| { + let names = sheet::names(&worker.values(doc, sheet::NAMES)?)?; + let check = + sheet::name_check(&worker.values(doc, &sheet::name_check_query(&asked))?)?; + Ok((names, check)) + }, + move |book, read, _, cx| { + let (names, check) = match read { + Ok(read) => read, + Err(error) => return book.engine_failed(&error.to_string(), cx), + }; + let lower = name.to_lowercase(); + let taken = names + .iter() + .any(|known| known.scope == scope && known.name.to_lowercase() == lower); + let refused = match check { + _ if taken => Some("table.names.taken"), + "valid" => None, + "address" => Some("table.names.address"), + _ => Some("table.names.characters"), + }; + if let Some(key) = refused { + let text = Book::text(cx, key, &[("name", &name)]); + return book.toast(text, Severity::Warning, cx); + } + book.dialog = None; + book.run_in(Some(tab), "names.define", &[&name, &range, &scope], cx); + }, + cx, + ); + } + + /// Lists the names to change, jump to or remove. + fn ask_names(&mut self, cx: &mut Context) { + let Some(id) = self.core.active else { + let text = Book::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + self.names_in(id, cx); + } + + /// The names dialog for tab `id`, once its input lands. + pub(crate) fn names_in(&mut self, id: u64, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |book, _, cx| book.names_in(id, cx)); + } + self.ask_in( + id, + |worker, doc| sheet::names(&worker.values(doc, sheet::NAMES)?), + move |book, read, window, cx| { + let names = match read { + Ok(names) => names, + Err(error) => return book.engine_failed(&error.to_string(), cx), + }; + let Some(first) = names.first() else { + let text = Book::text(cx, "table.names.none", &[]); + return book.toast(text, Severity::Info, cx); + }; + let range = cx.new(|cx| TextInput::new(window, cx)); + let formula = first.formula.clone(); + range.update(cx, |range, cx| range.set_text(formula, cx)); + book.dialog = Some(Dialog::Names { + tab: id, + names, + chosen: 0, + range, + }); + cx.notify(); + }, + cx, + ); + } + + /// Picks name `index` in the open list. + pub(crate) fn choose_name(&mut self, index: usize, cx: &mut Context) { + if let Some(Dialog::Names { + names, + chosen, + range, + .. + }) = &mut self.dialog + { + *chosen = index; + let formula = names[index].formula.clone(); + range.update(cx, |range, cx| range.set_text(formula, cx)); + } + cx.notify(); + } + + /// Applies, jumps to or removes the chosen name. + pub(crate) fn act_on_name(&mut self, act: NameAct, cx: &mut Context) { + let Some(Dialog::Names { + tab, + names, + chosen, + range, + }) = self.dialog.take() + else { + return; + }; + let name = &names[chosen]; + let tab = Some(tab); + match act { + NameAct::Apply => { + let range = range.read(cx).text().trim().to_string(); + self.run_in(tab, "names.define", &[&name.name, &range, &name.scope], cx); + } + NameAct::Go => { + // The jump shows its own workbook. + self.core.active = tab; + self.run_in(tab, "cell.goto", &[&name.range], cx); + } + NameAct::Remove => self.run_in(tab, "names.remove", &[&name.name, &name.scope], cx), + } + cx.notify(); + } +} + +/// What the names list does with the chosen name. +#[derive(Clone, Copy)] +pub enum NameAct { + Apply, + Go, + Remove, +} diff --git a/apps/ely-table/src/formulas/dialogs.rs b/apps/ely-table/src/formulas/dialogs.rs new file mode 100644 index 0000000..962df03 --- /dev/null +++ b/apps/ely-table/src/formulas/dialogs.rs @@ -0,0 +1,147 @@ +//! The name dialogs: define, and the list (TABLE-06). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Choice, Input, Select, TextInput}, + overlays::Dialog as Card, +}; +use ely_lok::sheet::Name; +use gpui::{App, Entity, ParentElement as _, SharedString, Styled as _, Window, div}; + +use super::NameAct; +use crate::book::Book; +use crate::dialog::Dialog; + +/// What the scope list shows: the workbook, then each sheet. +fn scope_label(scope: &str, cx: &App) -> SharedString { + if scope.is_empty() { + Book::text(cx, "table.names.workbook", &[]) + } else { + scope.to_string().into() + } +} + +pub fn define_dialog( + this: Entity, + (name, range, scope, sheets): (&Entity, &Entity, &str, Vec), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let scopes = std::iter::once(String::new()) + .chain(sheets) + .map(|scope| Choice::new(scope.clone(), scope_label(&scope, cx))); + let picked = this.clone(); + let scope = Select::new("table-define-scope", scopes) + .selected(scope.to_string()) + .on_change(move |value, _, cx| { + picked.update(cx, |book, cx| { + if let Some(Dialog::Define { scope, .. }) = &mut book.dialog { + *scope = value.to_string(); + } + cx.notify(); + }) + }); + let fields = DescriptionList::new() + .item( + Book::text(cx, "table.names.name", &[]), + div().w_full().max_w_64().child(Input::new(name)), + ) + .item( + Book::text(cx, "table.names.refers", &[]), + div().w_full().max_w_64().child(Input::new(range)), + ) + .item(Book::text(cx, "table.names.scope", &[]), scope); + let empty = name.read(cx).text().trim().is_empty() || range.read(cx).text().trim().is_empty(); + let label = Book::text(cx, "table.names.go_define", &[]); + Card::new( + "table-define", + Book::text(cx, "table.names.define_title", &[]), + dismiss, + ) + .child(fields) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("table-define-go", label) + .variant(ButtonVariant::Primary) + .disabled(empty) + .on_click(move |_, _, cx| this.update(cx, |book, cx| book.define(cx))) + }) +} + +/// One name as the list shows it. +fn entry(name: &Name, cx: &App) -> SharedString { + let args = [ + ("name", name.name.as_str()), + ("scope", &scope_label(&name.scope, cx)), + ("formula", name.formula.as_str()), + ]; + let key = if name.broken { + "table.names.entry_broken" + } else { + "table.names.entry" + }; + Book::text(cx, key, &args) +} + +pub fn names_dialog( + this: Entity, + (names, chosen, range): (&[Name], usize, &Entity), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let choices = names + .iter() + .enumerate() + .map(|(index, name)| Choice::new(index.to_string(), entry(name, cx))); + let picked = this.clone(); + let list = Select::new("table-names-pick", choices) + .selected(chosen.to_string()) + .on_change(move |value, _, cx| { + let index: usize = value.parse().expect("names are listed by index"); + picked.update(cx, |book, cx| book.choose_name(index, cx)) + }); + let fields = DescriptionList::new() + .item(Book::text(cx, "table.names.name", &[]), list) + .item( + Book::text(cx, "table.names.refers", &[]), + div().w_full().max_w_64().child(Input::new(range)), + ); + let nowhere = names[chosen].broken || names[chosen].range.is_empty(); + let act = |id: &'static str, key: &str, act: NameAct, variant, off: bool| { + let (this, label) = (this.clone(), Book::text(cx, key, &[])); + move |_| { + Button::new(id, label) + .variant(variant) + .disabled(off) + .on_click(move |_, _, cx| this.update(cx, |book, cx| book.act_on_name(act, cx))) + } + }; + Card::new( + "table-names", + Book::text(cx, "table.names.manage_title", &[]), + dismiss, + ) + .child(fields) + .action(act( + "table-names-remove", + "table.names.remove", + NameAct::Remove, + ButtonVariant::Danger, + false, + )) + .action(act( + "table-names-go", + "table.names.goto", + NameAct::Go, + ButtonVariant::Secondary, + nowhere, + )) + .action(act( + "table-names-apply", + "table.names.apply", + NameAct::Apply, + ButtonVariant::Primary, + false, + )) +} diff --git a/apps/ely-table/src/grid.rs b/apps/ely-table/src/grid.rs new file mode 100644 index 0000000..fc87bc9 --- /dev/null +++ b/apps/ely-table/src/grid.rs @@ -0,0 +1,361 @@ +//! Headers, sizing drags and the fill handle (TABLE-02). + +use ely_gpui_component::{primitives::FocusRing as _, theme::ActiveTheme as _}; +use ely_lok::{ + LokView, Rect, + sheet::{Axis, address, column_name}, +}; +use ely_office::prelude::*; +use gpui::{ + AnyElement, ClickEvent, Context, CursorStyle, Entity, InteractiveElement as _, IntoElement, + Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, + Pixels, Point, Role, StatefulInteractiveElement as _, Styled as _, Window, div, px, + transparent_black, +}; + +use crate::book::Book; + +/// A drag over the grid. +pub enum Drag { + /// A column or row edge. + Size { column: bool, index: u32 }, + /// The fill handle, to the cell under the pointer. + Fill { to: Option<(u32, u32)> }, +} + +/// The width that grabs a header edge. +const EDGE: f32 = 4.0; +const HANDLE: f32 = 7.0; + +/// The vcl modifier bits a header click takes. +fn modifier(modifiers: &Modifiers) -> String { + let shift = if modifiers.shift { 0x1000 } else { 0 }; + let add = if modifiers.secondary() { 0x2000 } else { 0 }; + (shift | add).to_string() +} + +/// Twips as 1/100 mm, as the size commands take them. +fn mm100(twips: i32) -> i32 { + (twips * 2540 + 720) / 1440 +} + +impl Book { + pub fn grid(&mut self, _: &mut Window, cx: &mut Context) -> AnyElement { + let tab = self.active_tab().expect("the grid needs a tab"); + let view = tab.view.clone(); + let (rows, columns) = ( + tab.extra.geometry.rows.clone(), + tab.extra.geometry.columns.clone(), + ); + let theme = cx.theme(); + let (border, sunken) = (theme.colors.border, theme.colors.sunken); + let column_strip = self.strip(&view, &columns, true, cx); + let row_strip = self.strip(&view, &rows, false, cx); + let corner = div() + .w_12() + .h_6() + .border_r_1() + .border_b_1() + .border_color(border); + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .bg(sunken) + .on_mouse_move(cx.listener(Self::on_drag_move)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_drag_end)) + .child(div().flex().child(corner).child(column_strip)) + .child( + div().flex_1().min_h_0().flex().child(row_strip).child( + div() + .relative() + .flex_1() + .min_w_0() + .child(view.clone()) + .children(self.fill_handle(&view, cx)) + .children(self.cell_aids(&view, cx)) + .children(self.find_box(cx)), + ), + ) + .into_any_element() + } + + /// One header strip; `column` picks the axis. + fn strip( + &self, + view: &Entity, + axis: &Axis, + column: bool, + cx: &mut Context, + ) -> AnyElement { + let theme = cx.theme(); + let border = theme.colors.border; + let read = view.read(cx); + let (width, height) = read.viewport(); + let along = |twips: i32| { + let at = read.place((twips, twips)); + f32::from(if column { at.x } else { at.y }) + }; + let length = if column { width } else { height }; + let pick = |pair: (i32, i32)| if column { pair.0 } else { pair.1 }; + let (frozen, scroll) = (pick(read.frozen()), pick(read.scroll())); + let mut spans = Vec::new(); + let mut take = |index: u32| { + let (start, end) = (along(axis.start(index)), along(axis.start(index + 1))); + if end > start { + spans.push((index, start, end - start)); + } + }; + // The frozen indexes, then the scrolled ones. + let held = if frozen > 0 { + axis.at(frozen - 1) + 1 + } else { + 0 + }; + (0..held).for_each(&mut take); + let mut index = axis.at(frozen + scroll).max(held); + while index < axis.count() && along(axis.start(index)) < length { + take(index); + index += 1; + } + let cells: Vec = spans + .into_iter() + .map(|(index, start, size)| self.header(index, start, size, column, cx)) + .collect(); + let strip = div() + .id(if column { + "table-columns" + } else { + "table-rows" + }) + .relative() + .overflow_hidden() + .border_color(border) + .children(cells); + if column { + strip.flex_1().h_6().border_b_1().into_any_element() + } else { + strip.w_12().h_full().border_r_1().into_any_element() + } + } + + /// One row or column header with its sizing edge. + fn header( + &self, + index: u32, + start: f32, + size: f32, + column: bool, + cx: &mut Context, + ) -> AnyElement { + let theme = cx.theme(); + let (border, text) = (theme.colors.border, theme.colors.fg_muted); + let label = if column { + column_name(index) + } else { + (index + 1).to_string() + }; + let edge = div() + .id(("table-edge", index as usize)) + .absolute() + .cursor(if column { + CursorStyle::ResizeLeftRight + } else { + CursorStyle::ResizeUpDown + }) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |book, _: &MouseDownEvent, _, cx| { + cx.stop_propagation(); + book.drag = Some(Drag::Size { column, index }); + }), + ); + let edge = if column { + edge.top_0().right_0().h_full().w(px(EDGE)) + } else { + edge.left_0().bottom_0().w_full().h(px(EDGE)) + }; + let id = ( + if column { "table-column" } else { "table-row" }, + index as usize, + ); + let name = if column { + Book::text(cx, "table.header.column", &[("name", &label)]) + } else { + Book::text(cx, "table.header.row", &[("name", &label)]) + }; + let button = div() + .id(id) + .role(if column { + Role::ColumnHeader + } else { + Role::RowHeader + }) + .aria_label(name) + .size_full() + .flex() + .items_center() + .justify_center() + .border_1() + .border_color(transparent_black()) + .focus_ring(cx) + .tab_index(0) + .on_click(cx.listener(move |book, event: &ClickEvent, _, cx| { + let id = if column { + "select.column" + } else { + "select.row" + }; + book.run_active(id, &[&index.to_string(), &modifier(&event.modifiers())], cx); + })) + .child(label); + let cell = div() + .absolute() + .overflow_hidden() + .text_xs() + .text_color(text) + .border_color(border) + .child(button) + .child(edge); + let cell = if column { + cell.top_0() + .left(px(start)) + .w(px(size)) + .h_full() + .border_r_1() + } else { + cell.left_0() + .top(px(start)) + .h(px(size)) + .w_full() + .border_b_1() + }; + cell.into_any_element() + } + + /// The square at the selection's corner; dragging it fills. + fn fill_handle(&self, view: &Entity, cx: &mut Context) -> Option { + let read = view.read(cx); + let area = span(read.selection()).or(read.cell().cursor)?; + let corner = read.place((area.0 + area.2, area.1 + area.3)); + let start = read.place((area.0, area.1)); + // Scrolled under the frozen part, the area shows nothing. + if corner.x <= start.x || corner.y <= start.y { + return None; + } + let theme = cx.theme(); + let (fill, edge) = (theme.colors.focus, theme.colors.bg); + let preview = match &self.drag { + Some(Drag::Fill { + to: Some((column, row)), + }) => { + let geometry = &self.active_tab()?.extra.geometry; + let end = ( + geometry.columns.start(column + 1), + geometry.rows.start(row + 1), + ); + Some(outline(read.place((area.0, area.1)), read.place(end), fill)) + } + _ => None, + }; + let handle = div() + .id("table-fill") + .absolute() + .left(corner.x - px(HANDLE / 2.0)) + .top(corner.y - px(HANDLE / 2.0)) + .size(px(HANDLE)) + .bg(fill) + .border_1() + .border_color(edge) + .cursor(CursorStyle::Crosshair) + .occlude() + .on_mouse_down( + MouseButton::Left, + cx.listener(|book, _: &MouseDownEvent, _, cx| { + cx.stop_propagation(); + book.drag = Some(Drag::Fill { to: None }); + cx.notify(); + }), + ); + let layer = div().absolute().top_0().left_0().size_full(); + Some(layer.children(preview).child(handle).into_any_element()) + } + + fn on_drag_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context) { + if !matches!(self.drag, Some(Drag::Fill { .. })) { + return; + } + let Some(tab) = self.active_tab() else { + return; + }; + let read = tab.view.read(cx); + let (x, y) = read.twips_at(event.position - read.origin()); + let geometry = &tab.extra.geometry; + let cell = (geometry.columns.at(x), geometry.rows.at(y)); + self.drag = Some(Drag::Fill { to: Some(cell) }); + cx.notify(); + } + + fn on_drag_end(&mut self, event: &MouseUpEvent, _: &mut Window, cx: &mut Context) { + let Some(drag) = self.drag.take() else { + return; + }; + cx.notify(); + match drag { + Drag::Size { column, index, .. } => { + let Some(tab) = self.active_tab() else { + return; + }; + let read = tab.view.read(cx); + let geometry = &tab.extra.geometry; + let axis = if column { + &geometry.columns + } else { + &geometry.rows + }; + let local = event.position - read.origin(); + let (x, y) = read.twips_at(local); + let twips = (if column { x } else { y }) - axis.start(index); + let size = mm100(twips).clamp(1, 65535).to_string(); + let id = if column { + "columns.width" + } else { + "rows.height" + }; + log::info!("table: {id} {} to {size}", index + 1); + self.run_active(id, &[&size, &(index + 1).to_string()], cx); + } + Drag::Fill { + to: Some((column, row)), + } => { + self.run_active("fill.auto", &[&address(column, row)], cx); + } + Drag::Fill { to: None } => {} + } + } +} + +/// The rectangle that holds all of `rects`. +fn span(rects: &[Rect]) -> Option { + let first = *rects.first()?; + let (mut x0, mut y0, mut x1, mut y1) = (first.0, first.1, first.0 + first.2, first.1 + first.3); + for &(x, y, w, h) in &rects[1..] { + (x0, y0) = (x0.min(x), y0.min(y)); + (x1, y1) = (x1.max(x + w), y1.max(y + h)); + } + Some((x0, y0, x1 - x0, y1 - y0)) +} + +/// A frame from `from` to `to`, view-local. +fn outline(from: Point, to: Point, color: gpui::Hsla) -> AnyElement { + div() + .absolute() + .left(from.x) + .top(from.y) + .w(to.x - from.x) + .h(to.y - from.y) + .border_2() + .border_color(color) + .into_any_element() +} diff --git a/apps/ely-table/src/main.rs b/apps/ely-table/src/main.rs new file mode 100644 index 0000000..22a5094 --- /dev/null +++ b/apps/ely-table/src/main.rs @@ -0,0 +1,26 @@ +mod analysis; +mod bars; +mod book; +mod cells; +mod data; +mod dialog; +mod figures; +mod format; +mod formulas; +mod grid; +mod output; +mod page; +mod protect; +mod rules; +mod sheets; +mod template; +mod text; + +use ely_platform::AppId; + +fn main() { + ely_ui::run(AppId::Table, book::open); +} + +#[cfg(test)] +mod tests; diff --git a/apps/ely-table/src/output.rs b/apps/ely-table/src/output.rs new file mode 100644 index 0000000..cf5dd25 --- /dev/null +++ b/apps/ely-table/src/output.rs @@ -0,0 +1,201 @@ +//! Export as PDF, CSV or TSV, and print (TABLE-13, 14). + +use std::path::PathBuf; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + overlays::Dialog as Card, + primitives::Severity, + theme::ControlSize, +}; +use ely_lok::Pdf; +use ely_office::{in_window, prelude::*}; +use ely_ui::commands::{Export, Print}; +use gpui::{App, Context, Entity, ParentElement as _, TaskExt as _, Window, div}; + +use crate::book::Book; +use crate::dialog::{Dialog, edit}; + +/// What an export writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Pdf, + Csv, + Tsv, +} + +const FORMATS: [(Format, &str, &str); 3] = [ + (Format::Pdf, "pdf", "PDF"), + (Format::Csv, "csv", "CSV"), + (Format::Tsv, "tsv", "TSV"), +]; + +impl Format { + fn extension(self) -> &'static str { + FORMATS.iter().find(|(each, ..)| *each == self).expect("listed").1 + } +} + +/// Every sheet as Page Setup lays it out. +const WHOLE: Pdf = Pdf { + pages: None, + comments: false, + changes: false, + handouts: 0, +}; + +impl Book { + pub(crate) fn on_export(&mut self, _: &Export, _: &mut Window, cx: &mut Context) { + let Some(tab) = self.core.active else { + return self.warn_key("command.blocked.no_document", cx); + }; + self.dialog = Some(Dialog::Export { + tab, + format: Format::Pdf, + }); + cx.notify(); + } + + /// Asks where, then writes the chosen format. + pub(crate) fn export_asked(&mut self, cx: &mut Context) { + let Some(Dialog::Export { tab, format }) = self.dialog.take() else { + return; + }; + cx.notify(); + let Some(source) = self.tab(tab) else { + return; + }; + let Some(dir) = ely_office::folder(source) else { + return self.panel_failed("no documents folder", cx); + }; + let stem = source + .name + .rsplit_once('.') + .map_or(source.name.as_ref(), |(stem, _)| stem); + let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", format.extension()))); + cx.spawn(async move |this, cx| { + let chosen = match chosen.await { + Ok(Ok(path)) => Ok(path), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + in_window(&this, cx, |book, _, cx| match chosen { + Ok(Some(path)) => book.export_to(tab, path, format, cx), + Ok(None) => log::info!("table: the export panel was cancelled"), + Err(error) => book.panel_failed(&error, cx), + }) + }) + .detach_and_log_err(cx); + } + + pub(crate) fn export_to(&mut self, tab: u64, path: PathBuf, format: Format, cx: &mut Context) { + if !self.settled(tab, cx) { + return self.when_settled(tab, move |book, _, cx| book.export_to(tab, path, format, cx)); + } + match self.holder(&path) { + Ok(None) => {} + Ok(Some(_)) => { + let name = ely_office::files::file_name(&path); + let text = Book::text(cx, "table.export.open", &[("name", &name)]); + return self.toast(text, Severity::Danger, cx); + } + Err(error) => return self.output_failed(&error.to_string(), cx), + } + log::info!("table: exports tab {tab} as {format:?}"); + let (url, name) = (ely_lok::file_url(&path), ely_office::files::file_name(&path)); + let opened = path.clone(); + self.read_in( + tab, + move |worker, doc| match format { + Format::Pdf => doc.export_pdf(worker, &url, &WHOLE), + Format::Csv => doc.export_text(worker, &url, ','), + Format::Tsv => doc.export_text(worker, &url, '\t'), + }, + move |book, written, _, cx| match written { + Ok(()) => { + let text = Book::text(cx, "table.export.done", &[("name", &name)]); + book.toast_output(text, opened.clone(), cx); + } + Err(error) => book.output_failed(&error.to_string(), cx), + }, + cx, + ); + } + + /// Prints a PDF of every sheet through the system panel. + pub(crate) fn on_print(&mut self, _: &Print, _: &mut Window, cx: &mut Context) { + let Some(tab) = self.core.active else { + return self.warn_key("command.blocked.no_document", cx); + }; + self.print(tab, cx); + } + + fn print(&mut self, tab: u64, cx: &mut Context) { + if !self.settled(tab, cx) { + return self.when_settled(tab, move |book, _, cx| book.print(tab, cx)); + } + let Some(source) = self.tab(tab) else { + return; + }; + let path = source.work.join("print.pdf"); + let url = ely_lok::file_url(&path); + log::info!("table: prints tab {tab}"); + self.read_in( + tab, + move |worker, doc| doc.export_pdf(worker, &url, &WHOLE), + move |book, written, _, cx| match written { + Ok(()) => ely_office::print_later(path, cx, |book, error, cx| { + book.output_failed(&error, cx) + }), + Err(error) => book.output_failed(&error.to_string(), cx), + }, + cx, + ); + } + + fn output_failed(&self, error: &str, cx: &mut Context) { + let text = Book::text(cx, "table.export.failed", &[("error", error)]); + self.toast(text, Severity::Danger, cx); + } +} + +/// The export dialog; text formats say what they drop. +pub fn export_dialog( + this: Entity, + (format, sheet): (Format, &str), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let picking = this.clone(); + let formats = FORMATS + .iter() + .fold( + SegmentedControl::new("table-export-format", format.extension()), + |control, (_, value, label)| control.segment(*value, *label, None), + ) + .size(ControlSize::Sm) + .on_change(move |value, _, cx| { + let Some((chosen, ..)) = FORMATS.iter().find(|(_, each, _)| *each == value.as_ref()) + else { + return; + }; + let chosen = *chosen; + edit(&picking, cx, |dialog, _, _| { + if let Dialog::Export { format, .. } = dialog { + *format = chosen; + } + }) + }); + let loss = (format != Format::Pdf) + .then(|| div().child(Book::text(cx, "table.export.loss", &[("sheet", sheet)]))); + let go = Book::text(cx, "table.export.go", &[]); + Card::new("table-export", Book::text(cx, "table.export.title", &[]), dismiss) + .child(formats) + .children(loss) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("table-export-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |book, cx| book.export_asked(cx))) + }) +} diff --git a/apps/ely-table/src/page.rs b/apps/ely-table/src/page.rs new file mode 100644 index 0000000..3d5ce9c --- /dev/null +++ b/apps/ely-table/src/page.rs @@ -0,0 +1,238 @@ +//! The Page menu: print area, titles, page, breaks (TABLE-14). + +use ely_gpui_component::forms::TextInput; +use ely_lok::sheet::{self, PrintLayout, Scale, Span}; +use ely_office::prelude::*; +use gpui::{ + App, AppContext as _, Context, Div, Entity, InteractiveElement as _, Menu, MenuItem, Window, + actions, +}; + +use crate::book::Book; +use crate::dialog::Dialog; + +mod dialogs; +pub use dialogs::page_dialog; + +actions!( + table, + [ + PageSetup, + RowBreak, + ColumnBreak, + RemoveRowBreak, + RemoveColumnBreak + ] +); + +pub fn menu(cx: &App) -> Menu { + let label = |key: &str| Book::text(cx, key, &[]); + Menu::new(label("menu.page")).items([ + MenuItem::action(label("table.page.setup"), PageSetup), + MenuItem::separator(), + MenuItem::action(label("table.page.row_break"), RowBreak), + MenuItem::action(label("table.page.column_break"), ColumnBreak), + MenuItem::action(label("table.page.remove_row_break"), RemoveRowBreak), + MenuItem::action(label("table.page.remove_column_break"), RemoveColumnBreak), + ]) +} + +pub fn bind(root: Div, cx: &mut Context) -> Div { + root.on_action(cx.listener(|book, _: &PageSetup, _, cx| book.ask_page(cx))) + .on_action(cx.listener(|book, _: &RowBreak, _, cx| book.run_active("break.row", &[], cx))) + .on_action( + cx.listener(|book, _: &ColumnBreak, _, cx| book.run_active("break.column", &[], cx)), + ) + .on_action(cx.listener(|book, _: &RemoveRowBreak, _, cx| book.remove_break(true, cx))) + .on_action( + cx.listener(|book, _: &RemoveColumnBreak, _, cx| book.remove_break(false, cx)), + ) +} + +/// The page setup dialog's text fields. +pub struct PageFields { + pub area: Entity, + pub rows: Entity, + pub columns: Entity, +} + +/// A column's number from its letters, A is 1. +fn column_number(letters: &str) -> Option { + (1..=3).contains(&letters.len()).then_some(())?; + let number = letters.bytes().try_fold(0u32, |number, byte| { + byte.is_ascii_uppercase() + .then(|| number * 26 + u32::from(byte - b'A') + 1) + })?; + (number <= 16384).then_some(number) +} + +fn row_number(digits: &str) -> Option { + let number: u32 = digits.parse().ok()?; + (1..=1_048_576).contains(&number).then_some(number) +} + +/// One A1 cell such as `$B$2`. +fn cell_ok(cell: &str) -> bool { + let cell = cell.strip_prefix('$').unwrap_or(cell); + let split = cell.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(cell.len()); + let (letters, rest) = cell.split_at(split); + let digits = rest.strip_prefix('$').unwrap_or(rest); + column_number(&letters.to_ascii_uppercase()).is_some() + && digits.bytes().all(|byte| byte.is_ascii_digit()) + && row_number(digits).is_some() +} + +/// Areas as the kernel takes them, or None. +fn areas(text: &str) -> Option { + let tokens: Vec<&str> = text + .split([',', ';']) + .map(str::trim) + .filter(|token| !token.is_empty()) + .collect(); + let ok = |token: &&str| match token.split_once(':') { + Some((from, to)) => cell_ok(from) && cell_ok(to), + None => cell_ok(token), + }; + tokens.iter().all(ok).then(|| tokens.join(",")) +} + +/// A span such as `1:2` or `$A`; empty is none. +fn span(text: &str, number: fn(&str) -> Option) -> Option> { + let text = text.trim(); + if text.is_empty() { + return Some(None); + } + let end = |end: &str| number(&end.trim().trim_start_matches('$').to_ascii_uppercase()); + let (first, last) = match text.split_once(':') { + Some((first, last)) => (end(first)?, end(last)?), + None => (end(text)?, end(text)?), + }; + (first <= last).then_some(Some((first, last))) +} + +impl Book { + pub(crate) fn ask_page(&mut self, cx: &mut Context) { + let Some(id) = self.core.active else { + return self.warn_key("command.blocked.no_document", cx); + }; + if !self.settled(id, cx) { + return self.when_settled(id, |book, _, cx| book.ask_page(cx)); + } + self.ask_in( + id, + |worker, doc| sheet::print_layout(&worker.values(doc, sheet::LAYOUT)?), + move |book, read, window, cx| match read { + Ok(layout) => book.page_dialog(id, layout, window, cx), + Err(error) => book.engine_failed(&error.to_string(), cx), + }, + cx, + ); + } + + fn page_dialog(&mut self, id: u64, layout: PrintLayout, window: &mut Window, cx: &mut Context) { + let mut field = |text: String, hint: &str, cx: &mut Context| { + let hint = Book::text(cx, hint, &[]); + cx.new(|cx| { + let mut input = TextInput::new(window, cx).placeholder(hint); + input.set_text(text, cx); + input + }) + }; + let fields = PageFields { + area: field(layout.areas.join(","), "table.page.area_hint", cx), + rows: field(sheet::rows_ref(layout.rows), "table.page.rows_hint", cx), + columns: field(sheet::columns_ref(layout.columns), "table.page.columns_hint", cx), + }; + self.dialog = Some(Dialog::Page { + tab: id, + landscape: layout.landscape, + scale: layout.scale, + layout: Box::new(layout), + fields: Box::new(fields), + }); + cx.notify(); + } + + /// Applies the page setup dialog's changes. + pub(crate) fn page_asked(&mut self, cx: &mut Context) { + let Some(Dialog::Page { + tab, + layout, + landscape, + scale, + fields, + }) = &self.dialog + else { + return; + }; + let text = |field: &Entity| field.read(cx).text().to_string(); + let read = ( + areas(&text(&fields.area)), + span(&text(&fields.rows), row_number), + span(&text(&fields.columns), column_number), + ); + let (Some(area), Some(rows), Some(columns)) = read else { + return self.warn_key("table.page.bad_reference", cx); + }; + let (tab, landscape, scale) = (*tab, *landscape, *scale); + if scale == Scale::Fit(0, 0) { + return self.warn_key("table.page.bad_fit", cx); + } + let printed = area != layout.areas.join(",") || rows != layout.rows || columns != layout.columns; + let paged = landscape != layout.landscape || scale != layout.scale; + self.dialog = None; + cx.notify(); + if printed { + let (rows, columns) = (sheet::rows_ref(rows), sheet::columns_ref(columns)); + self.run_in(Some(tab), "print.area", &[&area, &rows, &columns], cx); + } + if paged { + self.run_in(Some(tab), "page.setup", &[&sheet::page_json(landscape, scale)], cx); + } + } + + /// Removes the cursor's break; none says so. + fn remove_break(&mut self, row: bool, cx: &mut Context) { + let Some(id) = self.core.active else { + return self.warn_key("command.blocked.no_document", cx); + }; + if !self.settled(id, cx) { + return self.when_settled(id, move |book, _, cx| book.remove_break(row, cx)); + } + self.ask_in( + id, + |worker, doc| sheet::print_layout(&worker.values(doc, sheet::LAYOUT)?), + move |book, read, _, cx| match read { + Ok(layout) if row && layout.row_break => { + book.run_in(Some(id), "break.row_remove", &[], cx) + } + Ok(layout) if !row && layout.column_break => { + book.run_in(Some(id), "break.column_remove", &[], cx) + } + Ok(_) => book.warn_key("table.page.no_break", cx), + Err(error) => book.engine_failed(&error.to_string(), cx), + }, + cx, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn references_parse_like_excel() { + assert_eq!(areas(" A1:B2; $d$4 ").as_deref(), Some("A1:B2,$d$4")); + assert_eq!(areas("").as_deref(), Some("")); + assert_eq!(areas("A1:B"), None); + assert_eq!(areas("XFE1"), None); + assert_eq!(areas("S!A1"), None); + assert_eq!(span("$1:$3", row_number), Some(Some((1, 3)))); + assert_eq!(span("2", row_number), Some(Some((2, 2)))); + assert_eq!(span("3:1", row_number), None); + assert_eq!(span("a:ab", column_number), Some(Some((1, 28)))); + assert_eq!(span(" ", column_number), Some(None)); + assert_eq!(span("A1", column_number), None); + } +} diff --git a/apps/ely-table/src/page/dialogs.rs b/apps/ely-table/src/page/dialogs.rs new file mode 100644 index 0000000..356027b --- /dev/null +++ b/apps/ely-table/src/page/dialogs.rs @@ -0,0 +1,141 @@ +//! The page setup dialog. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + data_display::DescriptionList, + forms::{Input, NumberInput}, + overlays::Dialog as Card, + theme::ControlSize, +}; +use ely_lok::sheet::{PrintLayout, Scale}; +use gpui::{App, Entity, ParentElement as _, Styled as _, Window, div}; + +use super::PageFields; +use crate::book::Book; +use crate::dialog::{Dialog, edit}; + +/// Changes the dialog's orientation or scale. +fn set(this: &Entity, cx: &mut App, change: impl FnOnce(&mut bool, &mut Scale)) { + edit(this, cx, |dialog, _, _| { + if let Dialog::Page { + landscape, scale, .. + } = dialog + { + change(landscape, scale); + } + }) +} + +fn number(id: &'static str, this: &Entity, value: u16, range: (u16, u16), apply: fn(&mut Scale, u16)) -> NumberInput { + let this = this.clone(); + NumberInput::new(id, f64::from(value)) + .range(f64::from(range.0), f64::from(range.1)) + .on_commit(move |value, _, cx| { + let value = value.round() as u16; + set(&this, cx, |_, scale| apply(scale, value)) + }) +} + +/// The scale's mode, then its numbers under the mode's name. +fn scale_rows( + rows: DescriptionList, + this: &Entity, + scale: Scale, + cx: &App, +) -> DescriptionList { + let label = |key: &str| Book::text(cx, key, &[]); + let mode = match scale { + Scale::Percent(_) => "percent", + Scale::Fit(..) => "fit", + Scale::Pages(_) => "pages", + }; + let picking = this.clone(); + let modes = SegmentedControl::new("table-page-scale", mode) + .size(ControlSize::Sm) + .segment("percent", label("table.page.percent"), None) + .segment("fit", label("table.page.fit"), None) + .segment("pages", label("table.page.pages"), None) + .on_change(move |value, _, cx| { + let chosen = match value.as_ref() { + "fit" => Scale::Fit(1, 1), + "pages" => Scale::Pages(1), + _ => Scale::Percent(100), + }; + set(&picking, cx, |_, scale| *scale = chosen) + }); + let field = |input: NumberInput| div().w_full().max_w_24().child(input); + let numbers = match scale { + Scale::Percent(percent) => div().child(field(number( + "table-page-percent", + this, + percent, + (10, 400), + |scale, value| *scale = Scale::Percent(value), + ))), + Scale::Fit(wide, tall) => div() + .flex() + .items_center() + .gap_1() + .child(field(number("table-page-wide", this, wide, (0, 1000), |scale, value| { + if let Scale::Fit(wide, _) = scale { + *wide = value; + } + }))) + .child("×") + .child(field(number("table-page-tall", this, tall, (0, 1000), |scale, value| { + if let Scale::Fit(_, tall) = scale { + *tall = value; + } + }))), + Scale::Pages(pages) => div().child(field(number( + "table-page-count", + this, + pages, + (1, 1000), + |scale, value| *scale = Scale::Pages(value), + ))), + }; + let named = label(&format!("table.page.{mode}")); + rows.item(label("table.page.scale"), modes) + .item(named, numbers) +} + +pub fn page_dialog( + this: Entity, + (layout, landscape, scale, fields): (&PrintLayout, bool, Scale, &PageFields), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let label = |key: &str| Book::text(cx, key, &[]); + let picking = this.clone(); + let orientation = SegmentedControl::new( + "table-page-orientation", + if landscape { "landscape" } else { "portrait" }, + ) + .size(ControlSize::Sm) + .segment("portrait", label("table.page.portrait"), None) + .segment("landscape", label("table.page.landscape"), None) + .on_change(move |value, _, cx| { + let on = value.as_ref() == "landscape"; + set(&picking, cx, |landscape, _| *landscape = on) + }); + let input = |field| div().w_full().max_w_48().child(Input::new(field)); + let rows = DescriptionList::new() + .item(label("table.page.area"), input(&fields.area)) + .item(label("table.page.rows"), input(&fields.rows)) + .item(label("table.page.columns"), input(&fields.columns)) + .item(label("table.page.orientation"), orientation); + let rows = scale_rows(rows, &this, scale, cx); + let mut card = Card::new("table-page", label("table.page.title"), dismiss); + // The page style may govern other sheets too. + if layout.sheets.len() > 1 { + let sheets = layout.sheets.join(", "); + card = card.detail(Book::text(cx, "table.page.shared", &[("sheets", &sheets)])); + } + let go = label("table.page.apply"); + card.child(rows).action(ely_office::cancel(cx)).action(move |_| { + Button::new("table-page-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |book, cx| book.page_asked(cx))) + }) +} diff --git a/apps/ely-table/src/protect.rs b/apps/ely-table/src/protect.rs new file mode 100644 index 0000000..592e7e7 --- /dev/null +++ b/apps/ely-table/src/protect.rs @@ -0,0 +1,166 @@ +//! Sheet protection and locked cells (TABLE-15). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Input, Switch, TextInput}, + overlays::Dialog as Card, + primitives::Severity, +}; +use ely_lok::sheet::{self, PROTECT_OPTIONS, Protection}; +use ely_office::prelude::*; +use gpui::{ + App, AppContext as _, Context, Div, Entity, InteractiveElement as _, ParentElement as _, + Styled as _, Window, actions, div, +}; + +use crate::book::Book; +use crate::dialog::{Dialog, edit}; + +actions!(table, [ProtectSheet, LockCells, UnlockCells]); + +pub fn bind(root: Div, cx: &mut Context) -> Div { + root.on_action(cx.listener(|book, _: &ProtectSheet, _, cx| book.ask_protect(cx))) + .on_action(cx.listener(|book, _: &LockCells, _, cx| book.run_active("cells.lock", &["true"], cx))) + .on_action( + cx.listener(|book, _: &UnlockCells, _, cx| book.run_active("cells.lock", &["false"], cx)), + ) +} + +/// The protection dialog's state. +pub struct Protecting { + pub now: Protection, + pub password: Entity, + pub confirm: Entity, + pub allow: Vec, +} + +impl Book { + pub(crate) fn ask_protect(&mut self, cx: &mut Context) { + let Some(id) = self.core.active else { + return self.warn_key("command.blocked.no_document", cx); + }; + if !self.settled(id, cx) { + return self.when_settled(id, |book, _, cx| book.ask_protect(cx)); + } + self.ask_in( + id, + |worker, doc| sheet::protection(&worker.values(doc, sheet::PROTECTION)?), + move |book, read, window, cx| match read { + Ok(now) => { + let mut masked = |key: &str, cx: &mut Context| { + let name = Book::text(cx, key, &[]); + cx.new(|cx| TextInput::new(window, cx).masked().label(name)) + }; + let protecting = Protecting { + allow: now.allow.clone(), + password: masked("table.protect.password", cx), + confirm: masked("table.protect.confirm", cx), + now, + }; + book.dialog = Some(Dialog::Protect { + tab: id, + protecting: Box::new(protecting), + }); + cx.notify(); + } + Err(error) => book.engine_failed(&error.to_string(), cx), + }, + cx, + ); + } + + /// Protects or unprotects as the dialog says. + pub(crate) fn protect_asked(&mut self, cx: &mut Context) { + let Some(Dialog::Protect { tab, protecting }) = &self.dialog else { + return; + }; + let (tab, password) = (*tab, protecting.password.read(cx).text().to_string()); + if !protecting.now.protected { + if password != protecting.confirm.read(cx).text() { + return self.warn_key("table.protect.mismatch", cx); + } + let json = sheet::protect_json(&password, &protecting.allow); + log::info!("table: protects tab {tab}, allows {:?}", protecting.allow); + self.dialog = None; + cx.notify(); + return self.run_in(Some(tab), "sheet.protect", &[&json], cx); + } + self.dialog = None; + cx.notify(); + log::info!("table: unprotects tab {tab}"); + let json = sheet::unprotect_json(&password); + self.read_in( + tab, + move |worker, doc| match worker.run(doc, "sheet.protect", &[&json]) { + Ok(()) => Ok(true), + // Still protected: the password was wrong. + Err(error) => match sheet::protection(&worker.values(doc, sheet::PROTECTION)?)? { + Protection { protected: true, .. } => Ok(false), + _ => Err(error), + }, + }, + |book, done, _, cx| match done { + Ok(true) => {} + Ok(false) => { + let text = Book::text(cx, "table.protect.wrong", &[]); + book.toast(text, Severity::Danger, cx); + } + Err(error) => book.engine_failed(&error.to_string(), cx), + }, + cx, + ); + } +} + +/// Protects with options, or asks the password back. +pub fn protect_dialog( + this: Entity, + protecting: &Protecting, + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let label = |key: &str| Book::text(cx, key, &[]); + let input = |field| div().w_full().max_w_48().child(Input::new(field)); + let protected = protecting.now.protected; + let (title, go) = match protected { + true => ("table.protect.off_title", "table.protect.off"), + false => ("table.protect.on_title", "table.protect.on"), + }; + let mut card = Card::new("table-protect", label(title), dismiss); + if !protected { + let switches = PROTECT_OPTIONS.iter().enumerate().map(|(at, option)| { + let toggling = this.clone(); + let on = protecting.allow.iter().any(|allowed| allowed == option); + Switch::new(("table-protect-allow", at), on) + .label(label(&format!("table.protect.{option}"))) + .on_change(move |on, _, cx| { + edit(&toggling, cx, |dialog, _, _| { + if let Dialog::Protect { protecting, .. } = dialog { + protecting.allow.retain(|allowed| allowed != option); + if on { + protecting.allow.push(option.to_string()); + } + } + }) + }) + }); + card = card + .child( + DescriptionList::new() + .item(label("table.protect.password"), input(&protecting.password)) + .item(label("table.protect.confirm"), input(&protecting.confirm)), + ) + .child(div().flex().flex_col().gap_2().children(switches)); + } else if protecting.now.password { + card = card.child( + DescriptionList::new().item(label("table.protect.password"), input(&protecting.password)), + ); + } + let go = label(go); + card.action(ely_office::cancel(cx)).action(move |_| { + Button::new("table-protect-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |book, cx| book.protect_asked(cx))) + }) +} diff --git a/apps/ely-table/src/rules.rs b/apps/ely-table/src/rules.rs new file mode 100644 index 0000000..2f213c5 --- /dev/null +++ b/apps/ely-table/src/rules.rs @@ -0,0 +1,379 @@ +//! Rules: validation, choices, input help, conditional formats (TABLE-09). + +use ely_gpui_component::{forms::TextInput, primitives::Severity}; +use ely_lok::{ + LokView, Request, + sheet::{self, Validation}, +}; +use ely_office::prelude::*; +use gpui::{AppContext as _, Context, Div, Entity, InteractiveElement as _, Window, actions}; + +use crate::book::{Book, Spot}; +use crate::dialog::Dialog; + +mod aids; +mod dialogs; +pub use aids::Choices; +pub use dialogs::{Shown, conditional_dialog, formats_dialog, validation_dialog}; + +actions!(table, [Validate, Unvalidate, Conditional, Conditionals]); + +pub fn bind(root: Div, cx: &mut Context) -> Div { + root.on_action(cx.listener(|book, _: &Validate, _, cx| book.ask_rule(cx))) + .on_action(cx.listener(|book, _: &Unvalidate, _, cx| book.clear_rule(cx))) + .on_action( + cx.listener(|book, _: &Conditional, window, cx| book.ask_conditional(window, cx)), + ) + .on_action(cx.listener(|book, _: &Conditionals, _, cx| book.ask_formats(cx))) +} + +/// The text fields of the validation dialog. +pub struct Fields { + pub value1: Entity, + pub value2: Entity, + pub list: Entity, + pub input_title: Entity, + pub input_message: Entity, + pub error_title: Entity, + pub error_message: Entity, +} + +/// List entries, one per line; a range starts "=". +fn list_text(value1: &str) -> String { + if !value1.starts_with('"') { + return format!("={value1}"); + } + let mut entries = Vec::new(); + let mut chars = value1.chars().peekable(); + while let Some(c) = chars.next() { + if c != '"' { + continue; + } + let mut entry = String::new(); + while let Some(c) = chars.next() { + match (c, chars.peek()) { + ('"', Some('"')) => { + entry.push('"'); + chars.next(); + } + ('"', _) => break, + (c, _) => entry.push(c), + } + } + entries.push(entry); + } + entries.join("\n") +} + +/// No rule on the cell: the dialog's starting point. +fn unrestricted() -> Validation { + Validation { + kind: "any".into(), + operator: "between".into(), + dropdown: true, + ignore_blank: true, + show_error: true, + error_style: "stop".into(), + ..Validation::default() + } +} + +impl Book { + /// Opens the validation of the current cell. + pub(crate) fn ask_rule(&mut self, cx: &mut Context) { + let Some(id) = self.core.active else { + let text = Book::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + self.rule_in(id, cx); + } + + /// The validation dialog for tab `id`, once its input lands. + pub(crate) fn rule_in(&mut self, id: u64, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |book, _, cx| book.rule_in(id, cx)); + } + self.ask_in( + id, + |worker, doc| sheet::validation(&worker.values(doc, sheet::VALIDATION)?), + move |book, read, window, cx| match read { + Ok(rule) => book.rule_dialog(id, rule, window, cx), + Err(error) => book.engine_failed(&error.to_string(), cx), + }, + cx, + ); + } + + fn rule_dialog( + &mut self, + id: u64, + rule: Validation, + window: &mut Window, + cx: &mut Context, + ) { + let mut field = |text: &str, rows: Option<(usize, usize)>, cx: &mut Context| { + let text = text.to_string(); + cx.new(|cx| { + let input = TextInput::new(window, cx); + let mut input = match rows { + Some((min, max)) => input.multi_line(min, max), + None => input, + }; + input.set_text(text, cx); + input + }) + }; + let list = if rule.kind == "list" { + list_text(&rule.value1) + } else { + String::new() + }; + let fields = Fields { + value1: field(&rule.value1, None, cx), + value2: field(&rule.value2, None, cx), + list: field(&list, Some((3, 8)), cx), + input_title: field(&rule.input_title, None, cx), + input_message: field(&rule.input_message, Some((2, 4)), cx), + error_title: field(&rule.error_title, None, cx), + error_message: field(&rule.error_message, Some((2, 4)), cx), + }; + // A cell without a rule starts from the defaults. + let rule = if rule.operator.is_empty() { + unrestricted() + } else { + rule + }; + self.dialog = Some(Dialog::Validation { + tab: id, + rule: Box::new(rule), + fields: Box::new(fields), + }); + cx.notify(); + } + + /// Removes the selection's validation. + pub(crate) fn clear_rule(&mut self, cx: &mut Context) { + let rule = unrestricted().to_json(); + self.run_active("rules.validation", &[&rule], cx); + } + + /// Sets the validation the dialog describes. + pub(crate) fn rule_asked(&mut self, cx: &mut Context) { + let Some(Dialog::Validation { tab, rule, fields }) = self.dialog.take() else { + return; + }; + let text = |field: &Entity| field.read(cx).text().to_string(); + let mut rule = Validation { + value1: text(&fields.value1).trim().to_string(), + value2: text(&fields.value2).trim().to_string(), + input_title: text(&fields.input_title), + input_message: text(&fields.input_message), + error_title: text(&fields.error_title), + error_message: text(&fields.error_message), + ..*rule + }; + if rule.kind == "list" { + let list = text(&fields.list); + match list.trim().strip_prefix('=') { + Some(source) => rule.value1 = source.trim().to_string(), + None => { + rule.value1.clear(); + rule.list = list + .lines() + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect(); + } + } + } + self.run_in(Some(tab), "rules.validation", &[&rule.to_json()], cx); + } + + /// Takes the kernel's new request on tab `id`. + pub(crate) fn requested(&mut self, id: u64, view: &Entity, cx: &mut Context) { + let cell = view.read(cx).cell(); + let (count, request) = (cell.requests, cell.request.clone()); + let Some(tab) = self.tab_mut(id) else { + return; + }; + if tab.extra.requests == count { + return; + } + tab.extra.requests = count; + match request { + Some(request @ Request::Filter { .. }) => self.ask_column(id, &request, cx), + Some(Request::Choices { column, row }) => self.offer_choices(id, (column, row), cx), + Some(Request::Invalid { + title, + message, + style, + accepted, + }) => { + let fate = if accepted { "noted" } else { "refused" }; + log::info!("table: a {style} entry was {fate}"); + let severity = match style.as_str() { + "info" => Severity::Info, + "warning" => Severity::Warning, + _ => Severity::Danger, + }; + // The document's own words: shown, never logged. + if let Some(root) = self.core.root.upgrade() { + let text: gpui::SharedString = format!("{title}: {message}").into(); + root.update(cx, |root, cx| root.toast(text, severity, cx)); + } + } + None => {} + } + } + + /// Rereads the current cell's rule after the cursor moves. + pub(crate) fn follow_rule(&mut self, id: u64, view: &Entity, cx: &mut Context) { + let read = view.read(cx); + let spot: Spot = ( + read.cell().sheets, + read.cell().address.clone(), + read.edited(), + ); + let Some(tab) = self.tab(id) else { + return; + }; + let asked = tab.extra.ruled.as_ref() == Some(&spot); + if spot.1.is_empty() || tab.extra.ruling || asked { + return; + } + let doc = tab.doc.clone(); + // Asked now: a failure waits for the next change. + let extra = &mut self.tab_mut(id).expect("the tab is open").extra; + (extra.ruling, extra.ruled) = (true, Some(spot.clone())); + ely_lok::read_off( + &self.core.worker, + &doc, + |worker, doc| sheet::validation(&worker.values(doc, sheet::VALIDATION)?), + move |book: &mut Self, read, cx| { + let Some(tab) = book.tab_mut(id) else { + return; + }; + tab.extra.ruling = false; + let view = tab.view.clone(); + match read { + Ok(rule) => tab.extra.rule = Some((spot, rule)), + Err(error) => book.engine_failed(&error.to_string(), cx), + } + cx.notify(); + // The cursor may have moved meanwhile. + book.follow_rule(id, &view, cx); + }, + cx, + ); + } + + pub(crate) fn ask_conditional(&mut self, window: &mut Window, cx: &mut Context) { + let Some(id) = self.core.active else { + let text = Book::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + let value1 = cx.new(|cx| TextInput::new(window, cx)); + let value2 = cx.new(|cx| TextInput::new(window, cx)); + self.dialog = Some(Dialog::Conditional { + tab: id, + kind: "cell", + operator: "greater", + style: "Good", + preset: 0, + value1, + value2, + }); + cx.notify(); + } + + /// Adds the conditional format the dialog describes. + pub(crate) fn conditional_asked(&mut self, cx: &mut Context) { + let Some(Dialog::Conditional { + tab, + kind, + operator, + style, + preset, + value1, + value2, + }) = self.dialog.take() + else { + return; + }; + let rule = match kind { + "scale" => serde_json::json!({ "kind": "scale", "colors": dialogs::SCALES[preset].1 }), + "databar" => serde_json::json!({ "kind": "databar", "color": dialogs::BARS[preset].1 }), + _ => serde_json::json!({ + "kind": "cell", + "operator": operator, + "style": style, + "value1": value1.read(cx).text().trim(), + "value2": value2.read(cx).text().trim(), + }), + }; + self.run_in(Some(tab), "rules.conditional", &[&rule.to_string()], cx); + } + + /// Lists the shown sheet's conditional formats. + pub(crate) fn ask_formats(&mut self, cx: &mut Context) { + let Some(id) = self.core.active else { + let text = Book::text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + self.formats_in(id, cx); + } + + /// The format list of tab `id`, once its input lands. + pub(crate) fn formats_in(&mut self, id: u64, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |book, _, cx| book.formats_in(id, cx)); + } + self.ask_in( + id, + |worker, doc| sheet::formats(&worker.values(doc, sheet::FORMATS)?), + move |book, read, _, cx| match read { + Ok(formats) if formats.is_empty() => { + let text = Book::text(cx, "table.rules.no_formats", &[]); + book.toast(text, Severity::Info, cx); + } + Ok(formats) => { + book.dialog = Some(Dialog::Formats { + tab: id, + formats, + chosen: 0, + }); + cx.notify(); + } + Err(error) => book.engine_failed(&error.to_string(), cx), + }, + cx, + ); + } + + /// Removes the chosen conditional format. + pub(crate) fn format_removed(&mut self, cx: &mut Context) { + let Some(Dialog::Formats { + tab, + formats, + chosen, + }) = self.dialog.take() + else { + return; + }; + let remove = serde_json::json!({ "remove": formats[chosen].key }).to_string(); + self.run_in(Some(tab), "rules.conditional", &[&remove], cx); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn list_sources_read_as_lines() { + assert_eq!( + super::list_text(r#""Red","Green ""G""""#), + "Red\nGreen \"G\"" + ); + assert_eq!(super::list_text("$A$1:$A$3"), "=$A$1:$A$3"); + } +} diff --git a/apps/ely-table/src/rules/aids.rs b/apps/ely-table/src/rules/aids.rs new file mode 100644 index 0000000..9bfe9c9 --- /dev/null +++ b/apps/ely-table/src/rules/aids.rs @@ -0,0 +1,154 @@ +//! The cell's aids: the choices menu, input help, list button. + +use ely_gpui_component::{ + buttons::{ButtonVariant, IconButton}, + menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem}, + primitives::{IconName, raise}, + theme::{ActiveTheme as _, ControlSize, Elevation, Radius, TextSize}, +}; +use ely_lok::{LokView, sheet}; +use ely_office::prelude::*; +use gpui::{ + AnyElement, Context, Entity, IntoElement, ParentElement as _, Pixels, Point, Styled as _, div, + px, +}; + +use crate::book::Book; + +/// The open choices menu: tab, values, request. +pub struct Choices { + tab: u64, + pub(crate) values: Vec, + request: (u64, Point), +} + +impl Book { + /// A cell's validation list, as a menu below. + pub(crate) fn offer_choices( + &mut self, + id: u64, + (column, row): (u32, u32), + cx: &mut Context, + ) { + self.ask_in( + id, + move |worker, doc| { + let json = worker.values(doc, &sheet::cell_query(sheet::LIST, column, row))?; + sheet::list_values(&json) + }, + move |book, read, _, cx| { + let values = match read { + Ok(values) => values, + Err(error) => return book.engine_failed(&error.to_string(), cx), + }; + let Some(tab) = book.tab(id) else { + return; + }; + let geometry = &tab.extra.geometry; + let bottom = (geometry.columns.start(column), geometry.rows.start(row + 1)); + let at = tab.view.read(cx).window_point(bottom); + let number = book + .choices + .as_ref() + .map_or(0, |choices| choices.request.0 + 1); + book.choices = Some(Choices { + tab: id, + values, + request: (number, at), + }); + cx.notify(); + }, + cx, + ); + } + + /// The choices menu and the help of the current cell. + pub(crate) fn cell_aids( + &self, + view: &Entity, + cx: &mut Context, + ) -> Vec { + let Some(tab) = self.active_tab() else { + return Vec::new(); + }; + let mut aids = Vec::new(); + if let Some(choices) = self + .choices + .as_ref() + .filter(|choices| choices.tab == tab.id) + { + let this = cx.entity(); + let menu = choices.values.iter().fold(Popup::new(), |menu, value| { + let (this, value) = (this.clone(), value.clone()); + menu.item(PopupItem::new(value.clone()).on_click(move |_, cx| { + this.update(cx, |book, cx| book.run_active("cell.enter", &[&value], cx)) + })) + }); + aids.push( + ContextMenu::new("table-choices", menu) + .manual(Some(choices.request)) + .into_any_element(), + ); + } + let read = view.read(cx); + let (Some((at, rule)), Some((x, y, w, h))) = (&tab.extra.rule, read.cell().cursor) else { + return aids; + }; + if at.1 != read.cell().address { + return aids; + } + if rule.kind == "list" && rule.dropdown { + let corner = read.place((x + w, y)); + let (column, row) = ( + tab.extra.geometry.columns.at(x), + tab.extra.geometry.rows.at(y), + ); + let (id, size) = ( + tab.id, + f32::from(read.place((x, y + h)).y - read.place((x, y)).y), + ); + aids.push( + div() + .absolute() + .left(corner.x) + .top(corner.y) + .child( + IconButton::new("table-choices-open", IconName::ChevronDown) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(Book::text(cx, "table.rules.choose", &[])) + .on_click(cx.listener(move |book, _, _, cx| { + book.offer_choices(id, (column, row), cx) + })), + ) + .h(px(size.max(16.0))) + .into_any_element(), + ); + } + if rule.show_input && !rule.input_message.is_empty() { + let below = read.place((x, y + h)); + let theme = cx.theme(); + let title = (!rule.input_title.is_empty()).then(|| { + div() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(rule.input_title.clone()) + }); + let note = div() + .absolute() + .left(below.x) + .top(below.y) + .max_w(theme.tooltip_max_width()) + .px_2() + .py_1() + .rounded(theme.radius(Radius::Md)) + .bg(theme.colors.tooltip_bg) + .text_color(theme.colors.tooltip_fg) + .text_size(theme.text_size(TextSize::Sm)) + .shadow(theme.elevation(Elevation::Floating)) + .children(title) + .child(rule.input_message.clone()); + aids.push(raise("table-input-help", note).into_any_element()); + } + aids + } +} diff --git a/apps/ely-table/src/rules/dialogs.rs b/apps/ely-table/src/rules/dialogs.rs new file mode 100644 index 0000000..20bb17c --- /dev/null +++ b/apps/ely-table/src/rules/dialogs.rs @@ -0,0 +1,448 @@ +//! The rule dialogs: validation, conditional format, the format list. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Choice, Input, Select, Switch, TextInput}, + overlays::Dialog as Card, +}; +use ely_lok::sheet::{Format, Validation}; +use gpui::{App, Entity, ParentElement as _, SharedString, Styled as _, Window, div}; + +use super::Fields; +use crate::book::Book; +use crate::dialog::{Dialog, edit}; + +/// What a cell may hold. +const KINDS: [(&str, &str); 8] = [ + ("any", "table.rules.any"), + ("whole", "table.rules.whole"), + ("decimal", "table.rules.decimal"), + ("date", "table.rules.date"), + ("time", "table.rules.time"), + ("textlength", "table.rules.textlength"), + ("list", "table.rules.list"), + ("custom", "table.rules.custom"), +]; + +const OPERATORS: [(&str, &str); 8] = [ + ("between", "table.rules.between"), + ("notbetween", "table.rules.notbetween"), + ("equal", "table.rules.equal"), + ("notequal", "table.rules.notequal"), + ("greater", "table.rules.greater"), + ("less", "table.rules.less"), + ("greaterequal", "table.rules.greaterequal"), + ("lessequal", "table.rules.lessequal"), +]; + +const ALERTS: [(&str, &str); 3] = [ + ("stop", "table.rules.stop"), + ("warning", "table.rules.warning"), + ("info", "table.rules.info"), +]; + +/// Conditions a cell rule tests; more than validation has. +const CONDITIONS: [(&str, &str); 18] = [ + ("greater", "table.rules.greater"), + ("less", "table.rules.less"), + ("between", "table.rules.between"), + ("notbetween", "table.rules.notbetween"), + ("equal", "table.rules.equal"), + ("notequal", "table.rules.notequal"), + ("greaterequal", "table.rules.greaterequal"), + ("lessequal", "table.rules.lessequal"), + ("duplicate", "table.rules.duplicate"), + ("unique", "table.rules.unique"), + ("contains", "table.rules.contains"), + ("beginswith", "table.rules.beginswith"), + ("endswith", "table.rules.endswith"), + ("top10", "table.rules.top"), + ("bottom10", "table.rules.bottom"), + ("aboveaverage", "table.rules.above_average"), + ("belowaverage", "table.rules.below_average"), + ("error", "table.rules.error"), +]; + +/// The kernel's cell styles a rule can apply. +const STYLES: [(&str, &str); 10] = [ + ("Good", "table.style.good"), + ("Neutral", "table.style.neutral"), + ("Bad", "table.style.bad"), + ("Warning", "table.style.warning"), + ("Error", "table.style.error"), + ("Accent", "table.style.accent"), + ("Accent 1", "table.style.accent1"), + ("Accent 2", "table.style.accent2"), + ("Accent 3", "table.style.accent3"), + ("Note", "table.style.note"), +]; + +const FORMATS: [(&str, &str); 3] = [ + ("cell", "table.rules.cell"), + ("scale", "table.rules.scale"), + ("databar", "table.rules.databar"), +]; + +/// Color scales: label, low to high. +pub(super) const SCALES: [(&str, &[&str]); 4] = [ + ("table.rules.scale_rgy", &["#F8696B", "#FFEB84", "#63BE7B"]), + ("table.rules.scale_gyr", &["#63BE7B", "#FFEB84", "#F8696B"]), + ("table.rules.scale_wr", &["#FFFFFF", "#F8696B"]), + ("table.rules.scale_wg", &["#FFFFFF", "#63BE7B"]), +]; + +/// Data bar colors. +pub(super) const BARS: [(&str, &str); 4] = [ + ("table.rules.bar_blue", "#638EC6"), + ("table.rules.bar_green", "#63C384"), + ("table.rules.bar_red", "#FF555A"), + ("table.rules.bar_orange", "#FFB628"), +]; + +/// The table's value for a picked choice. +fn find(table: &[(&'static str, &'static str)], value: &str) -> &'static str { + table + .iter() + .find(|(key, _)| *key == value) + .map(|(key, _)| *key) + .expect("a listed choice") +} + +/// A select over `table`; `set` takes the pick. +fn pick( + this: &Entity, + id: &'static str, + (table, selected): (&[(&'static str, &'static str)], &str), + set: fn(&mut Dialog, &'static str), + cx: &App, +) -> Select { + let choices = table + .iter() + .map(|(value, key)| Choice::new(*value, Book::text(cx, key, &[]))); + let (this, table) = (this.clone(), table.to_vec()); + Select::new(id, choices) + .selected(selected.to_string()) + .on_change(move |value, _, cx| { + let value = find(&table, value); + edit(&this, cx, |dialog, _, _| set(dialog, value)) + }) +} + +/// A switch; `set` takes its state. +fn switch( + this: &Entity, + id: &'static str, + on: bool, + set: fn(&mut Validation, bool), +) -> Switch { + let this = this.clone(); + Switch::new(id, on).on_change(move |on, _, cx| { + edit(&this, cx, |dialog, _, _| { + if let Dialog::Validation { rule, .. } = dialog { + set(rule, on); + } + }) + }) +} + +fn field(input: &Entity) -> gpui::Div { + div().w_full().max_w_64().child(Input::new(input)) +} + +fn rule_of(dialog: &mut Dialog) -> &mut Validation { + match dialog { + Dialog::Validation { rule, .. } => rule, + _ => unreachable!("a validation dialog"), + } +} + +pub fn validation_dialog( + this: Entity, + (rule, fields): (&Validation, &Fields), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let label = |key: &str| Book::text(cx, key, &[]); + let mut rows = DescriptionList::new().item( + label("table.rules.allow"), + pick( + &this, + "table-rule-kind", + (&KINDS, &rule.kind), + |dialog, kind| rule_of(dialog).kind = kind.into(), + cx, + ), + ); + match rule.kind.as_str() { + "whole" | "decimal" | "date" | "time" | "textlength" => { + rows = rows + .item( + label("table.rules.data"), + pick( + &this, + "table-rule-operator", + (&OPERATORS, &rule.operator), + |dialog, op| rule_of(dialog).operator = op.into(), + cx, + ), + ) + .item(label("table.rules.value"), field(&fields.value1)); + if matches!(rule.operator.as_str(), "between" | "notbetween") { + rows = rows.item(label("table.rules.and"), field(&fields.value2)); + } + } + "list" => { + rows = rows + .item(label("table.rules.entries"), field(&fields.list)) + .item( + label("table.rules.dropdown"), + switch(&this, "table-rule-dropdown", rule.dropdown, |rule, on| { + rule.dropdown = on + }), + ); + } + "custom" => rows = rows.item(label("table.rules.formula"), field(&fields.value1)), + _ => {} + } + if rule.kind != "any" { + rows = rows.item( + label("table.rules.blank"), + switch(&this, "table-rule-blank", rule.ignore_blank, |rule, on| { + rule.ignore_blank = on + }), + ); + } + rows = rows.item( + label("table.rules.help"), + switch(&this, "table-rule-help", rule.show_input, |rule, on| { + rule.show_input = on + }), + ); + if rule.show_input { + rows = rows + .item(label("table.rules.title"), field(&fields.input_title)) + .item(label("table.rules.message"), field(&fields.input_message)); + } + if rule.kind != "any" { + rows = rows.item( + label("table.rules.alert"), + switch(&this, "table-rule-alert", rule.show_error, |rule, on| { + rule.show_error = on + }), + ); + if rule.show_error { + rows = rows + .item( + label("table.rules.style"), + pick( + &this, + "table-rule-style", + (&ALERTS, &rule.error_style), + |dialog, style| rule_of(dialog).error_style = style.into(), + cx, + ), + ) + .item(label("table.rules.title"), field(&fields.error_title)) + .item(label("table.rules.message"), field(&fields.error_message)); + } + } + let go = label("table.rules.apply"); + Card::new("table-rule", label("table.rules.validation_title"), dismiss) + .child(rows) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("table-rule-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |book, cx| book.rule_asked(cx))) + }) +} + +/// The open conditional dialog's parts. +pub struct Shown<'a> { + pub kind: &'a str, + pub operator: &'a str, + pub style: &'a str, + pub preset: usize, + pub value1: &'a Entity, + pub value2: &'a Entity, +} + +fn set_conditional( + dialog: &mut Dialog, + change: impl FnOnce(&mut &'static str, &mut &'static str, &mut &'static str, &mut usize), +) { + if let Dialog::Conditional { + kind, + operator, + style, + preset, + .. + } = dialog + { + change(kind, operator, style, preset); + } +} + +pub fn conditional_dialog( + this: Entity, + shown: Shown, + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let label = |key: &str| Book::text(cx, key, &[]); + let mut rows = DescriptionList::new().item( + label("table.rules.kind"), + pick( + &this, + "table-format-kind", + (&FORMATS, shown.kind), + |dialog, value| { + set_conditional(dialog, |kind, _, _, preset| { + *kind = value; + *preset = 0; + }) + }, + cx, + ), + ); + match shown.kind { + "scale" | "databar" => { + let table: Vec<(SharedString, SharedString)> = if shown.kind == "scale" { + SCALES + .iter() + .enumerate() + .map(|(at, (key, _))| (at.to_string().into(), label(key))) + .collect() + } else { + BARS.iter() + .enumerate() + .map(|(at, (key, _))| (at.to_string().into(), label(key))) + .collect() + }; + let picked = this.clone(); + let presets = Select::new( + "table-format-preset", + table + .into_iter() + .map(|(value, text)| Choice::new(value, text)), + ) + .selected(shown.preset.to_string()) + .on_change(move |value, _, cx| { + let at: usize = value.parse().expect("presets are indexes"); + edit(&picked, cx, |dialog, _, _| { + set_conditional(dialog, |_, _, _, preset| *preset = at) + }) + }); + rows = rows.item(label("table.rules.colors"), presets); + } + _ => { + rows = rows.item( + label("table.rules.condition"), + pick( + &this, + "table-format-operator", + (&CONDITIONS, shown.operator), + |dialog, value| set_conditional(dialog, |_, operator, _, _| *operator = value), + cx, + ), + ); + match shown.operator { + "duplicate" | "unique" | "aboveaverage" | "belowaverage" | "error" => {} + "between" | "notbetween" => { + rows = rows + .item(label("table.rules.value"), field(shown.value1)) + .item(label("table.rules.and"), field(shown.value2)); + } + _ => rows = rows.item(label("table.rules.value"), field(shown.value1)), + } + rows = rows.item( + label("table.rules.cell_style"), + pick( + &this, + "table-format-style", + (&STYLES, shown.style), + |dialog, value| set_conditional(dialog, |_, _, style, _| *style = value), + cx, + ), + ); + } + } + let go = label("table.rules.add"); + Card::new( + "table-format", + label("table.rules.conditional_title"), + dismiss, + ) + .child(rows) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("table-format-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| this.update(cx, |book, cx| book.conditional_asked(cx))) + }) +} + +/// One format as the list shows it. +fn entry(format: &Format, cx: &App) -> SharedString { + let kind = FORMATS + .iter() + .find(|(value, _)| *value == format.kind) + .map_or_else( + || format.kind.clone().into(), + |(_, key)| Book::text(cx, key, &[]), + ); + let style = STYLES + .iter() + .find(|(value, _)| *value == format.style) + .map_or_else( + || format.style.clone().into(), + |(_, key)| Book::text(cx, key, &[]), + ); + let args = [ + ("range", format.range.as_str()), + ("kind", kind.as_ref()), + ("style", style.as_ref()), + ("value", format.value.as_str()), + ]; + match format.kind.as_str() { + "cell" => Book::text(cx, "table.rules.entry_cell", &args), + _ => Book::text(cx, "table.rules.entry", &args), + } +} + +pub fn formats_dialog( + this: Entity, + (formats, chosen): (&[Format], usize), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let choices = formats + .iter() + .enumerate() + .map(|(at, format)| Choice::new(at.to_string(), entry(format, cx))); + let picked = this.clone(); + let list = Select::new("table-formats-pick", choices) + .selected(chosen.to_string()) + .on_change(move |value, _, cx| { + let at: usize = value.parse().expect("formats are listed by index"); + edit(&picked, cx, |dialog, _, _| { + if let Dialog::Formats { chosen, .. } = dialog { + *chosen = at; + } + }) + }); + let go = Book::text(cx, "table.rules.remove", &[]); + Card::new( + "table-formats", + Book::text(cx, "table.rules.formats_title", &[]), + dismiss, + ) + .child(list) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("table-formats-remove", go) + .variant(ButtonVariant::Danger) + .on_click(move |_, _, cx| this.update(cx, |book, cx| book.format_removed(cx))) + }) +} diff --git a/apps/ely-table/src/sheets.rs b/apps/ely-table/src/sheets.rs new file mode 100644 index 0000000..e475da2 --- /dev/null +++ b/apps/ely-table/src/sheets.rs @@ -0,0 +1,358 @@ +//! Sheet tabs and the workbook's sheet commands (TABLE-01). + +use std::io; + +use ely_gpui_component::{ + buttons::IconButton, + forms::{Choice, TextInput}, + menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem}, + navigation::{TabPlacement, Tabs as SheetTabs}, + primitives::{IconName, Severity}, +}; +use ely_office::prelude::*; +use gpui::{ + Action, AnyElement, App, AppContext as _, Context, Div, InteractiveElement as _, IntoElement, + Menu, MenuItem, ParentElement as _, Styled as _, Window, actions, div, +}; + +use crate::book::Book; +use crate::dialog::Dialog; + +actions!( + table, + [ + InsertSheet, + RenameSheet, + DuplicateSheet, + MoveSheetLeft, + MoveSheetRight, + HideSheet, + ShowSheet, + DeleteSheet + ] +); + +pub fn menu(cx: &App) -> Menu { + let label = |key: &str| Book::text(cx, key, &[]); + Menu::new(label("menu.sheet")).items([ + MenuItem::action(label("table.sheet.insert"), InsertSheet), + MenuItem::action(label("table.sheet.rename"), RenameSheet), + MenuItem::action(label("table.sheet.duplicate"), DuplicateSheet), + MenuItem::separator(), + MenuItem::action(label("table.sheet.left"), MoveSheetLeft), + MenuItem::action(label("table.sheet.right"), MoveSheetRight), + MenuItem::separator(), + MenuItem::action(label("table.sheet.hide"), HideSheet), + MenuItem::action(label("table.sheet.show"), ShowSheet), + MenuItem::separator(), + MenuItem::action(label("table.protect.menu"), crate::protect::ProtectSheet), + MenuItem::separator(), + MenuItem::action(label("table.sheet.delete"), DeleteSheet), + ]) +} + +/// The same commands on a right click. +fn popup(cx: &App) -> Popup { + let item = |key: &str, action: Box| { + PopupItem::new(Book::text(cx, key, &[])) + .on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx)) + }; + Popup::new() + .item(item("table.sheet.insert", Box::new(InsertSheet))) + .item(item("table.sheet.rename", Box::new(RenameSheet))) + .item(item("table.sheet.duplicate", Box::new(DuplicateSheet))) + .separator() + .item(item("table.sheet.left", Box::new(MoveSheetLeft))) + .item(item("table.sheet.right", Box::new(MoveSheetRight))) + .separator() + .item(item("table.sheet.hide", Box::new(HideSheet))) + .item(item("table.sheet.show", Box::new(ShowSheet))) + .separator() + .item(item("table.sheet.delete", Box::new(DeleteSheet))) +} + +pub(crate) mod dialogs; + +pub fn bind(root: Div, cx: &mut Context) -> Div { + root.on_action(cx.listener(|book, _: &InsertSheet, _, cx| book.insert_sheet(cx))) + .on_action(cx.listener(|book, _: &RenameSheet, window, cx| book.ask_rename(window, cx))) + .on_action(cx.listener(|book, _: &DuplicateSheet, _, cx| book.duplicate_sheet(cx))) + .on_action(cx.listener(|book, _: &MoveSheetLeft, _, cx| book.move_sheet(false, cx))) + .on_action(cx.listener(|book, _: &MoveSheetRight, _, cx| book.move_sheet(true, cx))) + .on_action(cx.listener(|book, _: &HideSheet, _, cx| book.hide_sheet(cx))) + .on_action(cx.listener(|book, _: &ShowSheet, _, cx| book.ask_show(cx))) + .on_action( + cx.listener(|book, _: &DeleteSheet, _, cx| book.ask_delete(book.core.active, cx)), + ) +} + +impl Book { + /// The shown sheet, its name, and every name. + fn current_sheet(&self) -> Option<(usize, String, Vec)> { + self.sheet_in(self.core.active?) + } + + /// Tab `id`: shown sheet, its name, every name. + pub(crate) fn sheet_in(&self, id: u64) -> Option<(usize, String, Vec)> { + let sheets = &self.tab(id)?.extra.sheets; + let names: Vec = sheets.list.iter().map(|sheet| sheet.name.clone()).collect(); + Some((sheets.current, names.get(sheets.current)?.clone(), names)) + } + + fn warn(&self, key: &str, cx: &mut Context) { + let text = Book::text(cx, key, &[]); + self.toast(text, Severity::Warning, cx); + } + + /// The visible sheets as tabs, with an add button. + pub fn sheet_strip(&self, cx: &mut Context) -> AnyElement { + let Some(tab) = self.active_tab() else { + return div().into_any_element(); + }; + let Some(choices) = visible(&tab.extra.sheets) else { + return div().into_any_element(); + }; + let this = cx.entity(); + let picked = this.clone(); + let tabs = SheetTabs::new( + "table-sheets", + choices, + tab.extra.sheets.current.to_string(), + ) + .placement(TabPlacement::Bottom) + .on_change(move |value, _, cx| { + let index: usize = value.parse().expect("sheet tabs are indexes"); + picked.update(cx, |book, cx| book.show_sheet(index, cx)); + }) + .on_context(move |value, at, _, cx| { + let index: usize = value.parse().expect("sheet tabs are indexes"); + this.update(cx, |book, cx| { + book.show_sheet(index, cx); + book.sheet_menu = Some((book.sheet_menu.map_or(0, |(n, _)| n + 1), at)); + cx.notify(); + }); + }); + let add = IconButton::new("table-sheet-add", IconName::Plus) + .tooltip(Book::text(cx, "table.sheet.add", &[])) + .on_click(cx.listener(|book, _, _, cx| book.insert_sheet(cx))); + ContextMenu::new("table-sheet-menu", popup(cx)) + .manual(self.sheet_menu) + .child(div().flex().items_center().px_2().child(tabs).child(add)) + .into_any_element() + } + + /// Shows sheet `index` in order with the queued input. + pub(crate) fn show_sheet(&mut self, index: usize, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return; + }; + let (id, view) = (tab.id, tab.view.clone()); + view.update(cx, |view, _| view.show_part(index)); + self.tab_mut(id) + .expect("the tab is open") + .extra + .sheets + .current = index; + cx.notify(); + } + + fn insert_sheet(&mut self, cx: &mut Context) { + let Some((current, _, names)) = self.current_sheet() else { + return self.warn("command.blocked.no_document", cx); + }; + let name = (1..) + .map(|n| Book::text(cx, "table.sheet.default", &[("n", &n.to_string())])) + .find(|name| !names.iter().any(|taken| taken == name.as_ref())) + .expect("a free name"); + let at = (current + 2).to_string(); + self.run_active("sheet.insert", &[&name, &at], cx); + } + + fn duplicate_sheet(&mut self, cx: &mut Context) { + let Some((current, ..)) = self.current_sheet() else { + return self.warn("command.blocked.no_document", cx); + }; + self.run_active( + "sheet.move", + &[&(current + 2).to_string(), "true", "true"], + cx, + ); + } + + /// Moves the shown sheet; Index is its final place. + fn move_sheet(&mut self, right: bool, cx: &mut Context) { + let Some((current, _, names)) = self.current_sheet() else { + return self.warn("command.blocked.no_document", cx); + }; + if (right && current + 1 == names.len()) || (!right && current == 0) { + return self.warn("table.sheet.edge", cx); + } + let at = if right { current + 2 } else { current }; + self.run_active("sheet.move", &[&at.to_string(), "false", "true"], cx); + } + + fn visible_count(&self, id: u64) -> usize { + self.tab(id).map_or(0, |tab| { + tab.extra + .sheets + .list + .iter() + .filter(|sheet| sheet.visible) + .count() + }) + } + + fn hide_sheet(&mut self, cx: &mut Context) { + let Some((current, ..)) = self.current_sheet() else { + return self.warn("command.blocked.no_document", cx); + }; + if self.core.active.is_none_or(|id| self.visible_count(id) < 2) { + return self.warn("table.sheet.last", cx); + } + self.run_active("sheet.hide", &[¤t.to_string()], cx); + } + + fn ask_show(&mut self, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + return self.warn("command.blocked.no_document", cx); + }; + let hidden: Vec = tab + .extra + .sheets + .list + .iter() + .filter(|sheet| !sheet.visible) + .map(|sheet| sheet.name.clone()) + .collect(); + let Some(chosen) = hidden.first().cloned() else { + return self.warn("table.show.none", cx); + }; + let tab = tab.id; + self.dialog = Some(Dialog::Show { + tab, + hidden, + chosen, + }); + cx.notify(); + } + + pub(crate) fn ask_rename(&mut self, window: &mut Window, cx: &mut Context) { + let (Some(tab), Some((index, name, _))) = (self.core.active, self.current_sheet()) else { + return self.warn("command.blocked.no_document", cx); + }; + // The list shown was read in this state. + let Some(at) = self.read_at(tab, cx) else { + return self.warn("command.blocked.no_document", cx); + }; + let field = cx.new(|cx| TextInput::new(window, cx)); + field.update(cx, |field, cx| field.set_text(name, cx)); + self.dialog = Some(Dialog::Rename { tab, at, index, field }); + cx.notify(); + } + + /// Asks before a delete, naming what refers (UX-07). + pub(crate) fn ask_delete(&mut self, id: Option, cx: &mut Context) { + let Some(id) = id else { + return self.warn("command.blocked.no_document", cx); + }; + if !self.settled(id, cx) { + return self.when_settled(id, move |book, _, cx| book.ask_delete(Some(id), cx)); + } + let Some((index, name, _)) = self.sheet_in(id) else { + return self.warn("command.blocked.no_document", cx); + }; + if self.visible_count(id) < 2 { + return self.warn("table.sheet.last", cx); + } + self.ask_in( + id, + move |worker, doc| { + // The sheet is checked and deleted in one state. + let at = worker.now(doc)?; + if doc.sheets(worker)?.list.get(index).map(|sheet| &sheet.name) != Some(&name) { + return Err(io::Error::other(format!("{}sheet {index} moved", ely_lok::STALE))); + } + let json = worker.values(doc, &ely_lok::sheet::references_query(index))?; + Ok((ely_lok::sheet::references(&json)?, at, name)) + }, + move |book, read, _, cx| match read { + Ok((references, at, name)) => { + book.dialog = Some(Dialog::Delete { + tab: id, + at, + index, + name, + references, + }); + cx.notify(); + } + Err(error) if ely_lok::is_stale(&error) => book.warn("office.error.changed", cx), + Err(error) => book.engine_failed(&error.to_string(), cx), + }, + cx, + ); + } + + /// Renames the sheet the open dialog asked about. + pub(crate) fn rename_asked(&mut self, name: &str, cx: &mut Context) { + let Some(Dialog::Rename { tab, at, index, .. }) = self.dialog.take() else { + return; + }; + self.run_on(tab, &at, "sheet.rename", &[name, &(index + 1).to_string()], cx); + } + + /// Deletes the sheet the open dialog asked about. + pub(crate) fn delete_asked(&mut self, cx: &mut Context) { + let Some(Dialog::Delete { tab, at, index, .. }) = self.dialog.take() else { + return; + }; + self.run_on(tab, &at, "sheet.delete", &[&(index + 1).to_string()], cx); + } +} + +/// Visible sheets as tabs, once the shown one is known. +fn visible(sheets: &ely_lok::Sheets) -> Option> { + let shown = sheets.list.get(sheets.current)?; + if !shown.visible { + return None; + } + Some( + sheets + .list + .iter() + .enumerate() + .filter(|(_, sheet)| sheet.visible) + .map(|(index, sheet)| Choice::new(index.to_string(), sheet.name.clone())) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use ely_lok::{Sheet, Sheets}; + + #[test] + fn tabs_wait_for_a_known_shown_sheet() { + assert!(visible(&Sheets::default()).is_none(), "no list yet"); + let sheet = |name: &str, visible| Sheet { + name: name.into(), + visible, + }; + let hidden = Sheets { + current: 1, + list: vec![sheet("A", true), sheet("B", false)], + }; + assert!(visible(&hidden).is_none(), "a hidden current waits"); + let shown = Sheets { + current: 0, + list: vec![sheet("A", true), sheet("B", false), sheet("C", true)], + }; + let values: Vec<_> = visible(&shown) + .expect("tabs") + .into_iter() + .map(|choice| choice.value) + .collect(); + assert_eq!(values, ["0", "2"]); + } +} diff --git a/apps/ely-table/src/sheets/dialogs.rs b/apps/ely-table/src/sheets/dialogs.rs new file mode 100644 index 0000000..2149076 --- /dev/null +++ b/apps/ely-table/src/sheets/dialogs.rs @@ -0,0 +1,142 @@ +//! The sheet dialogs: rename, show, delete (TABLE-01). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + forms::{Choice, Select, TextInput}, + overlays::{Dialog as Card, PromptDialog}, +}; +use ely_lok::sheet::References; +use ely_office::prelude::*; +use gpui::{App, Entity, ParentElement as _, SharedString, Window}; + +use super::Dialog; +use crate::book::Book; + +/// Why `name` cannot name a sheet, as a message key. +fn refused(name: &str, names: &[String]) -> Option<&'static str> { + if name.is_empty() { + return Some("table.rename.empty"); + } + let bad = name.contains([':', '\\', '/', '?', '*', '[', ']']) + || name.starts_with('\'') + || name.ends_with('\''); + if bad { + return Some("table.rename.invalid"); + } + names + .iter() + .any(|taken| taken.to_lowercase() == name.to_lowercase()) + .then_some("table.rename.taken") +} + +pub(crate) fn rename( + this: Entity, + (index, field, names): (usize, &Entity, Vec), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> PromptDialog { + let current = names.get(index).cloned().unwrap_or_default(); + let others: Vec = names.into_iter().filter(|name| *name != current).collect(); + let title = Book::text(cx, "table.rename.title", &[("name", ¤t)]); + let messages: Vec<(&str, SharedString)> = [ + "table.rename.empty", + "table.rename.invalid", + "table.rename.taken", + ] + .into_iter() + .map(|key| (key, Book::text(cx, key, &[]))) + .collect(); + PromptDialog::new("table-rename", title, field, dismiss) + .label(Book::text(cx, "table.rename.label", &[])) + .submit(Book::text(cx, "table.size.apply", &[])) + .check(move |text| match refused(text, &others) { + Some(key) => Err(messages + .iter() + .find(|(known, _)| *known == key) + .map(|(_, message)| message.clone()) + .expect("every refusal has a message")), + None => Ok(()), + }) + .on_submit(move |text, _, cx| { + let name = text.to_string(); + this.update(cx, |book, cx| book.rename_asked(&name, cx)) + }) +} + +pub(crate) fn show( + this: Entity, + (tab, hidden, chosen): (u64, &[String], &str), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let choices = hidden + .iter() + .map(|name| Choice::new(name.clone(), name.clone())); + let picked = this.clone(); + let select = Select::new("table-show-pick", choices) + .selected(chosen.to_string()) + .on_change(move |value, _, cx| { + picked.update(cx, |book, cx| { + if let Some(Dialog::Show { chosen, .. }) = &mut book.dialog { + *chosen = value.to_string(); + } + cx.notify(); + }) + }); + let label = Book::text(cx, "table.show.go", &[]); + Card::new( + "table-show", + Book::text(cx, "table.show.title", &[]), + dismiss, + ) + .child(select) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("table-show-go", label) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| { + this.update(cx, |book, cx| { + let Some(Dialog::Show { chosen, .. }) = book.dialog.take() else { + return; + }; + book.run_in(Some(tab), "sheet.show", &[&chosen], cx); + }) + }) + }) +} + +pub(crate) fn delete( + this: Entity, + (name, references): (&str, &References), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let mut detail = if references.formulas + references.names == 0 { + Book::text(cx, "table.delete.alone", &[]) + } else { + Book::text( + cx, + "table.delete.refs", + &[ + ("formulas", &references.formulas.to_string()), + ("first", &references.first), + ("names", &references.names.to_string()), + ], + ) + }; + if references.dynamic > 0 { + let count = references.dynamic.to_string(); + let dynamic = Book::text(cx, "table.delete.dynamic", &[("count", &count)]); + detail = format!("{detail} {dynamic}").into(); + } + let label = Book::text(cx, "table.delete.go", &[]); + let title = Book::text(cx, "table.delete.title", &[("name", name)]); + Card::new("table-delete", title, dismiss) + .detail(detail) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("table-delete-go", label) + .variant(ButtonVariant::Danger) + .on_click(move |_, _, cx| this.update(cx, |book, cx| book.delete_asked(cx))) + }) +} diff --git a/apps/ely-table/src/template.rs b/apps/ely-table/src/template.rs new file mode 100644 index 0000000..dda1182 --- /dev/null +++ b/apps/ely-table/src/template.rs @@ -0,0 +1,15 @@ +//! The blank workbook a new window starts from (TABLE-01). + +use gpui::App; + +/// A one-sheet flat ODF workbook with a local name. +pub fn blank(cx: &App) -> String { + let name = ely_ui::i18n::text(cx, "table.sheet.default", &[("n", "1")]); + let name = name + .replace('&', "&") + .replace('<', "<") + .replace('"', """); + format!( + "\n\n" + ) +} diff --git a/apps/ely-table/src/tests.rs b/apps/ely-table/src/tests.rs new file mode 100644 index 0000000..173b146 --- /dev/null +++ b/apps/ely-table/src/tests.rs @@ -0,0 +1,401 @@ +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; + +use ely_office::prelude::*; +use gpui::{AppContext as _, Entity, TestAppContext, WindowHandle}; + +use crate::book::Book; +use crate::dialog::Dialog; + +struct Blank; + +impl gpui::Render for Blank { + fn render( + &mut self, + _: &mut gpui::Window, + _: &mut gpui::Context, + ) -> impl gpui::IntoElement { + gpui::Empty + } +} + +/// An unrendered Book over a fresh worker, if LOK exists. +fn book(cx: &mut TestAppContext, dir: &Path) -> Option<(WindowHandle, Entity)> { + let install = ely_lok::install_dir().ok()?; + let worker = ely_lok::Worker::spawn(&install, &dir.join("profile")).expect("the worker"); + let work = dir.join("work"); + std::fs::create_dir_all(&work).expect("the work dir"); + cx.update(|cx| { + ely_ui::settings::install_defaults(cx); + ely_ui::i18n::install(cx); + }); + let worker = Arc::new(Mutex::new(worker)); + let window = cx.add_window(|_, _| Blank); + let book = cx + .update_window(window.into(), |_, window, app| { + let book = app.new(|cx| Book::new(worker, work, window, cx)); + book.update(app, |book, _| { + book.core.window = Some(window.window_handle()) + }); + book + }) + .expect("the window lives"); + Some((window, book)) +} + +/// Steps the scheduler until `done` or ten seconds pass. +#[track_caller] +fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline { + if !cx.executor().tick() { + cx.executor() + .advance_clock(std::time::Duration::from_millis(20)); + } + if cx.update(|app| done(app)) { + return; + } + } + panic!("the state never settled"); +} + +/// Waits until every tab's input landed and was read. +#[track_caller] +fn fresh(cx: &TestAppContext, book: &Entity) { + settle(cx, |app| { + let book = book.read(app); + let mut worker = book.core.worker.lock().expect("the worker lock"); + book.core.tabs.iter().all(|tab| { + tab.view.read(app).settled() + && tab.extra.read_at.is_some() && tab.extra.read_at.as_ref() == worker.now(&tab.doc).ok().as_ref() + }) + }); +} + +fn update( + cx: &mut TestAppContext, + book: &Entity, + f: impl FnOnce(&mut Book, &mut gpui::Context), +) { + cx.update(|app| book.update(app, f)); +} + +fn sheet_names(app: &gpui::App, book: &Entity) -> Vec { + book.read(app).active_tab().map_or_else(Vec::new, |tab| { + tab.extra + .sheets + .list + .iter() + .map(|sheet| sheet.name.clone()) + .collect() + }) +} + +/// A blank workbook, loaded and read. +fn blank(cx: &mut TestAppContext, book: &Entity) { + update(cx, book, |book, cx| book.new_blank(cx)); + settle(cx, |app| sheet_names(app, book).len() == 1); +} + +#[test] +fn a_new_workbook_saves_xlsx_and_keeps_its_sheets() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &book); + update(&mut cx, &book, |book, cx| book.run_active("cell.enter", &["42"], cx)); + fresh(&cx, &book); + update(&mut cx, &book, |book, cx| book.run_active("sheet.insert", &["Plan", "2"], cx)); + let first = cx.update(|app| sheet_names(app, &book)[0].clone()); + settle(&cx, |app| { + sheet_names(app, &book) == [first.as_str(), "Plan"] + }); + let path = dir.path().join("book.xlsx"); + let id = cx.update(|app| book.read(app).core.tabs[0].id); + cx.update_window(window.into(), |_, window, app| { + book.update(app, |book, cx| { + book.write_as(id, path.clone(), None, window, cx) + }) + }) + .expect("the window lives"); + settle(&cx, |app| book.read(app).core.tabs[0].name == "book.xlsx"); + update(&mut cx, &book, |book, cx| { + book.close_tab(id, cx); + book.open_file(path.clone(), cx); + }); + settle(&cx, |app| { + sheet_names(app, &book) == [first.as_str(), "Plan"] + }); + let bytes = std::fs::read(&path).expect("the file"); + assert!(bytes.starts_with(b"PK"), "an XLSX zip"); +} + +#[test] +fn deleting_names_what_refers_and_merging_asks_when_data_would_hide() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &book); + let first = cx.update(|app| sheet_names(app, &book)[0].clone()); + let formula = format!("='{first}'!A1*2"); + update(&mut cx, &book, |book, cx| { + book.run_active("cell.enter", &["1"], cx); + book.run_active("cell.goto", &["B1"], cx); + book.run_active("cell.enter", &["b"], cx); + }); + fresh(&cx, &book); + update(&mut cx, &book, |book, cx| { + book.run_active("sheet.insert", &["Uses", "2"], cx); + book.run_active("cell.enter", &[&formula], cx); + }); + settle(&cx, |app| sheet_names(app, &book).len() == 2); + update(&mut cx, &book, |book, cx| { + let tab = book.active_tab().expect("a tab"); + let mut worker = book.core.worker.lock().expect("the worker lock"); + tab.doc.show_sheet(&mut worker, 0).expect("Sheet1 shows"); + drop(worker); + cx.notify(); + }); + settle(&cx, |app| { + book.read(app) + .active_tab() + .is_some_and(|tab| tab.extra.sheets.current == 0) + }); + update(&mut cx, &book, |book, cx| { + book.ask_delete(book.core.active, cx) + }); + settle(&cx, |app| { + matches!(book.read(app).dialog, Some(Dialog::Delete { .. })) + }); + let referred = cx.update(|app| match &book.read(app).dialog { + Some(Dialog::Delete { references, .. }) => references.formulas, + _ => 0, + }); + assert_eq!(referred, 1, "the formula on Uses is named"); + update(&mut cx, &book, |book, cx| { + book.dialog = None; + book.run_active("cell.goto", &["A1:B1"], cx); + book.merge(book.core.active, cx); + }); + settle(&cx, |app| { + matches!(book.read(app).dialog, Some(Dialog::Merge { .. })) + }); + update(&mut cx, &book, |book, cx| { + book.dialog = None; + book.run_active("cell.goto", &["C1:D1"], cx); + book.merge(book.core.active, cx); + }); + settle(&cx, |app| { + let book = book.read(app); + book.dialog.is_none() + && book.active_tab().is_some_and(|tab| { + tab.view.read(app).settled() && tab.view.read(app).cell().address == "C1:D1" + }) + }); +} + +#[test] +fn a_queued_entry_lands_before_the_sheet_switches() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &book); + update(&mut cx, &book, |book, cx| { + book.run_active("sheet.insert", &["Two", "2"], cx); + }); + settle(&cx, |app| sheet_names(app, &book).len() == 2); + update(&mut cx, &book, |book, cx| book.show_sheet(0, cx)); + settle(&cx, |app| { + book.read(app) + .active_tab() + .is_some_and(|tab| tab.extra.sheets.current == 0 && tab.view.read(app).settled()) + }); + update(&mut cx, &book, |book, cx| { + book.run_active("cell.enter", &["42"], cx); + book.show_sheet(1, cx); + book.run_active("cell.enter", &["99"], cx); + }); + settle(&cx, |app| { + book.read(app) + .active_tab() + .is_some_and(|tab| tab.extra.sheets.current == 1 && tab.view.read(app).settled()) + }); + let path = dir.path().join("two.fods"); + cx.update(|app| { + let book = book.read(app); + let mut worker = book.core.worker.lock().expect("the worker lock"); + let doc = &book.core.tabs[0].doc; + doc.save_as(&mut worker, &ely_lok::file_url(&path), "fods") + .expect("the save"); + }); + let xml = std::fs::read_to_string(&path).expect("the save"); + let split = xml.find("table:name=\"Two\"").expect("the second sheet"); + let (first, second) = xml.split_at(split); + assert!( + first.contains("office:value=\"42\""), + "42 stays on the first sheet" + ); + assert!( + second.contains("office:value=\"99\""), + "99 goes to the second" + ); +} + +#[test] +fn a_switch_stays_with_its_workbook() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let current = |app: &gpui::App, at: usize| book.read(app).core.tabs[at].extra.sheets.current; + for _ in 0..2 { + update(&mut cx, &book, |book, cx| book.new_blank(cx)); + } + settle(&cx, |app| { + let tabs = &book.read(app).core.tabs; + tabs.len() == 2 && tabs.iter().all(|tab| tab.extra.sheets.list.len() == 1) + }); + for at in 0..2 { + update(&mut cx, &book, |book, cx| { + book.core.active = Some(book.core.tabs[at].id); + book.run_active("sheet.insert", &["Two", "2"], cx); + book.show_sheet(0, cx); + }); + } + settle(&cx, |app| { + book.read(app) + .core + .tabs + .iter() + .all(|tab| tab.extra.sheets.list.len() == 2 && tab.view.read(app).settled()) + }); + update(&mut cx, &book, |book, cx| { + book.core.active = Some(book.core.tabs[0].id); + book.run_active("cell.enter", &["42"], cx); + book.show_sheet(1, cx); + book.core.active = Some(book.core.tabs[1].id); + }); + settle(&cx, |app| { + book.read(app) + .core + .tabs + .iter() + .all(|tab| tab.view.read(app).settled()) + }); + settle(&cx, |app| current(app, 0) == 1); + assert_eq!( + cx.update(|app| current(app, 1)), + 0, + "the other workbook stays" + ); +} + +#[test] +fn the_bars_follow_the_active_workbook() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + for _ in 0..2 { + update(&mut cx, &book, |book, cx| book.new_blank(cx)); + } + settle(&cx, |app| book.read(app).core.tabs.len() == 2); + update(&mut cx, &book, |book, cx| { + book.core.active = Some(book.core.tabs[1].id); + book.run_active("cell.goto", &["C3"], cx); + }); + let address = |app: &gpui::App, at: usize| { + book.read(app).core.tabs[at] + .view + .read(app) + .cell() + .address + .clone() + }; + settle(&cx, |app| { + address(app, 0) == "A1" && address(app, 1) == "C3" + }); + let name = |cx: &mut TestAppContext, at: usize| { + update(cx, &book, |book, cx| { + book.core.active = Some(book.core.tabs[at].id); + book.refill(cx); + }); + cx.update(|app| book.read(app).bars.name.read(app).text().to_string()) + }; + assert_eq!(name(&mut cx, 0), "A1", "the first workbook's cell"); + assert_eq!(name(&mut cx, 1), "C3", "then the second's"); + assert_eq!(name(&mut cx, 0), "A1", "and back"); +} + +#[test] +fn a_delete_stays_with_its_workbook() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let count = |app: &gpui::App, at: usize| book.read(app).core.tabs[at].extra.sheets.list.len(); + for _ in 0..2 { + update(&mut cx, &book, |book, cx| book.new_blank(cx)); + } + settle(&cx, |app| book.read(app).core.tabs.len() == 2); + for (at, name) in [(0, "OriginTwo"), (1, "OtherTwo")] { + fresh(&cx, &book); + update(&mut cx, &book, |book, cx| { + book.core.active = Some(book.core.tabs[at].id); + book.run_active("sheet.insert", &[name, "2"], cx); + book.show_sheet(1, cx); + }); + } + settle(&cx, |app| count(app, 0) == 2 && count(app, 1) == 2); + update(&mut cx, &book, |book, cx| { + book.core.active = Some(book.core.tabs[0].id); + book.run_active("cell.enter", &["42"], cx); + book.ask_delete(book.core.active, cx); + }); + settle(&cx, |app| { + matches!(book.read(app).dialog, Some(Dialog::Delete { .. })) + }); + // The user moves on while the dialog is open. + update(&mut cx, &book, |book, _| { + book.core.active = Some(book.core.tabs[1].id) + }); + let asked = cx.update(|app| match &book.read(app).dialog { + Some(Dialog::Delete { name, .. }) => name.clone(), + _ => String::new(), + }); + assert_eq!( + asked, "OriginTwo", + "the dialog names the first workbook's sheet" + ); + update(&mut cx, &book, |book, cx| book.delete_asked(cx)); + settle(&cx, |app| count(app, 0) == 1); + assert_eq!( + cx.update(|app| count(app, 1)), + 2, + "the other workbook keeps both" + ); +} + +mod analysis; +mod data; +mod names; +mod numbers; +mod output; +mod rules; diff --git a/apps/ely-table/src/tests/analysis.rs b/apps/ely-table/src/tests/analysis.rs new file mode 100644 index 0000000..1f0371c --- /dev/null +++ b/apps/ely-table/src/tests/analysis.rs @@ -0,0 +1,184 @@ +use ely_lok::sheet::{self, Layout}; + +use super::data::{cells, open, quiet, run}; +use super::*; +use crate::analysis::Asked; + +/// Reads through the active tab's worker. +fn read(cx: &mut TestAppContext, book: &Entity, what: &str, parse: fn(&str) -> T) -> T { + quiet(cx, book); + cx.update(|app| { + let book = book.read(app); + let tab = book.active_tab().expect("a tab"); + let mut worker = book.core.worker.lock().expect("the worker lock"); + parse(&worker.values(&tab.doc, what).expect("the read")) + }) +} + +fn ask(cx: &mut TestAppContext, book: &Entity, asked: Asked, is: fn(&Dialog) -> bool) { + update(cx, book, |book, cx| { + book.dialog = None; + book.ask(asked, cx) + }); + settle(cx, |app| book.read(app).dialog.as_ref().is_some_and(is)); +} + +#[test] +fn a_pivot_table_is_made_then_changed_in_place() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + open(&mut cx, &book); + run(&mut cx, &book, "cell.goto", &["B3"]); + ask(&mut cx, &book, Asked::Pivot, |dialog| { + matches!(dialog, Dialog::Pivot { editing: false, .. }) + }); + update(&mut cx, &book, |book, cx| { + let Some(Dialog::Pivot { + source, + fields, + layout, + .. + }) = &mut book.dialog + else { + unreachable!("a pivot dialog") + }; + assert!(source.ends_with("$A$1:$C$5"), "the data area: {source}"); + assert_eq!(fields, &["Name", "Qty", "City"], "the header row"); + layout.rows.push("City".into()); + layout.data.push(("Qty".into(), "sum".into())); + book.pivot_asked(cx) + }); + settle(&cx, |app| sheet_names(app, &book).len() == 2); + let made = read(&mut cx, &book, sheet::PIVOT, |json| { + sheet::pivot(json).expect("parses") + }) + .expect("the cursor is on the new table"); + assert_eq!(made.layout.rows, ["City"]); + let shown = cells(&mut cx, &book, "A1:B4"); + assert!( + shown.contains("x\t6") && shown.contains("y\t3"), + "{shown:?}" + ); + + run(&mut cx, &book, "cell.goto", &["A2"]); + ask(&mut cx, &book, Asked::Pivot, |dialog| { + matches!(dialog, Dialog::Pivot { editing: true, .. }) + }); + let changed = Layout { + rows: vec!["Name".into()], + data: vec![("Qty".into(), "max".into())], + ..Layout::default() + }; + update(&mut cx, &book, |book, cx| { + if let Some(Dialog::Pivot { layout, .. }) = &mut book.dialog { + *layout = changed.clone(); + } + book.pivot_asked(cx) + }); + run(&mut cx, &book, "cell.goto", &["A2"]); + let now = read(&mut cx, &book, sheet::PIVOT, |json| { + sheet::pivot(json).expect("parses") + }); + let placed = Layout { + columns: vec![String::new()], + ..changed + }; + assert_eq!(now.expect("still there").layout, placed); + let sheets = cx.update(|app| sheet_names(app, &book)); + assert_eq!(sheets.len(), 2, "the change made no sheet"); +} + +#[test] +fn a_chart_is_inserted_then_edited() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + open(&mut cx, &book); + run(&mut cx, &book, "cell.goto", &["A1:B5"]); + ask(&mut cx, &book, Asked::Chart, |dialog| { + matches!(dialog, Dialog::Chart { chart: None, .. }) + }); + let range = cx.update(|app| { + let Some(Dialog::Chart { fields, .. }) = &book.read(app).dialog else { + unreachable!("a chart dialog") + }; + fields.range.read(app).text().to_string() + }); + assert!(range.ends_with("$A$1:$B$5"), "the picked range: {range}"); + update(&mut cx, &book, |book, cx| { + let Some(Dialog::Chart { look, fields, .. }) = &mut book.dialog else { + unreachable!("a chart dialog") + }; + look.kind = "line".into(); + fields + .look + .title + .update(cx, |input, cx| input.set_text("Qty by name", cx)); + book.chart_asked(cx) + }); + let chart = |cx: &mut TestAppContext| { + read(cx, &book, sheet::CHART, |json| { + sheet::chart(json).expect("parses") + }) + }; + let made = chart(&mut cx).expect("the new chart is selected"); + assert_eq!( + (made.look.kind.as_str(), made.look.title.as_str()), + ("line", "Qty by name") + ); + + ask(&mut cx, &book, Asked::Chart, |dialog| { + matches!(dialog, Dialog::Chart { chart: Some(_), .. }) + }); + update(&mut cx, &book, |book, cx| { + if let Some(Dialog::Chart { look, .. }) = &mut book.dialog { + look.kind = "pie".into(); + look.legend = false; + } + book.chart_asked(cx) + }); + let edited = chart(&mut cx).expect("still selected"); + assert_eq!( + (edited.look.kind.as_str(), edited.look.legend), + ("pie", false) + ); + assert_eq!(edited.look.title, "Qty by name", "the title stays"); +} + +#[test] +fn asking_on_an_empty_cell_leaves_enter_moving_down() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &book); + update(&mut cx, &book, |book, cx| book.ask(Asked::Chart, cx)); + for _ in 0..200 { + cx.executor().tick(); + } + quiet(&cx, &book); + let address = cx.update(|app| { + let book = book.read(app); + let tab = book.active_tab().expect("a tab"); + let mut worker = book.core.worker.lock().expect("the worker lock"); + worker.post_key(&tab.doc, '7' as u32, 0).expect("a key"); + worker.post_key(&tab.doc, 0, 1280).expect("Enter"); + let json = worker + .values(&tab.doc, sheet::SELECTED) + .expect("the selection"); + sheet::selected_range(&json) + .expect("parses") + .expect("one") + .range + }); + assert!(address.ends_with("$A$2"), "Enter moved down: {address}"); +} diff --git a/apps/ely-table/src/tests/data.rs b/apps/ely-table/src/tests/data.rs new file mode 100644 index 0000000..9346dd8 --- /dev/null +++ b/apps/ely-table/src/tests/data.rs @@ -0,0 +1,302 @@ +use ely_gpui_component::tables::{FilterRule, SortKey, Test}; +use ely_lok::{Request, sheet}; + +use super::*; + +/// Name, Qty, City; one repeated row. +const DATA: &str = r#" + + + NameQtyCity + a3x + b1y + a3x + c2y + +"#; + +pub(super) fn open(cx: &mut TestAppContext, book: &Entity) { + update(cx, book, |book, cx| { + book.new_from_text("data.fods", DATA, cx) + }); + settle(cx, |app| sheet_names(app, book) == ["S"]); +} + +/// Waits for the active tab's queued input. +pub(super) fn quiet(cx: &TestAppContext, book: &Entity) { + settle(cx, |app| { + book.read(app) + .active_tab() + .is_some_and(|tab| tab.view.read(app).settled()) + }); +} + +/// The cells of `range`, read through the worker. +pub(super) fn cells(cx: &mut TestAppContext, book: &Entity, range: &str) -> String { + quiet(cx, book); + cx.update(|app| { + let book = book.read(app); + let tab = book.active_tab().expect("a tab"); + let mut worker = book.core.worker.lock().expect("the worker lock"); + worker + .run(&tab.doc, "cell.goto", &[range]) + .expect("the range"); + tab.doc + .selection_text(&mut worker) + .expect("the text") + .trim() + .to_string() + }) +} + +pub(super) fn run(cx: &mut TestAppContext, book: &Entity, id: &str, values: &[&str]) { + update(cx, book, |book, cx| book.run_active(id, values, cx)); + quiet(cx, book); +} + +/// The column's entries the filter drops. +fn dropped(cx: &mut TestAppContext, book: &Entity, column: u32) -> Vec { + quiet(cx, book); + cx.update(|app| { + let book = book.read(app); + let tab = book.active_tab().expect("a tab"); + let mut worker = book.core.worker.lock().expect("the worker lock"); + let json = worker + .values(&tab.doc, &sheet::cell_query(sheet::FILTER, column, 0)) + .expect("the entries"); + sheet::filter_values(&json) + .expect("parses") + .entries + .into_iter() + .filter(|entry| !entry.selected) + .map(|entry| entry.text) + .collect() + }) +} + +pub(super) fn asked(cx: &TestAppContext, book: &Entity, is: fn(&Dialog) -> bool) { + settle(cx, |app| book.read(app).dialog.as_ref().is_some_and(is)); +} + +#[test] +fn sort_filter_duplicates_and_columns_run_from_their_dialogs() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + open(&mut cx, &book); + run(&mut cx, &book, "cell.goto", &["B3"]); + update(&mut cx, &book, |book, cx| { + book.ask_data(crate::data::Asked::Sort, cx) + }); + asked(&cx, &book, |dialog| matches!(dialog, Dialog::Sort { .. })); + update(&mut cx, &book, |book, cx| { + let Some(Dialog::Sort { + picked, + keys, + header, + .. + }) = &mut book.dialog + else { + unreachable!("checked above"); + }; + assert_eq!(picked.top, ["Name", "Qty", "City"]); + assert!(*header, "the kernel sees headers"); + assert_eq!(crate::data::title(picked, 1, *header), "Qty"); + *keys = vec![SortKey { + column: "1".into(), + rising: true, + }]; + book.data_asked(cx); + }); + assert_eq!(cells(&mut cx, &book, "A2:A5"), "b\nc\na\na"); + + run(&mut cx, &book, "cell.goto", &["A1"]); + update(&mut cx, &book, |book, cx| { + book.ask_data(crate::data::Asked::Filter, cx) + }); + asked(&cx, &book, |dialog| matches!(dialog, Dialog::Filter { .. })); + update(&mut cx, &book, |book, cx| { + if let Some(Dialog::Filter { rules, .. }) = &mut book.dialog { + *rules = vec![FilterRule { + column: "2".into(), + test: Test::Equals, + value: "x".into(), + }]; + } + book.data_asked(cx); + }); + assert_eq!(dropped(&mut cx, &book, 2), ["y"]); + run(&mut cx, &book, "data.unfilter", &[]); + + run(&mut cx, &book, "cell.goto", &["A2"]); + update(&mut cx, &book, |book, cx| { + book.ask_data(crate::data::Asked::Dedupe, cx) + }); + asked(&cx, &book, |dialog| matches!(dialog, Dialog::Dedupe { .. })); + update(&mut cx, &book, |book, cx| book.data_asked(cx)); + assert_eq!(cells(&mut cx, &book, "A1:A5"), "Name\nb\nc\na"); + + run(&mut cx, &book, "cell.goto", &["A7"]); + run(&mut cx, &book, "cell.enter", &["p;q;r"]); + run(&mut cx, &book, "cell.goto", &["A7"]); + update(&mut cx, &book, |book, cx| book.ask_columns(cx)); + asked(&cx, &book, |dialog| { + matches!(dialog, Dialog::Columns { .. }) + }); + update(&mut cx, &book, |book, cx| { + if let Some(Dialog::Columns { separators, .. }) = &mut book.dialog { + *separators = vec!["59".into()]; + } + book.columns_asked(cx); + }); + assert_eq!(cells(&mut cx, &book, "A7:C7"), "p\tq\tr"); +} + +#[test] +fn a_column_button_lists_validation_offers_and_formats_list() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + open(&mut cx, &book); + run(&mut cx, &book, "cell.goto", &["A1"]); + run(&mut cx, &book, "data.autofilter", &[]); + let id = cx.update(|app| book.read(app).core.active.expect("a tab")); + update(&mut cx, &book, |book, cx| { + book.ask_column(id, &Request::Filter { column: 0, row: 0 }, cx) + }); + asked(&cx, &book, |dialog| matches!(dialog, Dialog::Column { .. })); + update(&mut cx, &book, |book, cx| { + let Some(Dialog::Column { + entries, picked, .. + }) = &mut book.dialog + else { + unreachable!("checked above"); + }; + assert_eq!(picked.len(), entries.len(), "nothing filtered yet"); + *picked = vec!["c".into()]; + book.column_asked(None, cx); + }); + assert_eq!(dropped(&mut cx, &book, 0), ["a", "b"]); + run(&mut cx, &book, "data.unfilter", &[]); + + run(&mut cx, &book, "cell.goto", &["E2"]); + update(&mut cx, &book, |book, cx| book.ask_rule(cx)); + asked(&cx, &book, |dialog| { + matches!(dialog, Dialog::Validation { .. }) + }); + update(&mut cx, &book, |book, cx| { + let Some(Dialog::Validation { rule, fields, .. }) = &mut book.dialog else { + unreachable!("checked above"); + }; + rule.kind = "list".into(); + rule.show_input = true; + fields + .list + .update(cx, |list, cx| list.set_text("Red\nGreen", cx)); + fields + .input_message + .update(cx, |note, cx| note.set_text("Pick a color", cx)); + book.rule_asked(cx); + }); + run(&mut cx, &book, "cell.goto", &["E3"]); + run(&mut cx, &book, "cell.goto", &["E2"]); + settle(&cx, |app| { + book.read(app) + .active_tab() + .and_then(|tab| tab.extra.rule.as_ref()) + .is_some_and(|(at, rule)| at.1 == "E2" && rule.input_message == "Pick a color") + }); + update(&mut cx, &book, |book, cx| { + book.offer_choices(id, (4, 1), cx) + }); + settle(&cx, |app| book.read(app).choices.is_some()); + let values = cx.update(|app| { + let choices = book.read(app).choices.as_ref().expect("the menu"); + choices.values.clone() + }); + assert_eq!(values, ["Red", "Green"]); + + run(&mut cx, &book, "cell.goto", &["B2:B5"]); + cx.update_window(window.into(), |_, window, app| { + book.update(app, |book, cx| book.ask_conditional(window, cx)) + }) + .expect("the window lives"); + update(&mut cx, &book, |book, cx| { + if let Some(Dialog::Conditional { value1, .. }) = &book.dialog { + value1.update(cx, |value, cx| value.set_text("2", cx)); + } + book.conditional_asked(cx); + }); + quiet(&cx, &book); + update(&mut cx, &book, |book, cx| book.ask_formats(cx)); + asked(&cx, &book, |dialog| { + matches!(dialog, Dialog::Formats { .. }) + }); + let style = cx.update(|app| match &book.read(app).dialog { + Some(Dialog::Formats { formats, .. }) => formats[0].style.clone(), + _ => unreachable!("checked above"), + }); + assert_eq!(style, "Good"); + update(&mut cx, &book, |book, cx| book.format_removed(cx)); + quiet(&cx, &book); + update(&mut cx, &book, |book, cx| book.ask_formats(cx)); + assert!( + cx.update(|app| book.read(app).dialog.is_none()), + "none left" + ); +} + +#[test] +fn the_panel_reads_number_formats_and_find_counts_cells() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + open(&mut cx, &book); + run(&mut cx, &book, "cell.goto", &["B2"]); + run(&mut cx, &book, "number.percent", &[]); + update(&mut cx, &book, |book, cx| { + let tab = book.active_tab().expect("a tab"); + let shown = (tab.view.clone(), tab.doc.clone()); + book.panel + .update(cx, |panel, cx| panel.show(Some((&shown.0, &shown.1)), cx)); + }); + let kind = |app: &gpui::App| book.read(app).panel.read(app).number.clone(); + settle(&cx, |app| { + kind(app).is_some_and(|(_, kind)| kind == "percent") + }); + let panel = cx.update(|app| book.read(app).panel.clone()); + cx.update(|app| panel.update(app, |panel, cx| panel.apply_code("[ZZZ]0", cx))); + quiet(&cx, &book); + cx.update(|app| panel.update(app, |panel, cx| panel.apply_code("0.0", cx))); + settle(&cx, |app| kind(app).is_some_and(|(code, _)| code == "0.0")); + + cx.update_window(window.into(), |_, window, app| { + book.update(app, |book, cx| { + book.on_find(&ely_office::FindReplace, window, cx) + }) + }) + .expect("the window lives"); + let query = cx.update(|app| { + let finder = book.read(app).core.finder.as_ref().expect("the finder"); + finder.query.clone() + }); + cx.update(|app| query.update(app, |query, cx| query.set_text("a", cx))); + settle(&cx, |app| { + book.read(app) + .core + .finder + .as_ref() + .is_some_and(|finder| finder.all.len() == 3) + }); +} diff --git a/apps/ely-table/src/tests/names.rs b/apps/ely-table/src/tests/names.rs new file mode 100644 index 0000000..0af6870 --- /dev/null +++ b/apps/ely-table/src/tests/names.rs @@ -0,0 +1,364 @@ +use super::*; + +/// The active workbook's names, from the kernel. +fn names(app: &gpui::App, book: &Entity) -> Vec { + let book = book.read(app); + let tab = book.active_tab().expect("a tab"); + let mut worker = book.core.worker.lock().expect("the worker lock"); + let json = worker + .values(&tab.doc, ely_lok::sheet::NAMES) + .expect("names"); + ely_lok::sheet::names(&json).expect("the names parse") +} + +/// Opens the define dialog in the test window. +fn ask_define(cx: &mut TestAppContext, book: &Entity) { + cx.update(|app| book.update(app, |book, cx| book.ask_define(cx))); +} + +/// The define dialog's range text, once it is open. +fn asked_range(cx: &TestAppContext, book: &Entity) -> String { + settle(cx, |app| { + matches!(book.read(app).dialog, Some(Dialog::Define { .. })) + }); + cx.update(|app| match &book.read(app).dialog { + Some(Dialog::Define { range, .. }) => range.read(app).text().to_string(), + _ => unreachable!("checked above"), + }) +} + +/// Waits until every queued input of every tab landed. +fn quiet(cx: &TestAppContext, book: &Entity) { + settle(cx, |app| { + book.read(app) + .core + .tabs + .iter() + .all(|tab| tab.view.read(app).settled()) + }); +} + +#[test] +fn a_name_is_checked_then_defined_on_the_selection() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &book); + let sheet = cx.update(|app| sheet_names(app, &book)[0].clone()); + update(&mut cx, &book, |book, cx| { + book.run_active("cell.goto", &["B2:C3"], cx) + }); + settle(&cx, |app| { + book.read(app) + .active_tab() + .is_some_and(|tab| tab.view.read(app).cell().address == "B2:C3") + }); + update(&mut cx, &book, |book, cx| book.ask_define(cx)); + settle(&cx, |app| { + matches!(book.read(app).dialog, Some(Dialog::Define { .. })) + }); + let define = |cx: &mut TestAppContext, name: &str| { + cx.update(|app| { + book.update(app, |book, cx| { + if let Some(Dialog::Define { name: field, .. }) = &book.dialog { + let name = name.to_string(); + field.update(cx, |field, cx| field.set_text(name, cx)); + } + book.define(cx); + }) + }); + }; + // Checks answer in order: the cell name first. + define(&mut cx, "C4"); + define(&mut cx, "Area"); + settle(&cx, |app| book.read(app).dialog.is_none()); + settle(&cx, |app| { + book.read(app) + .active_tab() + .is_some_and(|tab| tab.view.read(app).settled()) + }); + let defined = cx.update(|app| names(app, &book)); + assert_eq!(defined.len(), 1); + assert_eq!( + (defined[0].name.as_str(), defined[0].formula.as_str()), + ("Area", format!("{sheet}!$B$2:$C$3").as_str()), + "the selection, sheet included" + ); +} + +#[test] +fn define_waits_for_queued_input_and_reads_named_selections() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &book); + let sheet = cx.update(|app| sheet_names(app, &book)[0].clone()); + update(&mut cx, &book, |book, cx| { + book.run_active("cell.goto", &["B2:C3"], cx) + }); + ask_define(&mut cx, &book); + assert_eq!( + asked_range(&cx, &book), + format!("{sheet}!$B$2:$C$3"), + "the selection after the queued move" + ); + update(&mut cx, &book, |book, cx| { + book.dialog = None; + let range = format!("'{sheet}'!$D$4:$E$5"); + book.run_active("names.define", &["Area", &range, ""], cx); + book.run_active("cell.goto", &["A1"], cx); + book.run_active("cell.goto", &["Area"], cx); + }); + quiet(&cx, &book); + let range = cx.update(|app| { + let names = names(app, &book); + names[0].range.clone() + }); + assert!(range.ends_with("!$D$4:$E$5"), "{range}"); + ask_define(&mut cx, &book); + assert_eq!( + asked_range(&cx, &book), + range, + "a named selection keeps its range" + ); +} + +#[test] +fn the_names_list_stays_with_its_workbook_and_scope() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + for _ in 0..2 { + update(&mut cx, &book, |book, cx| book.new_blank(cx)); + } + settle(&cx, |app| { + let tabs = &book.read(app).core.tabs; + tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty()) + }); + let first = cx.update(|app| book.read(app).core.tabs[0].id); + let sheet = cx.update(|app| { + book.read(app).core.tabs[0].extra.sheets.list[0] + .name + .clone() + }); + update(&mut cx, &book, |book, cx| { + book.core.active = Some(first); + book.run_active("sheet.insert", &["Other", "2"], cx); + book.run_active( + "names.define", + &["Spot", &format!("'{sheet}'!$A$5"), ""], + cx, + ); + book.run_active("names.define", &["Spot", "'Other'!$C$7", "Other"], cx); + book.run_active("names.define", &["Local", "'Other'!$B$2", "Other"], cx); + book.show_sheet(0, cx); + }); + quiet(&cx, &book); + cx.update(|app| { + book.update(app, |book, cx| { + book.run_active("cell.enter", &["7"], cx); + book.names_in(first, cx); + }) + }); + settle(&cx, |app| { + matches!(book.read(app).dialog, Some(Dialog::Names { .. })) + }); + update(&mut cx, &book, |book, _| { + book.core.active = Some(book.core.tabs[1].id) + }); + let pick = |cx: &mut TestAppContext, name: &str, scope: &str| { + update(cx, &book, |book, cx| { + let Some(Dialog::Names { tab, names, .. }) = &book.dialog else { + panic!("the names list is open"); + }; + assert_eq!(*tab, first, "the list stays with the asking workbook"); + let at = names + .iter() + .position(|known| known.name == name && known.scope == scope) + .expect("the name is listed"); + book.choose_name(at, cx); + book.act_on_name(crate::formulas::NameAct::Go, cx); + }); + }; + let shown = |cx: &mut TestAppContext| { + quiet(cx, &book); + let active = cx.update(|app| book.read(app).core.active); + assert_eq!(active, Some(first), "the jump shows its workbook"); + update(cx, &book, |book, cx| book.refill(cx)); + let named = cx.update(|app| book.read(app).bars.name.read(app).text().to_string()); + let address = cx.update(|app| { + book.read(app).core.tabs[0] + .view + .read(app) + .cell() + .address + .clone() + }); + assert_eq!(named, address, "the name box shows the target"); + cx.update(|app| { + let tab = &book.read(app).core.tabs[0]; + ( + tab.extra.sheets.current, + tab.view.read(app).cell().address.clone(), + ) + }) + }; + pick(&mut cx, "Local", "Other"); + settle(&cx, |app| { + book.read(app).core.tabs[0].extra.sheets.current == 1 + }); + let (part, address) = shown(&mut cx); + assert!( + part == 1 && (address == "B2" || address.starts_with("Local")), + "{part} {address}" + ); + cx.update(|app| { + book.update(app, |book, cx| { + book.names_in(first, cx); + }) + }); + settle(&cx, |app| { + matches!(book.read(app).dialog, Some(Dialog::Names { .. })) + }); + update(&mut cx, &book, |book, _| { + book.core.active = Some(book.core.tabs[1].id) + }); + pick(&mut cx, "Spot", ""); + settle(&cx, |app| { + book.read(app).core.tabs[0].extra.sheets.current == 0 + }); + let (part, address) = shown(&mut cx); + assert!( + part == 0 && (address == "A5" || address == "Spot"), + "{part} {address}" + ); +} + +#[test] +fn a_waiting_definition_keeps_its_workbook_sheets() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + for _ in 0..2 { + update(&mut cx, &book, |book, cx| book.new_blank(cx)); + } + settle(&cx, |app| { + let tabs = &book.read(app).core.tabs; + tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty()) + }); + let (first, second) = cx.update(|app| { + let tabs = &book.read(app).core.tabs; + (tabs[0].id, tabs[1].id) + }); + for (id, name) in [(first, "Origin"), (second, "Destination")] { + update(&mut cx, &book, |book, cx| { + book.core.active = Some(id); + book.run_active("sheet.rename", &[name, "1"], cx); + }); + } + settle(&cx, |app| { + book.read(app) + .core + .tabs + .iter() + .zip(["Origin", "Destination"]) + .all(|(tab, name)| { + tab.extra + .sheets + .list + .first() + .is_some_and(|sheet| sheet.name == name) + }) + }); + cx.update(|app| { + book.update(app, |book, cx| { + book.core.active = Some(first); + book.run_active("cell.goto", &["B2:C3"], cx); + book.ask_define(cx); + }) + }); + settle(&cx, |app| { + matches!(book.read(app).dialog, Some(Dialog::Define { .. })) + }); + update(&mut cx, &book, |book, _| book.core.active = Some(second)); + let scopes = cx.update(|app| book.read(app).define_scopes()); + assert_eq!( + scopes, + Some(vec!["Origin".to_string()]), + "the asking workbook's sheets" + ); + update(&mut cx, &book, |book, cx| { + if let Some(Dialog::Define { name, scope, .. }) = &mut book.dialog { + *scope = "Origin".into(); + name.update(cx, |name, cx| name.set_text("Area", cx)); + } + book.define(cx); + book.core.active = Some(first); + }); + settle(&cx, |app| book.read(app).dialog.is_none()); + quiet(&cx, &book); + let defined = cx.update(|app| names(app, &book)); + assert!( + defined + .iter() + .any(|name| name.name == "Area" && name.scope == "Origin"), + "{defined:?}" + ); +} + +#[test] +fn a_rename_stays_with_its_workbook() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + for _ in 0..2 { + update(&mut cx, &book, |book, cx| book.new_blank(cx)); + } + settle(&cx, |app| { + let tabs = &book.read(app).core.tabs; + tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty()) + }); + let (first, second) = cx.update(|app| { + let tabs = &book.read(app).core.tabs; + (tabs[0].id, tabs[1].id) + }); + let before = cx.update(|app| { + book.read(app).core.tabs[1].extra.sheets.list[0] + .name + .clone() + }); + cx.update_window(window.into(), |_, window, app| { + book.update(app, |book, cx| { + book.core.active = Some(first); + book.ask_rename(window, cx); + book.core.active = Some(second); + book.rename_asked("Kept", cx); + }) + }) + .expect("the window lives"); + settle(&cx, |app| { + book.read(app).core.tabs[0].extra.sheets.list[0].name == "Kept" + }); + assert_eq!( + cx.update(|app| book.read(app).core.tabs[1].extra.sheets.list[0] + .name + .clone()), + before, + "the other workbook keeps its sheet name" + ); +} diff --git a/apps/ely-table/src/tests/numbers.rs b/apps/ely-table/src/tests/numbers.rs new file mode 100644 index 0000000..535623e --- /dev/null +++ b/apps/ely-table/src/tests/numbers.rs @@ -0,0 +1,46 @@ +//! Sheet numbers hold only in the state they were read. + +use super::*; + +#[test] +fn a_rename_asked_before_a_delete_renames_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &book); + for (name, at) in [("B", "2"), ("C", "3")] { + fresh(&cx, &book); + update(&mut cx, &book, |book, cx| book.run_active("sheet.insert", &[name, at], cx)); + } + settle(&cx, |app| sheet_names(app, &book).len() == 3); + fresh(&cx, &book); + update(&mut cx, &book, |book, cx| book.show_sheet(1, cx)); + cx.update_window(window.into(), |_, window, app| book.update(app, |book, cx| book.ask_rename(window, cx))) + .expect("the window lives"); + // The first sheet goes while the dialog is open. + update(&mut cx, &book, |book, cx| book.run_active("sheet.delete", &["1"], cx)); + settle(&cx, |app| sheet_names(app, &book) == ["B", "C"]); + fresh(&cx, &book); + update(&mut cx, &book, |book, cx| book.rename_asked("Renamed B", cx)); + fresh(&cx, &book); + assert_eq!(cx.update(|app| sheet_names(app, &book)), ["B", "C"], "C keeps its name"); +} + +#[test] +fn a_failed_command_still_moves_the_state_on() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &book); + // No merged cell to split: the kernel refuses. + update(&mut cx, &book, |book, cx| book.run_active("cells.split", &[], cx)); + fresh(&cx, &book); + update(&mut cx, &book, |book, cx| book.run_active("sheet.rename", &["Plan", "1"], cx)); + settle(&cx, |app| sheet_names(app, &book) == ["Plan"]); +} diff --git a/apps/ely-table/src/tests/output.rs b/apps/ely-table/src/tests/output.rs new file mode 100644 index 0000000..3465892 --- /dev/null +++ b/apps/ely-table/src/tests/output.rs @@ -0,0 +1,379 @@ +use ely_gpui_component::forms::TextInput; +use ely_lok::sheet::{self, Scale, csv::Column}; + +use super::data::{asked, cells, quiet, run}; +use super::*; +use crate::output::Format; + +/// The active tab's kernel read, after its input. +fn read( + cx: &mut TestAppContext, + book: &Entity, + parse: fn(&str) -> std::io::Result, + command: &str, +) -> T { + quiet(cx, book); + cx.update(|app| { + let book = book.read(app); + let tab = book.active_tab().expect("a tab"); + let mut worker = book.core.worker.lock().expect("the worker lock"); + parse(&worker.values(&tab.doc, command).expect("the read")).expect("parses") + }) +} + +fn type_in( + cx: &mut TestAppContext, + book: &Entity, + field: fn(&Dialog) -> &Entity, + text: &str, +) { + cx.update(|app| { + let input = field(book.read(app).dialog.as_ref().expect("a dialog")).clone(); + input.update(app, |input, cx| input.set_text(text, cx)); + }); +} + +#[test] +fn a_csv_opens_through_its_choices_and_keeps_codes() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let path = dir.path().join("accounts.txt"); + let mut text = "Account;Amount\n".to_string(); + text.push_str(&"123;2\n".repeat(18_000)); + text.push_str("007;1.5\n"); + std::fs::write(&path, text).expect("writes"); + update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx)); + asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading)); + cx.update(|app| match book.read(app).dialog.as_ref() { + Some(Dialog::Import(pending)) => { + assert_eq!(pending.how.separator, ';', "the guessed separator"); + assert_eq!(pending.how.columns, [Column::Text, Column::Standard]); + } + _ => unreachable!("an import dialog"), + }); + update(&mut cx, &book, |book, cx| book.import_asked(cx)); + settle(&cx, |app| { + book.read(app).active_tab().is_some_and(|tab| tab.name == "accounts.txt") + }); + assert_eq!(cells(&mut cx, &book, "A18002"), "007", "the zero stays"); + assert_eq!(cells(&mut cx, &book, "B18002"), "1.5"); + let saved = dir.path().join("accounts.xlsx"); + let id = cx.update(|app| book.read(app).core.tabs[0].id); + cx.update_window(window.into(), |_, window, app| { + book.update(app, |book, cx| book.write_as(id, saved.clone(), None, window, cx)) + }) + .expect("the window lives"); + settle(&cx, |app| book.read(app).core.tabs[0].name == "accounts.xlsx"); + update(&mut cx, &book, |book, cx| { + book.close_tab(id, cx); + book.open_file(saved.clone(), cx); + }); + settle(&cx, |app| sheet_names(app, &book).len() == 1); + assert_eq!(cells(&mut cx, &book, "A18002"), "007", "XLSX keeps it"); +} + +#[test] +fn a_wrong_encoding_blocks_the_import() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let path = dir.path().join("latin.csv"); + std::fs::write(&path, b"Name\nCaf\xe9\n").expect("writes"); + update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx)); + asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading)); + update(&mut cx, &book, |book, cx| { + if let Some(Dialog::Import(pending)) = &mut book.dialog { + pending.how.encoding = ely_lok::sheet::csv::Encoding::Utf8; + } + book.scan_import(cx); + }); + asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading)); + update(&mut cx, &book, |book, cx| book.import_asked(cx)); + assert!(cx.update(|app| book.read(app).dialog.is_some()), "broken bytes stay out"); + assert!(cx.update(|app| book.read(app).core.loading.is_empty())); +} + +#[test] +fn exports_write_the_shown_sheet_as_text() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &book); + run(&mut cx, &book, "cell.enter", &["=1+2"]); + run(&mut cx, &book, "cell.goto", &["B1"]); + run(&mut cx, &book, "cell.enter", &["a;b"]); + let id = cx.update(|app| book.read(app).core.tabs[0].id); + for (format, name, text) in [ + (Format::Csv, "out.csv", "3,a;b\n"), + (Format::Tsv, "out.tsv", "3\ta;b\n"), + ] { + let path = dir.path().join(name); + update(&mut cx, &book, |book, cx| book.export_to(id, path.clone(), format, cx)); + settle(&cx, |_| path.exists()); + quiet(&cx, &book); + assert_eq!(std::fs::read_to_string(&path).expect("reads"), text); + } + let pdf = dir.path().join("out.pdf"); + update(&mut cx, &book, |book, cx| book.export_to(id, pdf.clone(), Format::Pdf, cx)); + settle(&cx, |_| pdf.exists()); + quiet(&cx, &book); + assert!(std::fs::read(&pdf).expect("reads").starts_with(b"%PDF")); +} + +fn area(dialog: &Dialog) -> &Entity { + match dialog { + Dialog::Page { fields, .. } => &fields.area, + _ => unreachable!("a page dialog"), + } +} + +fn rows(dialog: &Dialog) -> &Entity { + match dialog { + Dialog::Page { fields, .. } => &fields.rows, + _ => unreachable!("a page dialog"), + } +} + +#[test] +fn page_setup_applies_titles_area_and_page() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &book); + update(&mut cx, &book, |book, cx| book.ask_page(cx)); + asked(&cx, &book, |dialog| matches!(dialog, Dialog::Page { .. })); + type_in(&mut cx, &book, area, "A1:"); + update(&mut cx, &book, |book, cx| book.page_asked(cx)); + asked(&cx, &book, |dialog| matches!(dialog, Dialog::Page { .. })); + type_in(&mut cx, &book, area, "a1:c4"); + type_in(&mut cx, &book, rows, "1"); + update(&mut cx, &book, |book, cx| { + if let Some(Dialog::Page { + landscape, scale, .. + }) = &mut book.dialog + { + (*landscape, *scale) = (true, Scale::Fit(1, 0)); + } + book.page_asked(cx) + }); + let layout = read(&mut cx, &book, sheet::print_layout, sheet::LAYOUT); + assert_eq!(layout.areas, ["$A$1:$C$4"]); + assert_eq!(layout.rows, Some((1, 1))); + assert_eq!((layout.landscape, layout.scale), (true, Scale::Fit(1, 0))); +} + +fn password(dialog: &Dialog) -> &Entity { + match dialog { + Dialog::Protect { protecting, .. } => &protecting.password, + _ => unreachable!("a protect dialog"), + } +} + +fn confirm(dialog: &Dialog) -> &Entity { + match dialog { + Dialog::Protect { protecting, .. } => &protecting.confirm, + _ => unreachable!("a protect dialog"), + } +} + +fn protected(cx: &mut TestAppContext, book: &Entity) -> bool { + read(cx, book, sheet::protection, sheet::PROTECTION).protected +} + +#[test] +fn a_wrong_password_leaves_the_sheet_protected() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &book); + let ask = |cx: &mut TestAppContext| { + update(cx, &book, |book, cx| book.ask_protect(cx)); + asked(cx, &book, |dialog| matches!(dialog, Dialog::Protect { .. })); + }; + ask(&mut cx); + type_in(&mut cx, &book, password, "pw"); + type_in(&mut cx, &book, confirm, "px"); + update(&mut cx, &book, |book, cx| book.protect_asked(cx)); + assert!(cx.update(|app| book.read(app).dialog.is_some()), "a mismatch stays"); + type_in(&mut cx, &book, confirm, "pw"); + update(&mut cx, &book, |book, cx| book.protect_asked(cx)); + assert!(protected(&mut cx, &book)); + ask(&mut cx); + type_in(&mut cx, &book, password, "no"); + update(&mut cx, &book, |book, cx| book.protect_asked(cx)); + cx.run_until_parked(); + assert!(protected(&mut cx, &book), "still protected"); + ask(&mut cx); + type_in(&mut cx, &book, password, "pw"); + update(&mut cx, &book, |book, cx| book.protect_asked(cx)); + cx.run_until_parked(); + assert!(!protected(&mut cx, &book)); +} + +#[test] +fn a_new_link_names_its_unrefreshed_source() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let source = dir.path().join("source.ods"); + blank(&mut cx, &book); + run(&mut cx, &book, "cell.enter", &["7"]); + let name = cx.update(|app| sheet_names(app, &book)[0].clone()); + cx.update(|app| { + let book = book.read(app); + let tab = book.active_tab().expect("a tab"); + let mut worker = book.core.worker.lock().expect("the worker lock"); + tab.doc + .save_as(&mut worker, &ely_lok::file_url(&source), "ods") + .expect("the source"); + }); + settle(&cx, |app| book.read(app).active_tab().is_some_and(|tab| tab.extra.linked.is_some())); + run(&mut cx, &book, "cell.goto", &["B1"]); + let formula = format!("='{}/[source.ods]{name}'!A1", dir.path().display()); + run(&mut cx, &book, "cell.enter", &[&formula]); + let url = ely_lok::file_url(&source); + settle(&cx, |app| { + book.read(app) + .active_tab() + .is_some_and(|tab| tab.extra.links == [url.clone()]) + }); +} + +#[test] +fn the_checked_bytes_import_even_if_the_file_changes() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let path = dir.path().join("codes.csv"); + std::fs::write(&path, "Account\n00123\n").expect("writes"); + update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx)); + asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading)); + std::fs::write(&path, b"Account\n999\nA\xffB\n").expect("rewrites"); + update(&mut cx, &book, |book, cx| book.import_asked(cx)); + settle(&cx, |app| book.read(app).active_tab().is_some()); + assert_eq!(cells(&mut cx, &book, "A2"), "00123", "what was checked"); +} + +#[test] +fn a_closed_dialogs_scan_never_lands_in_the_next() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let pending = |name: &str, text: &str| { + Box::new(crate::text::Pending::new(dir.path().join(name), text.into(), false)) + }; + // Either order: one of them lands the old scan last. + for old_first in [true, false] { + let (old, next) = (pending("old.csv", "Old\n00123\n"), pending("next.csv", "Next,Amount\n1,2\n")); + update(&mut cx, &book, |book, cx| { + let (first, second) = match old_first { + true => (Dialog::Import(old), Dialog::Import(next)), + false => (Dialog::Import(next), Dialog::Import(old)), + }; + book.dialog = Some(first); + book.scan_import(cx); + let first = book.dialog.replace(second); + book.scan_import(cx); + if !old_first { + book.dialog = first; + } + }); + cx.run_until_parked(); + cx.update(|app| match book.read(app).dialog.as_ref() { + Some(Dialog::Import(pending)) => { + assert_eq!(pending.rows[0], ["Next", "Amount"], "old first {old_first}"); + assert_eq!(pending.how.columns, [Column::Standard, Column::Standard]); + } + _ => unreachable!("an import dialog"), + }); + } +} + +#[test] +fn the_preview_reads_what_the_kernel_imports() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let path = dir.path().join("odd.csv"); + let text = "Id,Code\n \"00123\",1\n\ny,-12345678901234567890\n"; + std::fs::write(&path, text).expect("writes"); + update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx)); + asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading)); + let preview = cx.update(|app| match book.read(app).dialog.as_ref() { + Some(Dialog::Import(pending)) => pending.rows.clone(), + _ => unreachable!("an import dialog"), + }); + update(&mut cx, &book, |book, cx| book.import_asked(cx)); + settle(&cx, |app| book.read(app).active_tab().is_some()); + // Row 4 lines up only if the empty line counted. + for (at, row) in preview.iter().enumerate().filter(|(_, row)| row.len() == 2) { + for (column, value) in ["A", "B"].iter().zip(row) { + let cell = format!("{column}{}", at + 1); + assert_eq!(&cells(&mut cx, &book, &cell), value, "{cell} as previewed"); + } + } + assert_eq!(preview[3][1], "-12345678901234567890"); +} + +#[test] +fn text_files_wait_their_turn_for_the_import_dialog() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let paths = ["a.csv", "b.csv"].map(|name| dir.path().join(name)); + for (path, text) in paths.iter().zip(["first\n", "second\n"]) { + std::fs::write(path, text).expect("writes"); + } + // Another dialog is open; neither import may replace it. + update(&mut cx, &book, |book, cx| { + book.dialog = Some(Dialog::Merge { tab: 0 }); + for path in &paths { + book.ask_import(path.clone(), cx); + } + }); + settle(&cx, |app| book.read(app).imports.len() == 2); + cx.update(|app| assert!(matches!(book.read(app).dialog, Some(Dialog::Merge { .. })))); + update(&mut cx, &book, |book, cx| { + book.dialog = None; + cx.notify(); + }); + for opened in 1..=2 { + asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading)); + update(&mut cx, &book, |book, cx| book.import_asked(cx)); + settle(&cx, |app| book.read(app).core.tabs.len() == opened); + } + let mut names = cx.update(|app| book.read(app).core.tabs.iter().map(|tab| tab.name.clone()).collect::>()); + names.sort(); + assert_eq!(names, ["a.csv", "b.csv"], "both files open"); +} diff --git a/apps/ely-table/src/tests/rules.rs b/apps/ely-table/src/tests/rules.rs new file mode 100644 index 0000000..cad42bb --- /dev/null +++ b/apps/ely-table/src/tests/rules.rs @@ -0,0 +1,194 @@ +use ely_lok::sheet::Validation; + +use super::data::{open, quiet, run}; +use super::*; + +/// The active workbook's cached rule for its current cell. +fn rule(app: &gpui::App, book: &Entity) -> Option<(String, Validation)> { + let tab = book.read(app).active_tab()?; + let (spot, rule) = tab.extra.rule.as_ref()?; + Some((spot.1.clone(), rule.clone())) +} + +#[test] +fn a_dialog_asked_from_a_workbook_left_behind_is_dropped() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + open(&mut cx, &book); + update(&mut cx, &book, |book, cx| book.new_blank(cx)); + settle(&cx, |app| { + let tabs = &book.read(app).core.tabs; + tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty()) + }); + let (first, second) = cx.update(|app| { + let tabs = &book.read(app).core.tabs; + (tabs[0].id, tabs[1].id) + }); + type Ask = fn(&mut Book, &mut gpui::Window, &mut gpui::Context); + let asks: [Ask; 5] = [ + |book, _, cx| book.ask_rule(cx), + |book, _, cx| book.ask_columns(cx), + |book, _, cx| book.ask_formats(cx), + |book, _, cx| book.ask(crate::analysis::Asked::Chart, cx), + |book, _, cx| book.ask(crate::analysis::Asked::Pivot, cx), + ]; + for ask in asks { + update(&mut cx, &book, |book, cx| { + book.core.active = Some(first); + book.run_active("cell.goto", &["B2"], cx); + }); + quiet(&cx, &book); + let worker = cx.update(|app| book.read(app).core.worker.clone()); + // The read lands only after the switch. + let held = worker.lock().expect("the worker lock"); + cx.update_window(window.into(), |_, window, app| { + book.update(app, |book, cx| { + book.dialog = None; + ask(book, window, cx); + book.core.active = Some(second); + }) + }) + .expect("the window lives"); + drop(held); + for _ in 0..200 { + cx.executor().tick(); + } + quiet(&cx, &book); + assert!( + cx.update(|app| book.read(app).dialog.is_none()), + "no dialog for a workbook left behind" + ); + } +} + +#[test] +fn rules_set_in_place_reach_the_cell_and_clear() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + open(&mut cx, &book); + run(&mut cx, &book, "cell.goto", &["E2"]); + settle(&cx, |app| { + rule(app, &book).is_some_and(|(at, rule)| at == "E2" && rule.kind == "any") + }); + let list = Validation { + kind: "list".into(), + operator: "equal".into(), + list: vec!["Red".into()], + dropdown: true, + show_input: true, + input_message: "A color".into(), + error_style: "stop".into(), + ..Validation::default() + }; + run(&mut cx, &book, "rules.validation", &[&list.to_json()]); + settle(&cx, |app| { + rule(app, &book).is_some_and(|(_, rule)| rule.kind == "list") + }); + update(&mut cx, &book, |book, cx| book.clear_rule(cx)); + quiet(&cx, &book); + settle(&cx, |app| { + rule(app, &book).is_some_and(|(_, rule)| rule.kind == "any" && !rule.show_input) + }); +} + +#[test] +fn the_panel_follows_a_sheet_switch_at_one_address() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + open(&mut cx, &book); + run(&mut cx, &book, "sheet.insert", &["T", "2"]); + settle(&cx, |app| sheet_names(app, &book) == ["S", "T"]); + update(&mut cx, &book, |book, cx| { + let tab = book.active_tab().expect("a tab"); + let (view, doc) = (tab.view.clone(), tab.doc.clone()); + book.panel + .update(cx, |panel, cx| panel.show(Some((&view, &doc)), cx)); + }); + let kind = |app: &gpui::App| { + let number = book.read(app).panel.read(app).number.clone(); + number.map(|(_, kind)| kind) + }; + for (sheet, command, want) in [ + (0, "number.percent", "percent"), + (1, "number.currency", "currency"), + ] { + update(&mut cx, &book, |book, cx| book.show_sheet(sheet, cx)); + run(&mut cx, &book, "cell.goto", &["A1"]); + run(&mut cx, &book, command, &[]); + settle(&cx, |app| kind(app).as_deref() == Some(want)); + } + update(&mut cx, &book, |book, cx| book.show_sheet(0, cx)); + run(&mut cx, &book, "cell.goto", &["A1"]); + settle(&cx, |app| kind(app).as_deref() == Some("percent")); +} + +#[test] +fn a_format_code_lands_in_the_workbook_that_asked() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + open(&mut cx, &book); + update(&mut cx, &book, |book, cx| book.new_blank(cx)); + settle(&cx, |app| { + let tabs = &book.read(app).core.tabs; + tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty()) + }); + let (first, second) = cx.update(|app| { + let tabs = &book.read(app).core.tabs; + (tabs[0].id, tabs[1].id) + }); + let code = |cx: &mut TestAppContext, id: u64| { + cx.update(|app| { + let book = book.read(app); + let tab = book.tab(id).expect("a tab"); + let mut worker = book.core.worker.lock().expect("the worker lock"); + worker.run(&tab.doc, "cell.goto", &["A1"]).expect("A1"); + let json = worker + .values(&tab.doc, ely_lok::sheet::NUMBER) + .expect("the format"); + ely_lok::sheet::number_format(&json).expect("parses").0 + }) + }; + let (panel, view, doc) = cx.update(|app| { + book.update(app, |book, _| book.core.active = Some(first)); + let book = book.read(app); + let tab = book.tab(first).expect("a tab"); + (book.panel.clone(), tab.view.clone(), tab.doc.clone()) + }); + cx.update(|app| { + panel.update(app, |panel, cx| { + panel.show(Some((&view, &doc)), cx); + panel.apply_code("0.000", cx); + }) + }); + // The check is still on its way. + update(&mut cx, &book, |book, _| book.core.active = Some(second)); + settle(&cx, |app| { + book.read(app) + .core + .tabs + .iter() + .all(|tab| tab.view.read(app).settled()) + }); + quiet(&cx, &book); + for _ in 0..200 { + cx.executor().tick(); + } + assert_eq!(code(&mut cx, first), "0.000", "the asking workbook"); + assert_ne!(code(&mut cx, second), "0.000", "not the active one"); +} diff --git a/apps/ely-table/src/text.rs b/apps/ely-table/src/text.rs new file mode 100644 index 0000000..b517c4b --- /dev/null +++ b/apps/ely-table/src/text.rs @@ -0,0 +1,371 @@ +//! CSV and TSV files: read before opening (TABLE-12, §9.3). + +use std::{ + iter::Peekable, + path::{Path, PathBuf}, + str::Chars, + sync::Arc, +}; + +use ely_lok::sheet::csv::{Column, Encoding, Import}; +use ely_office::{in_window, prelude::*}; +use ely_ui::commands::Open; +use gpui::{AppContext as _, Context, TaskExt as _, Window}; + +use crate::book::Book; +use crate::dialog::Dialog; + +mod dialogs; +pub use dialogs::import_dialog; + +/// Extensions the import dialog reads. +const TEXT: [&str; 4] = ["csv", "tsv", "tab", "txt"]; +/// Rows the preview shows. +pub const SHOWN: usize = 8; +/// Characters a preview cell keeps. +const CELL: usize = 200; +/// Separators offered, in guessing order. +pub const SEPARATORS: [char; 5] = [',', ';', '\t', '|', ' ']; + +fn is_text(path: &Path) -> bool { + let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + TEXT.contains(&ext.to_ascii_lowercase().as_str()) +} + +/// A text file waiting for its import choices. +pub struct Pending { + pub path: PathBuf, + bytes: Arc>, + pub how: Import, + /// The first rows as the choices read them. + pub rows: Vec>, + /// The chosen encoding does not read the file. + pub broken: bool, + /// A scan of the whole file is on its way. + pub reading: bool, + /// The latest scan asked; older ones are dropped. + asked: u64, +} + +/// What one scan of the whole file found. +struct Scan { + rows: Vec>, + columns: Vec, + broken: bool, +} + +impl Pending { + pub(crate) fn new(path: PathBuf, bytes: Vec, chinese: bool) -> Self { + let how = Import { + separator: guess_separator(&path, &bytes), + quote: Some('"'), + encoding: guess_encoding(&bytes, chinese), + columns: Vec::new(), + }; + Pending { + path, + bytes: Arc::new(bytes), + how, + rows: Vec::new(), + broken: false, + reading: true, + asked: 0, + } + } +} + +/// Reads every row: preview, column kinds, decoding. +fn scan(bytes: &[u8], how: &Import) -> Scan { + let Some(text) = decode(bytes, how.encoding) else { + return Scan { + rows: Vec::new(), + columns: Vec::new(), + broken: true, + }; + }; + let mut columns = Vec::new(); + let mut rows = Vec::new(); + for row in split(&text, how) { + if columns.len() < row.len() { + columns.resize(row.len(), Column::Standard); + } + for (at, value) in row.iter().enumerate() { + if is_code(value.trim()) { + columns[at] = Column::Text; + } + } + if rows.len() < SHOWN { + rows.push(row.iter().map(|value| value.chars().take(CELL).collect()).collect()); + } + } + Scan { + rows, + columns, + broken: false, + } +} + +fn guess_encoding(sample: &[u8], chinese: bool) -> Encoding { + if sample.starts_with(&[0xFF, 0xFE]) || sample.starts_with(&[0xFE, 0xFF]) { + return Encoding::Utf16; + } + match std::str::from_utf8(sample) { + Ok(_) => Encoding::Utf8, + Err(_) if chinese => Encoding::Gb18030, + Err(_) => Encoding::Windows1252, + } +} + +/// TSV by name, else line one's commonest separator. +fn guess_separator(path: &Path, sample: &[u8]) -> char { + let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + if ["tsv", "tab"].contains(&ext.to_ascii_lowercase().as_str()) { + return '\t'; + } + let line = sample.split(|byte| *byte == b'\n').next().unwrap_or(&[]); + let count = |separator: char| line.iter().filter(|byte| char::from(**byte) == separator).count(); + SEPARATORS[..4] + .iter() + .copied() + .rev() + .max_by_key(|separator| count(*separator)) + .filter(|separator| count(*separator) > 0) + .unwrap_or(',') +} + +/// The text, or None where a byte breaks the encoding. +fn decode(bytes: &[u8], encoding: Encoding) -> Option { + let (codec, bom): (&'static encoding_rs::Encoding, &[u8]) = match encoding { + Encoding::Utf8 => (encoding_rs::UTF_8, &[0xEF, 0xBB, 0xBF]), + Encoding::Utf16 if bytes.starts_with(&[0xFE, 0xFF]) => (encoding_rs::UTF_16BE, &[0xFE, 0xFF]), + Encoding::Utf16 => (encoding_rs::UTF_16LE, &[0xFF, 0xFE]), + Encoding::Gb18030 => (encoding_rs::GB18030, &[]), + Encoding::Big5 => (encoding_rs::BIG5, &[]), + Encoding::ShiftJis => (encoding_rs::SHIFT_JIS, &[]), + Encoding::EucKr => (encoding_rs::EUC_KR, &[]), + Encoding::Windows1252 => (encoding_rs::WINDOWS_1252, &[]), + Encoding::Windows1251 => (encoding_rs::WINDOWS_1251, &[]), + // Latin-1 maps each byte to its code point. + Encoding::Latin1 => return Some(bytes.iter().map(|byte| char::from(*byte)).collect()), + }; + let body = bytes.strip_prefix(bom).unwrap_or(bytes); + codec + .decode_without_bom_handling_and_without_replacement(body) + .map(|text| text.into_owned()) +} + +/// Rows as Calc's text import reads them (impex.cxx). +fn split(text: &str, how: &Import) -> Vec> { + let mut chars = text.chars().peekable(); + let mut rows = Vec::new(); + while chars.peek().is_some() { + let mut row = Vec::new(); + loop { + let (value, more) = field(&mut chars, how); + row.push(value); + if !more { + break; + } + } + rows.push(row); + } + rows +} + +/// One field, and whether the record goes on. +fn field(chars: &mut Peekable, how: &Import) -> (String, bool) { + let mut value = String::new(); + if let Some(quote) = how.quote { + // Calc skips blanks before a quote unless blank splits. + let mut ahead = chars.clone(); + while how.separator != ' ' && ahead.next_if_eq(&' ').is_some() {} + if ahead.peek() == Some("e) { + *chars = ahead; + chars.next(); + while let Some(c) = chars.next() { + match c { + c if c == quote && chars.next_if_eq("e).is_some() => value.push(quote), + c if c == quote => break, + c => value.push(c), + } + } + } + } + while let Some(c) = chars.next() { + match c { + '\n' => return (value, false), + '\r' => { + chars.next_if_eq(&'\n'); + return (value, false); + } + c if c == how.separator => return (value, true), + c => value.push(c), + } + } + (value, false) +} + +/// A plus sign, leading zeros, or over 15 digits. +fn is_code(value: &str) -> bool { + let digits = value.trim_start_matches(['+', '-']); + let signs = value.len() - digits.len(); + !digits.is_empty() + && signs <= 1 + && digits.bytes().all(|byte| byte.is_ascii_digit()) + && (value.starts_with('+') + || (digits.len() > 1 && digits.starts_with('0')) + || digits.len() > 15) +} + +impl Book { + /// Opens a workbook; text files ask how to read first. + pub(crate) fn on_open_file(&mut self, _: &Open, _: &mut Window, cx: &mut Context) { + self.pick(false, cx, Self::open_asked); + } + + /// Opens a workbook; a text file asks first. + pub(crate) fn open_asked(&mut self, path: PathBuf, cx: &mut Context) { + match is_text(&path) { + true => self.ask_import(path, cx), + false => self.open_file(path, cx), + } + } + + pub(crate) fn ask_import(&mut self, path: PathBuf, cx: &mut Context) { + let reading = path.clone(); + let chinese = ely_ui::i18n::resolve(&ely_platform::locale::system()) == "zh-CN"; + cx.spawn(async move |this, cx| { + let bytes = cx + .background_spawn(async move { std::fs::read(&reading) }) + .await; + in_window(&this, cx, |book, _, cx| match bytes { + Ok(bytes) => { + log::info!("table: import asks for {} bytes", bytes.len()); + book.imports.push_back(Pending::new(path, bytes, chinese)); + book.next_import(cx); + } + Err(error) => book.failed_open(&path.display().to_string(), &error, cx), + }) + }) + .detach_and_log_err(cx); + } + + /// The next waiting import asks, once no dialog is open. + pub(crate) fn next_import(&mut self, cx: &mut Context) { + if self.dialog.is_some() { + return; + } + if let Some(pending) = self.imports.pop_front() { + self.dialog = Some(Dialog::Import(Box::new(pending))); + self.scan_import(cx); + } + } + + /// Reads the whole file again for the dialog's choices. + pub(crate) fn scan_import(&mut self, cx: &mut Context) { + let Some(Dialog::Import(pending)) = &mut self.dialog else { + return; + }; + pending.asked += 1; + pending.reading = true; + let (bytes, how, asked) = (Arc::clone(&pending.bytes), pending.how.clone(), pending.asked); + let scanned = Arc::clone(&bytes); + cx.notify(); + cx.spawn(async move |this, cx| { + let found = cx.background_spawn(async move { scan(&bytes, &how) }).await; + in_window(&this, cx, |book, _, cx| { + // Only this dialog's latest scan lands. + let Some(Dialog::Import(pending)) = &mut book.dialog else { + return; + }; + if pending.asked != asked || !Arc::ptr_eq(&pending.bytes, &scanned) { + return; + } + log::info!( + "table: import scan: {} columns, broken {}", + found.columns.len(), + found.broken + ); + (pending.rows, pending.how.columns) = (found.rows, found.columns); + (pending.broken, pending.reading) = (found.broken, false); + cx.notify(); + }) + }) + .detach_and_log_err(cx); + } + + /// Imports the file as the dialog says. + pub(crate) fn import_asked(&mut self, cx: &mut Context) { + let Some(Dialog::Import(pending)) = self.dialog.take_if(|dialog| { + matches!(dialog, Dialog::Import(pending) if !pending.reading && !pending.broken) + }) else { + return; + }; + let how = &pending.how; + log::info!( + "table: imports as {:?}, {:?}, quote {:?}", + how.encoding, + how.separator, + how.quote + ); + let name = ely_office::files::file_name(&pending.path); + self.import_text(&name, Arc::clone(&pending.bytes), pending.how.clone(), cx); + cx.notify(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_read_as_text_and_numbers_stay_numbers() { + assert!(is_code("00123") && is_code("+8613800000000")); + assert!(is_code("12345678901234567890")); + assert!(is_code("-12345678901234567890") && is_code("-007")); + assert!(!is_code("123") && !is_code("0") && !is_code("1.50") && !is_code("+")); + assert!(!is_code("-5") && !is_code("+-1")); + + } + + #[test] + fn guesses_follow_the_bytes_and_the_name() { + let csv = Path::new("a.csv"); + assert_eq!(guess_separator(csv, b"a;b;c,d\n1;2"), ';'); + assert_eq!(guess_separator(csv, b"one\n"), ','); + assert_eq!(guess_separator(Path::new("a.TSV"), b"a,b"), '\t'); + assert_eq!(guess_encoding("名称".as_bytes(), false), Encoding::Utf8); + assert_eq!(guess_encoding(&[0xC3, 0xFB], true), Encoding::Gb18030); + assert_eq!(guess_encoding(&[0xC3, 0xFB], false), Encoding::Windows1252); + assert_eq!(guess_encoding(&[0xFF, 0xFE, b'a', 0], false), Encoding::Utf16); + } + + #[test] + fn scans_read_every_row_and_refuse_broken_bytes() { + let mut how = Import { + separator: ',', + quote: Some('"'), + encoding: Encoding::Utf8, + columns: Vec::new(), + }; + let mut text = "Name,Note\n\"Doe, J\",1\n".to_string(); + text.push_str(&"Ada,123\n".repeat(20_000)); + text.push_str("Bo,00123\n"); + let found = scan(text.as_bytes(), &how); + assert_eq!(found.rows.len(), SHOWN); + assert_eq!(found.rows[1], ["Doe, J", "1"]); + assert_eq!(found.columns, [Column::Standard, Column::Text], "the last row counts"); + let long = format!("{},x\n", "a".repeat(100_000)); + assert_eq!(scan(long.as_bytes(), &how).rows[0][0].chars().count(), CELL); + assert!(scan(b"A\xffB\n", &how).broken, "no silent replacement"); + let calc = scan(b"Id,Note\n \"00123\",x\n\n\"a \"\"b\"\"\"c,\"two\nlines\"\r\n", &how); + assert_eq!(calc.rows[1], ["00123", "x"], "blanks before a quote go"); + assert_eq!(calc.rows[2], [""], "an empty line is a row"); + assert_eq!(calc.rows[3], ["a \"b\"c", "two\nlines"]); + assert_eq!(calc.columns, [Column::Text, Column::Standard]); + how.quote = None; + assert_eq!(scan(b"\"Doe, J\"\n", &how).rows[0], ["\"Doe", " J\""]); + how.encoding = Encoding::Latin1; + assert_eq!(scan(&[b'c', 0xE9], &how).rows[0], ["cé"]); + } +} diff --git a/apps/ely-table/src/text/dialogs.rs b/apps/ely-table/src/text/dialogs.rs new file mode 100644 index 0000000..dcb2932 --- /dev/null +++ b/apps/ely-table/src/text/dialogs.rs @@ -0,0 +1,168 @@ +//! The import dialog: encoding, separator, quote, column kinds. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + data_display::DescriptionList, + feedback::InlineMessage, + forms::{Choice, Select}, + layout::ScrollArea, + overlays::Dialog as Card, + primitives::Severity, + theme::{ActiveTheme as _, ControlSize}, +}; +use ely_lok::sheet::csv::{COLUMNS, Column, ENCODINGS}; +use gpui::{App, Entity, ParentElement as _, SharedString, Styled as _, Window, div, rems}; + +use super::{Pending, SEPARATORS}; +use crate::book::Book; +use crate::dialog::{Dialog, edit}; + +fn pending_of(dialog: &mut Dialog) -> &mut Pending { + match dialog { + Dialog::Import(pending) => pending, + _ => unreachable!("an import dialog"), + } +} + +/// Changes a choice that reads the file anew. +fn reread(this: &Entity, cx: &mut App, change: impl FnOnce(&mut Pending)) { + edit(this, cx, |dialog, _, _| change(pending_of(dialog))); + this.update(cx, |book, cx| book.scan_import(cx)); +} + +fn column_key(column: Column) -> &'static str { + match column { + Column::Standard => "table.import.standard", + Column::Text => "table.import.text", + Column::DateMdy => "table.import.mdy", + Column::DateDmy => "table.import.dmy", + Column::DateYmd => "table.import.ymd", + Column::Skip => "table.import.skip", + } +} + +fn separator_key(separator: char) -> &'static str { + match separator { + ',' => "table.import.comma", + ';' => "table.import.semicolon", + '\t' => "table.import.tab", + '|' => "table.import.pipe", + _ => "table.import.space", + } +} + +/// One column: its kind above its first cells. +fn column(this: &Entity, at: usize, kind: Column, rows: &[Vec], cx: &App) -> gpui::Div { + let label = |key: &str| Book::text(cx, key, &[]); + let picking = this.clone(); + let kinds = Select::new( + ("table-import-kind", at), + COLUMNS + .iter() + .enumerate() + .map(|(index, kind)| Choice::new(index.to_string(), label(column_key(*kind)))), + ) + .label(Book::text(cx, "table.import.kind_of", &[("n", &(at + 1).to_string())])) + .selected(COLUMNS.iter().position(|each| *each == kind).expect("listed").to_string()) + .on_change(move |value, _, cx| { + let Ok(index) = value.parse::() else { + return; + }; + edit(&picking, cx, |dialog, _, _| { + pending_of(dialog).how.columns[at] = COLUMNS[index]; + }) + }); + let fg = cx.theme().colors.fg; + let cells = rows.iter().map(|row| { + div() + .h_6() + .truncate() + .text_color(fg) + .child(SharedString::from(row.get(at).cloned().unwrap_or_default())) + }); + div().w_32().flex_none().flex().flex_col().gap_1().child(kinds).children(cells) +} + +pub fn import_dialog( + this: Entity, + pending: &Pending, + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Card { + let label = |key: &str| Book::text(cx, key, &[]); + let how = &pending.how; + let picking = this.clone(); + let encoding = Select::new( + "table-import-encoding", + ENCODINGS + .iter() + .enumerate() + .map(|(index, encoding)| Choice::new(index.to_string(), encoding.label())), + ) + .label(label("table.import.encoding")) + .selected(ENCODINGS.iter().position(|e| *e == how.encoding).expect("listed").to_string()) + .on_change(move |value, _, cx| { + if let Ok(index) = value.parse::() { + reread(&picking, cx, |pending| pending.how.encoding = ENCODINGS[index]); + } + }); + let picking = this.clone(); + let separator = Select::new( + "table-import-separator", + SEPARATORS + .iter() + .map(|each| Choice::new(each.to_string(), label(separator_key(*each)))), + ) + .label(label("table.import.separator")) + .selected(how.separator.to_string()) + .on_change(move |value, _, cx| { + if let Some(separator) = value.chars().next() { + reread(&picking, cx, |pending| pending.how.separator = separator); + } + }); + let picking = this.clone(); + let quoted = how.quote.map_or(String::new(), String::from); + let quote = SegmentedControl::new("table-import-quote", quoted) + .size(ControlSize::Sm) + .segment("\"", "\"", None) + .segment("'", "'", None) + .segment("", label("table.import.no_quote"), None) + .on_change(move |value, _, cx| { + let quote = value.chars().next(); + reread(&picking, cx, |pending| pending.how.quote = quote); + }); + let choices = DescriptionList::new() + .item(label("table.import.encoding"), div().w_full().max_w_48().child(encoding)) + .item(label("table.import.separator"), div().w_full().max_w_48().child(separator)) + .item(label("table.import.quote"), quote); + let columns = how + .columns + .iter() + .enumerate() + .map(|(at, kind)| column(&this, at, *kind, &pending.rows, cx)); + // As wide as its columns, so it scrolls. + let shown = how.columns.len() as f32; + let row = div().w(rems(shown * 8.5)).flex().gap_2().pb_2().children(columns); + let preview = ScrollArea::new("table-import-preview").h_64().child(row); + let broken = pending + .broken + .then(|| InlineMessage::new(Severity::Danger, label("table.import.broken"))); + let off = pending.reading || pending.broken; + let name = ely_office::files::file_name(&pending.path); + let go = label("table.import.go"); + Card::new( + "table-import", + Book::text(cx, "table.import.title", &[("name", &name)]), + dismiss, + ) + .child(choices) + .children(broken) + .child(preview) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("table-import-go", go) + .variant(ButtonVariant::Primary) + .disabled(off) + .on_click(move |_, _, cx| this.update(cx, |book, cx| book.import_asked(cx))) + }) +} diff --git a/apps/ely-tree/Cargo.toml b/apps/ely-tree/Cargo.toml new file mode 100644 index 0000000..f0ede71 --- /dev/null +++ b/apps/ely-tree/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "ely-tree" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +ely-files.workspace = true +ely-gpui-component.workspace = true +ely-platform.workspace = true +ely-ui.workspace = true +futures.workspace = true +gpui.workspace = true +jiff.workspace = true +log.workspace = true +notify.workspace = true +serde_json.workspace = true +tempfile.workspace = true + +[target.'cfg(unix)'.dependencies] +libc.workspace = true + +[dev-dependencies] +ely-ui = { workspace = true, features = ["test-support"] } +ely-gpui-component = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } + +[lints] +workspace = true diff --git a/apps/ely-tree/src/asks.rs b/apps/ely-tree/src/asks.rs new file mode 100644 index 0000000..064555f --- /dev/null +++ b/apps/ely-tree/src/asks.rs @@ -0,0 +1,363 @@ +//! The window's dialogs: clone, discard, failures, identity. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + forms::{Checkbox, Input, TextInput}, + overlays::{AlertDialog, ConfirmDialog, Dialog, PromptDialog}, + primitives::Severity, +}; +use gpui::{ + AnyElement, App, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, + Window, div, +}; + +use crate::{ + status::{Entry, State}, + tree::{Tree, text}, + work::Write, +}; + +/// What a dialog asks. +pub enum Ask { + Url(Entity), + Discard { + repo: u64, + entry: Entry, + }, + /// A failure with Git's whole account, such as a hook's. + Failed { + title: SharedString, + detail: SharedString, + }, + /// A command that waits for a yes, this once. + Confirm { + repo: u64, + title: SharedString, + message: SharedString, + go: SharedString, + write: Write, + }, + NewBranch { + /// The repository it was opened for. + repo: u64, + name: Entity, + at: Option, + switch: bool, + }, + NewTag { + /// The repository it was opened for. + repo: u64, + name: Entity, + message: Entity, + at: Option, + }, + Rename { + /// The repository it was opened for. + repo: u64, + old: String, + name: Entity, + }, + Identity { + /// The repository it was opened for. + repo: u64, + name: Entity, + email: Entity, + global: bool, + }, + Stash { + /// The repository it was opened for. + repo: u64, + message: Entity, + untracked: bool, + }, + /// A new remote, or an old one's name and URL. + Remote { + /// The repository it was opened for. + repo: u64, + old: Option<(String, String)>, + name: Entity, + url: Entity, + }, + Worktree { + /// The repository it was opened for. + repo: u64, + path: std::path::PathBuf, + branch: Entity, + new: bool, + }, + /// A forced push, with what it would overwrite. + Force { + repo: u64, + plan: crate::remotes::Force, + }, + /// One of several names, each a button. + Choose { + /// The repository it was opened for. + repo: u64, + title: SharedString, + choices: Vec, + then: Choice, + }, + /// What a clean would remove, read before the yes. + Clean { + repo: u64, + ignored: bool, + preview: Option>, + }, + /// HEAD's branch to a reflog commit, previewed. + Move { + repo: u64, + plan: crate::recover::Move, + }, + /// Another Git holds a lock; retry, never delete it. + Locked { + repo: u64, + write: Write, + detail: SharedString, + }, +} + +/// What a chosen name is for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Choice { + /// Push this branch there and track it. + Publish(String), + /// Track that remote branch from this one. + Upstream(String), +} + +impl Tree { + pub fn dialog(&self, cx: &mut Context) -> Option { + let this = cx.weak_entity(); + let dismiss = { + let this = this.clone(); + move |_: &mut Window, cx: &mut App| { + this.update(cx, |tree, cx| { + tree.ask = None; + cx.notify(); + }) + .ok(); + } + }; + match self.ask.as_ref()? { + Ask::Url(field) => { + let empty = text(cx, "tree.clone.empty", &[]); + let prompt = PromptDialog::new( + "tree-clone-url", + text(cx, "tree.clone.title", &[]), + field, + dismiss, + ) + .label(text(cx, "tree.clone.url", &[])) + .submit(text(cx, "tree.clone.next", &[])) + .check(move |url| { + if url.trim().is_empty() { + Err(empty.clone()) + } else { + Ok(()) + } + }) + .on_submit(move |url, _, cx| { + let url = url.trim().to_string(); + this.update(cx, |tree, cx| tree.pick_clone_folder(url, cx)) + .ok(); + }); + Some(prompt.into_any_element()) + } + Ask::Discard { repo, entry } => { + let key = if entry.state == State::Untracked { + "tree.discard.untracked" + } else { + "tree.discard.tracked" + }; + let message = text(cx, key, &[("path", &entry.path)]); + let (repo, write) = (*repo, Write::Discard(entry.clone())); + let confirm = ConfirmDialog::new( + "tree-discard", + text(cx, "tree.discard.title", &[("path", &entry.path)]), + message, + dismiss, + ) + .confirm(text(cx, "tree.discard.go", &[])) + .destructive() + .on_confirm(move |_, cx| { + this.update(cx, |tree, cx| tree.write(repo, write.clone(), cx)) + .ok(); + }); + Some(confirm.into_any_element()) + } + Ask::Failed { title, detail } => Some( + AlertDialog::new( + "tree-failed", + Severity::Danger, + title.clone(), + detail.clone(), + dismiss, + ) + .label(text(cx, "tree.ok", &[])) + .into_any_element(), + ), + Ask::Confirm { + repo, + title, + message, + go, + write, + } => { + let (repo, write) = (*repo, write.clone()); + let confirm = + ConfirmDialog::new("tree-confirm", title.clone(), message.clone(), dismiss) + .confirm(go.clone()) + .destructive() + .on_confirm(move |_, cx| { + this.update(cx, |tree, cx| tree.write(repo, write.clone(), cx)) + .ok(); + }); + Some(confirm.into_any_element()) + } + Ask::NewBranch { name, switch, .. } => { + let label = |key: &str| text(cx, key, &[]); + let rows = DescriptionList::new().item( + label("tree.branch.name"), + div().w_full().child(Input::new(name)), + ); + let toggle = { + let this = this.clone(); + Checkbox::new("tree-branch-switch", *switch) + .label(label("tree.branch.switch")) + .on_change(move |on, _, cx| { + this.update(cx, |tree, cx| { + if let Some(Ask::NewBranch { switch, .. }) = &mut tree.ask { + *switch = on; + cx.notify(); + } + }) + .ok(); + }) + }; + let (title, go) = (label("tree.branch.title"), label("tree.branch.create")); + Some(self.form( + "tree-new-branch", + title, + go, + rows, + Some(toggle.into_any_element()), + dismiss, + cx, + )) + } + Ask::NewTag { name, message, .. } => { + let label = |key: &str| text(cx, key, &[]); + let rows = DescriptionList::new() + .item( + label("tree.tag.name"), + div().w_full().child(Input::new(name)), + ) + .item( + label("tree.tag.message"), + div().w_full().child(Input::new(message)), + ); + let (title, go) = (label("tree.tag.title"), label("tree.tag.create")); + Some(self.form("tree-new-tag", title, go, rows, None, dismiss, cx)) + } + Ask::Rename { old, name, .. } => { + let label = |key: &str| text(cx, key, &[]); + let rows = DescriptionList::new().item( + label("tree.branch.name"), + div().w_full().child(Input::new(name)), + ); + let (title, go) = ( + text(cx, "tree.rename.title", &[("name", old)]), + label("tree.rename.go"), + ); + Some(self.form("tree-rename", title, go, rows, None, dismiss, cx)) + } + Ask::Stash { .. } + | Ask::Remote { .. } + | Ask::Worktree { .. } + | Ask::Force { .. } + | Ask::Choose { .. } => Some(self.place_dialog(dismiss, cx)?), + Ask::Clean { .. } | Ask::Move { .. } | Ask::Locked { .. } => { + Some(self.recover_dialog(dismiss, cx)?) + } + Ask::Identity { + name, + email, + global, + .. + } => { + let label = |key: &str| text(cx, key, &[]); + let rows = DescriptionList::new() + .item( + label("tree.identity.name"), + div().w_full().child(Input::new(name)), + ) + .item( + label("tree.identity.email"), + div().w_full().child(Input::new(email)), + ); + let scope = { + let this = this.clone(); + Checkbox::new("tree-identity-global", *global) + .label(label("tree.identity.global")) + .on_change(move |on, _, cx| { + this.update(cx, |tree, cx| { + if let Some(Ask::Identity { global, .. }) = &mut tree.ask { + *global = on; + cx.notify(); + } + }) + .ok(); + }) + }; + let (save, cancel) = (label("tree.identity.save"), label("tree.cancel")); + let card = Dialog::new("tree-identity", label("tree.identity.title"), dismiss) + .child(rows) + .child(scope) + .action(move |close| { + Button::new("tree-identity-cancel", cancel) + .on_click(move |_, window, cx| close(window, cx)) + }) + .action(move |_| { + Button::new("tree-identity-save", save) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| { + this.update(cx, |tree, cx| tree.set_identity(cx)).ok(); + }) + }); + Some(card.into_any_element()) + } + } + } + + /// A dialog of fields whose go button answers it. + pub(crate) fn form( + &self, + id: &'static str, + title: gpui::SharedString, + go: gpui::SharedString, + rows: DescriptionList, + extra: Option, + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &mut Context, + ) -> AnyElement { + let this = cx.weak_entity(); + let cancel = text(cx, "tree.cancel", &[]); + Dialog::new(id, title, dismiss) + .child(rows) + .children(extra) + .action(move |close| { + Button::new("tree-form-cancel", cancel) + .on_click(move |_, window, cx| close(window, cx)) + }) + .action(move |_| { + Button::new("tree-form-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| { + this.update(cx, |tree, cx| tree.answer(cx)).ok(); + }) + }) + .into_any_element() + } +} diff --git a/apps/ely-tree/src/asks_places.rs b/apps/ely-tree/src/asks_places.rs new file mode 100644 index 0000000..c6077fe --- /dev/null +++ b/apps/ely-tree/src/asks_places.rs @@ -0,0 +1,159 @@ +//! Dialogs for stashes, remotes, worktrees and pushes. + +use ely_gpui_component::{ + buttons::Button, + data_display::DescriptionList, + forms::{Checkbox, Input}, + overlays::{ConfirmDialog, Dialog}, +}; +use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Window, div}; + +use crate::{ + asks::{Ask, Choice}, + tree::{Tree, text}, + work::Write, +}; + +impl Tree { + pub fn place_dialog( + &self, + dismiss: impl Fn(&mut Window, &mut App) + Clone + 'static, + cx: &mut Context, + ) -> Option { + let this = cx.weak_entity(); + let label = |key: &str| text(cx, key, &[]); + let tick = |id: &'static str, on: bool, key: &str, set: fn(&mut Ask, bool)| { + let this = this.clone(); + Checkbox::new(id, on) + .label(text(cx, key, &[])) + .on_change(move |on, _, cx| { + this.update(cx, |tree, cx| { + if let Some(ask) = &mut tree.ask { + set(ask, on); + cx.notify(); + } + }) + .ok(); + }) + .into_any_element() + }; + Some(match self.ask.as_ref()? { + Ask::Stash { + message, untracked, .. + } => { + let rows = DescriptionList::new().item( + label("tree.stash.message"), + div().w_full().child(Input::new(message)), + ); + let extra = tick( + "tree-stash-untracked", + *untracked, + "tree.stash.untracked", + |ask, on| { + if let Ask::Stash { untracked, .. } = ask { + *untracked = on; + } + }, + ); + let (title, go) = (label("tree.stash.title"), label("tree.stash.go")); + self.form("tree-stash", title, go, rows, Some(extra), dismiss, cx) + } + Ask::Remote { old, name, url, .. } => { + let rows = DescriptionList::new() + .item( + label("tree.remote.name"), + div().w_full().child(Input::new(name)), + ) + .item( + label("tree.remote.url"), + div().w_full().child(Input::new(url)), + ); + let title = match old { + Some((was, _)) => text(cx, "tree.remote.edit", &[("name", was)]), + None => label("tree.remote.add"), + }; + let go = label("tree.remote.save"); + self.form("tree-remote", title, go, rows, None, dismiss, cx) + } + Ask::Worktree { + path, branch, new, .. + } => { + let rows = DescriptionList::new().item( + label("tree.worktree.branch"), + div().w_full().child(Input::new(branch)), + ); + let extra = tick("tree-worktree-new", *new, "tree.worktree.new", |ask, on| { + if let Ask::Worktree { new, .. } = ask { + *new = on; + } + }); + let shown = path.display().to_string(); + let title = text(cx, "tree.worktree.title", &[("path", &shown)]); + let go = label("tree.worktree.go"); + self.form("tree-worktree", title, go, rows, Some(extra), dismiss, cx) + } + Ask::Force { repo, plan } => { + let lost = match plan.lost.len() { + 0 => label("tree.force.nothing"), + n => text( + cx, + "tree.force.lost", + &[("n", &n.to_string()), ("commits", &plan.lost.join("\n"))], + ), + }; + let message = text( + cx, + "tree.force.message", + &[ + ("branch", &plan.branch), + ("remote", &plan.remote), + ("target", &plan.target), + ("lost", &lost), + ], + ); + let (repo, write) = (*repo, Write::Remote(plan.args())); + ConfirmDialog::new("tree-force", label("tree.force.title"), message, dismiss) + .confirm(label("tree.force.go")) + .destructive() + .on_confirm(move |_, cx| { + this.update(cx, |tree, cx| tree.write(repo, write.clone(), cx)) + .ok(); + }) + .into_any_element() + } + Ask::Choose { + repo, + title, + choices, + then, + } => { + let buttons = choices.iter().enumerate().map(|(at, choice)| { + let (this, then, choice, repo) = + (this.clone(), then.clone(), choice.clone(), *repo); + Button::new(("tree-choice", at), choice.clone()).on_click(move |_, _, cx| { + this.update(cx, |tree, cx| { + tree.ask = None; + match &then { + Choice::Publish(branch) => tree.publish(repo, branch, &choice, cx), + Choice::Upstream(branch) => { + tree.set_upstream(repo, branch, &choice, cx) + } + } + cx.notify(); + }) + .ok(); + }) + }); + let cancel = label("tree.cancel"); + Dialog::new("tree-choose", title.clone(), dismiss) + .child(div().flex().flex_col().gap_2().children(buttons)) + .action(move |close| { + Button::new("tree-choose-cancel", cancel) + .on_click(move |_, window, cx| close(window, cx)) + }) + .into_any_element() + } + _ => return None, + }) + } +} diff --git a/apps/ely-tree/src/blame.rs b/apps/ely-tree/src/blame.rs new file mode 100644 index 0000000..1d01be2 --- /dev/null +++ b/apps/ely-tree/src/blame.rs @@ -0,0 +1,87 @@ +//! Who last changed each line, from `blame --porcelain` (TREE-06). + +use std::{collections::HashMap, io, path::Path}; + +use crate::git::Git; + +/// A commit as blame names it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Owner { + pub commit: String, + pub author: String, + pub at: i64, + pub subject: String, +} + +impl Owner { + /// Lines not committed yet carry an all-zero id. + pub fn uncommitted(&self) -> bool { + self.commit.bytes().all(|b| b == b'0') + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Blamed { + pub code: String, + /// Each line's owner, as a place in `owners`. + pub lines: Vec, + pub owners: Vec, +} + +fn bad(line: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!("an unread blame line {line:?}"), + ) +} + +pub fn parse(bytes: &[u8]) -> io::Result { + let text = std::str::from_utf8(bytes) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "the file is not UTF-8 text"))?; + let mut blamed = Blamed { + code: String::new(), + lines: Vec::new(), + owners: Vec::new(), + }; + let mut known: HashMap = HashMap::new(); + let mut current: Option = None; + for line in text.split('\n').filter(|line| !line.is_empty()) { + if let Some(code) = line.strip_prefix('\t') { + blamed.lines.push(current.ok_or_else(|| bad(line))?); + blamed.code.push_str(code); + blamed.code.push('\n'); + continue; + } + let (key, value) = line.split_once(' ').unwrap_or((line, "")); + let header = key.len() >= 40 && key.bytes().all(|b| b.is_ascii_hexdigit()); + let owner = current.map(|at| &mut blamed.owners[at]); + match (header, key, owner) { + (true, _, _) => { + let next = blamed.owners.len(); + let at = *known.entry(key.to_string()).or_insert(next); + if at == next { + blamed.owners.push(Owner { + commit: key.into(), + author: String::new(), + at: 0, + subject: String::new(), + }); + } + current = Some(at); + } + (false, "author", Some(owner)) => owner.author = value.to_string(), + (false, "author-time", Some(owner)) => { + owner.at = value.parse().map_err(|_| bad(line))? + } + (false, "summary", Some(owner)) => owner.subject = value.to_string(), + // Mail, committer, filename and the rest are not shown. + _ => {} + } + } + Ok(blamed) +} + +/// The work tree's file, line by line. +pub fn read(git: &Git, root: &Path, path: &str) -> io::Result { + parse(&git.run(root, &["blame", "--porcelain", "--", path])?) +} diff --git a/apps/ely-tree/src/branches.rs b/apps/ely-tree/src/branches.rs new file mode 100644 index 0000000..bea0fbe --- /dev/null +++ b/apps/ely-tree/src/branches.rs @@ -0,0 +1,125 @@ +//! Branches and tags as `for-each-ref` lists them (TREE-07). + +use std::{io, path::Path}; + +use crate::git::Git; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Ref { + /// `main`, `origin/main`, `v1`. + pub name: String, + /// `refs/heads/main`: names a branch and a tag apart. + pub full: String, + pub kind: Kind, + pub current: bool, + pub upstream: Option, + /// Ahead and behind; None without a live upstream. + pub track: Option<(usize, usize)>, + /// The commit it names, through an annotated tag. + pub commit: String, + pub subject: String, + pub at: i64, + /// An annotated tag's own message. + pub message: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Local, + Remote, + Tag, +} + +const FORMAT: &str = "--format=%(refname)%00%(HEAD)%00%(upstream:short)%00%(upstream:track,nobracket)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(contents:subject)%00%(*subject)%00%(creatordate:unix)%1e"; + +fn bad(what: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!("an unread ref {what:?}"), + ) +} + +/// `ahead 2, behind 1` as (2, 1); `gone` as none. +fn track(words: &str) -> Option<(usize, usize)> { + if words == "gone" { + return None; + } + let mut counts = (0, 0); + for part in words.split(", ").filter(|part| !part.is_empty()) { + match part.split_once(' ')? { + ("ahead", n) => counts.0 = n.parse().ok()?, + ("behind", n) => counts.1 = n.parse().ok()?, + _ => return None, + } + } + Some(counts) +} + +pub fn parse(bytes: &[u8]) -> io::Result> { + let text = std::str::from_utf8(bytes).map_err(|_| bad("not UTF-8"))?; + let mut refs = Vec::new(); + for record in text + .split('\u{1e}') + .map(|record| record.trim_start_matches('\n')) + .filter(|record| !record.is_empty()) + { + let fields: Vec<&str> = record.split('\0').collect(); + let [ + full, + head, + upstream, + tracking, + object_kind, + object, + peeled, + subject, + peeled_subject, + at, + ] = fields[..] + else { + return Err(bad(record)); + }; + // Names come off the full ref; short ones clash. + let (kind, name) = if let Some(name) = full.strip_prefix("refs/heads/") { + (Kind::Local, name) + } else if let Some(name) = full + .strip_prefix("refs/remotes/") + .filter(|name| !name.ends_with("/HEAD")) + { + (Kind::Remote, name) + } else if let Some(name) = full.strip_prefix("refs/tags/") { + (Kind::Tag, name) + } else { + // `origin/HEAD` points at a branch; it is not one. + continue; + }; + let annotated = object_kind == "tag"; + refs.push(Ref { + name: name.to_string(), + full: full.to_string(), + kind, + current: head == "*", + upstream: (!upstream.is_empty()).then(|| upstream.to_string()), + track: (!upstream.is_empty()).then(|| track(tracking)).flatten(), + commit: if annotated { peeled } else { object }.to_string(), + subject: if annotated { peeled_subject } else { subject }.to_string(), + at: at.parse().map_err(|_| bad(record))?, + message: annotated.then(|| subject.to_string()), + }); + } + Ok(refs) +} + +/// Every branch, remote branch and tag. +pub fn read(git: &Git, root: &Path) -> io::Result> { + parse(&git.run( + root, + &[ + "for-each-ref", + FORMAT, + "refs/heads", + "refs/remotes", + "refs/tags", + ], + )?) +} diff --git a/apps/ely-tree/src/changes.rs b/apps/ely-tree/src/changes.rs new file mode 100644 index 0000000..5766268 --- /dev/null +++ b/apps/ely-tree/src/changes.rs @@ -0,0 +1,165 @@ +//! The left panel: the work tree's files, or history. + +use ely_gpui_component::{ + forms::Choice, + git::{Changed, ChangesList}, + lists::GitStatus, + navigation::Tabs, + primitives::IconName, +}; +use gpui::{ + AnyElement, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Subscription, WeakEntity, Window, div, + prelude::FluentBuilder as _, +}; + +use crate::{ + status::{Lines, State, Status}, + tree::{Side, Tree, text}, + work::Write, +}; + +/// The left panel; it reads the active repository. +pub struct Changes { + tree: WeakEntity, + _follow: Subscription, +} + +impl Changes { + pub fn new(tree: &Entity, cx: &mut Context) -> Self { + Changes { + tree: tree.downgrade(), + _follow: cx.observe(tree, |_, _, cx| cx.notify()), + } + } +} + +fn badge(letter: char) -> GitStatus { + match letter { + 'A' | 'C' => GitStatus::Added, + 'D' => GitStatus::Deleted, + 'R' => GitStatus::Renamed, + _ => GitStatus::Modified, + } +} + +/// The five sections: conflicted, staged, unstaged, untracked, ignored. +pub fn sections(status: &Status) -> [Vec; 5] { + let mut sections: [Vec; 5] = Default::default(); + let file = |path: &str, status, lines: Option<&Lines>| Changed { + path: path.to_string().into(), + status, + lines: lines.and_then(|lines| lines.get(path).copied().flatten()), + }; + for entry in &status.entries { + let path = entry.path.as_str(); + match &entry.state { + State::Conflicted(_) => sections[0].push(file(path, GitStatus::Conflicted, None)), + State::Tracked { staged, unstaged } => { + if *staged != '.' { + sections[1].push(file(path, badge(*staged), Some(&status.staged))); + } + if *unstaged != '.' { + sections[2].push(file(path, badge(*unstaged), Some(&status.unstaged))); + } + } + State::Untracked => sections[3].push(file(path, GitStatus::Untracked, None)), + State::Ignored => sections[4].push(file(path, GitStatus::Untracked, None)), + } + } + sections +} + +impl Render for Changes { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(tree) = self.tree.upgrade() else { + return div().into_any_element(); + }; + let side = tree.read(cx).side; + let panel = match side { + Side::Changes => changes(&tree, cx), + Side::History => crate::side_log::history(&tree, cx), + Side::Branches => crate::side_refs::branches(&tree, cx), + Side::Places => crate::side_places::places(&tree, cx), + }; + let this = tree.downgrade(); + let tabs = [ + Choice::new("changes", text(cx, "tree.side.changes", &[])).icon(IconName::SquarePen), + Choice::new("history", text(cx, "tree.side.history", &[])).icon(IconName::History), + Choice::new("branches", text(cx, "tree.side.branches", &[])).icon(IconName::GitBranch), + Choice::new("places", text(cx, "tree.side.places", &[])).icon(IconName::FolderOpen), + ]; + let chosen = match side { + Side::Changes => "changes", + Side::History => "history", + Side::Branches => "branches", + Side::Places => "places", + }; + div() + .size_full() + .flex() + .flex_col() + .child( + Tabs::new("tree-side", tabs, chosen).on_change(move |value, _, cx| { + let side = match value.as_ref() { + "history" => Side::History, + "branches" => Side::Branches, + "places" => Side::Places, + _ => Side::Changes, + }; + this.update(cx, |tree, cx| tree.set_side(side, cx)).ok(); + }), + ) + .child(div().flex_1().min_h_0().child(panel)) + .into_any_element() + } +} + +/// The work tree's files, then the commit box. +fn changes(tree: &Entity, cx: &mut Context) -> AnyElement { + let read = tree.read(cx); + let Some((id, Some(Ok(status)))) = read.active().map(|opened| (opened.id, &opened.status)) + else { + return div().into_any_element(); + }; + let [conflicted, staged, unstaged, untracked, ignored] = sections(status); + let staged_count = staged.len(); + let branch = crate::view::branch(&status.head, cx); + let (on_file, on_all) = (tree.downgrade(), tree.downgrade()); + let list = ChangesList::new(("tree-changes", id), staged, unstaged) + .conflicted(conflicted) + .untracked(untracked) + .ignored(ignored) + .when_some( + read.shown.as_ref().filter(|shown| shown.repo == id), + |list, shown| list.selected(shown.section, shown.path.clone()), + ) + .on_action(move |path, section, action, _, cx| { + on_file + .update(cx, |tree, cx| tree.file_action(path, section, action, cx)) + .ok(); + }) + .on_all(move |stage, _, cx| { + let write = if stage { + Write::StageAll + } else { + Write::UnstageAll + }; + on_all.update(cx, |tree, cx| tree.write(id, write, cx)).ok(); + }); + div() + .size_full() + .flex() + .flex_col() + .child( + div() + .id("tree-changes-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .p_2() + .child(list), + ) + .child(crate::side_log::commit_box(tree, branch, staged_count, cx)) + .into_any_element() +} diff --git a/apps/ely-tree/src/cloning.rs b/apps/ely-tree/src/cloning.rs new file mode 100644 index 0000000..f20087c --- /dev/null +++ b/apps/ely-tree/src/cloning.rs @@ -0,0 +1,153 @@ +//! Cloning with progress and a stop (TREE-01). + +use std::{io, path::PathBuf}; + +use ely_gpui_component::{forms::TextInput, primitives::Severity}; +use futures::{ + StreamExt as _, + channel::{mpsc, oneshot}, +}; +use gpui::{AppContext as _, Context, SharedString, Window}; + +use crate::{ + asks::Ask, + running::Stop, + tree::{Tree, text}, +}; + +/// A clone on its way. +pub struct Cloning { + pub into: PathBuf, + pub said: SharedString, + pub stop: Stop, + /// Asked to stop; its failure is then expected. + pub stopping: bool, +} + +/// A URL's last part, less `.git`. +pub fn suggested(url: &str) -> Option<&str> { + let last = url.trim_end_matches('/').rsplit(['/', ':', '\\']).next()?; + Some(last.strip_suffix(".git").unwrap_or(last)).filter(|name| !name.is_empty()) +} + +impl Tree { + pub fn ask_url(&mut self, window: &mut Window, cx: &mut Context) { + if self.cloning.is_some() { + let words = text(cx, "tree.clone.busy", &[]); + return self.toast(words, Severity::Warning, cx); + } + self.ask = Some(Ask::Url(cx.new(|cx| TextInput::new(window, cx)))); + cx.notify(); + } + + /// Asks where the clone goes, beside the last repository opened. + pub fn pick_clone_folder(&mut self, url: String, cx: &mut Context) { + let near = self + .recent + .first() + .and_then(|last| last.parent()) + .map(PathBuf::from) + .or_else(std::env::home_dir); + let Some(near) = near else { + return self.failed("tree.error.clone", "no home folder", cx); + }; + let picked = cx.prompt_for_new_path(&near, suggested(&url)); + cx.spawn(async move |this, cx| { + let picked = picked + .await + .map_err(|error| error.to_string()) + .and_then(|picked| picked.map_err(|error| error.to_string())); + this.update(cx, |tree, cx| match picked { + Ok(Some(into)) => tree.start_clone(url, into, cx), + Ok(None) => log::info!("tree: the clone folder panel was cancelled"), + Err(error) => tree.failed("tree.error.panel", &error, cx), + }) + .ok(); + }) + .detach(); + } + + fn start_clone(&mut self, url: String, into: PathBuf, cx: &mut Context) { + let Some(git) = self.git(cx) else { return }; + let running = match git.clone_repo(&url, &into) { + Ok(running) => running, + Err(error) => return self.failed("tree.error.clone", &error.to_string(), cx), + }; + self.cloning = Some(Cloning { + into, + said: SharedString::default(), + stop: running.stop(), + stopping: false, + }); + let (said, mut lines) = mpsc::unbounded(); + let (done, result) = oneshot::channel(); + // Git's progress blocks a thread until the clone ends. + std::thread::spawn(move || { + let followed = running.follow(|line| { + let _ = said.unbounded_send(SharedString::from(line.to_string())); + }); + let _ = done.send(followed); + }); + cx.spawn(async move |this, cx| { + while let Some(line) = lines.next().await { + let shown = this.update(cx, |tree, cx| { + if let Some(cloning) = &mut tree.cloning { + cloning.said = line; + cx.notify(); + } + }); + if shown.is_err() { + return; + } + } + let ended = result + .await + .unwrap_or_else(|_| Err(io::Error::other("the clone thread ended without a word"))); + this.update(cx, |tree, cx| tree.cloned(ended.map(drop), cx)) + .ok(); + }) + .detach(); + cx.notify(); + } + + fn cloned(&mut self, ended: io::Result<()>, cx: &mut Context) { + let Some(cloning) = self.cloning.take() else { + return; + }; + cx.notify(); + match ended { + Ok(()) => self.open_path(cloning.into, cx), + Err(_) if cloning.stopping => { + let words = text(cx, "tree.clone.stopped", &[]); + self.toast(words, Severity::Info, cx); + } + Err(error) => self.failed("tree.error.clone", &error.to_string(), cx), + } + } + + /// Stops the clone; Git removes what it began. + pub fn stop_clone(&mut self, cx: &mut Context) { + let Some(cloning) = &mut self.cloning else { + return; + }; + cloning.stopping = true; + if let Err(error) = cloning.stop.now() { + self.failed("tree.error.stop", &error.to_string(), cx); + } + cx.notify(); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn a_url_suggests_its_last_part() { + assert_eq!( + super::suggested("https://github.com/a/ely.git"), + Some("ely") + ); + assert_eq!(super::suggested("git@github.com:a/ely.git/"), Some("ely")); + assert_eq!(super::suggested("/srv/repos/notes"), Some("notes")); + assert_eq!(super::suggested("https://host/.git"), None); + } +} diff --git a/apps/ely-tree/src/commit.rs b/apps/ely-tree/src/commit.rs new file mode 100644 index 0000000..31854ad --- /dev/null +++ b/apps/ely-tree/src/commit.rs @@ -0,0 +1,54 @@ +//! Committing: message, amend, identity, signing as set (TREE-05). + +use std::{io, path::Path}; + +use crate::git::Git; + +/// Who commits, and how Git signs, as Git's settings say. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Me { + pub name: Option, + pub email: Option, + /// `openpgp`, `ssh` or `x509` when commits are signed. + pub signs: Option, +} + +/// A setting as Git resolves it; None when unset. +fn setting(git: &Git, root: &Path, key: &str) -> io::Result> { + let said = git.run_or_one(root, &["config", "--get", key])?; + let said = String::from_utf8_lossy(&said).trim().to_string(); + Ok((!said.is_empty()).then_some(said)) +} + +pub fn me(git: &Git, root: &Path) -> io::Result { + let signs = git.run_or_one(root, &["config", "--type=bool", "--get", "commit.gpgsign"])?; + let signs = match String::from_utf8_lossy(&signs).trim() { + "true" => Some(setting(git, root, "gpg.format")?.unwrap_or_else(|| "openpgp".into())), + _ => None, + }; + Ok(Me { + name: setting(git, root, "user.name")?, + email: setting(git, root, "user.email")?, + signs, + }) +} + +/// The last commit's whole message, to amend. +pub fn last_message(git: &Git, root: &Path) -> io::Result { + let said = git.run(root, &["log", "-1", "--format=%B"])?; + Ok(String::from_utf8_lossy(&said).trim_end().to_string()) +} + +/// Subject and body as one message: a blank line between. +pub fn message(subject: &str, body: &str) -> String { + match body.trim() { + "" => subject.trim().to_string(), + body => format!("{}\n\n{body}", subject.trim()), + } +} + +/// A message back to subject and body. +pub fn split(message: &str) -> (String, String) { + let (subject, body) = message.split_once('\n').unwrap_or((message, "")); + (subject.trim().to_string(), body.trim().to_string()) +} diff --git a/apps/ely-tree/src/commits.rs b/apps/ely-tree/src/commits.rs new file mode 100644 index 0000000..7ef61b0 --- /dev/null +++ b/apps/ely-tree/src/commits.rs @@ -0,0 +1,218 @@ +//! History as Git prints it: graph, file commits, search (TREE-06). + +use std::{io, path::Path}; + +use crate::git::Git; + +/// Commits read per page. +pub const PAGE: usize = 2000; + +/// One commit as a log lists it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Logged { + pub id: String, + pub parents: Vec, + pub author: String, + /// Author time, seconds since the epoch. + pub at: i64, + pub subject: String, + pub refs: Vec, +} + +/// What a search looks in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Find { + #[default] + Message, + Author, + Hash, + /// Commits adding or removing the text (`-S`). + Content, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Query { + pub find: Find, + pub text: String, +} + +const FORMAT: &str = "--format=%x1e%H%x00%P%x00%an%x00%at%x00%s%x00%D"; + +fn bad(what: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!("an unread log record {what:?}"), + ) +} + +fn text(bytes: &[u8]) -> io::Result<&str> { + std::str::from_utf8(bytes) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "a log that is not UTF-8")) +} + +/// `HEAD -> main, origin/main, tag: v1` as `main`, `origin/main`, `v1`. +fn refs(decorations: &str) -> Vec { + decorations + .split(", ") + .filter(|name| !name.is_empty() && *name != "HEAD") + .map(|name| { + name.trim_start_matches("HEAD -> ") + .trim_start_matches("tag: ") + .to_string() + }) + .collect() +} + +fn record(fields: &[&str], whole: &str) -> io::Result { + let [id, parents, author, at, subject, ..] = fields else { + return Err(bad(whole)); + }; + Ok(Logged { + id: id.to_string(), + parents: parents + .split(' ') + .filter(|p| !p.is_empty()) + .map(String::from) + .collect(), + author: author.to_string(), + at: at.parse().map_err(|_| bad(whole))?, + subject: subject.to_string(), + refs: fields + .get(5) + .map(|d| refs(d.trim_end())) + .unwrap_or_default(), + }) +} + +/// Reads `log` output in `FORMAT`. +pub fn parse(bytes: &[u8]) -> io::Result> { + text(bytes)? + .split('\u{1e}') + .filter(|chunk| !chunk.trim().is_empty()) + .map(|chunk| { + record( + &chunk.trim_end_matches('\n').split('\0').collect::>(), + chunk, + ) + }) + .collect() +} + +/// A page of history: every ref, or a search. +pub fn read(git: &Git, root: &Path, skip: usize, query: Option<&Query>) -> io::Result> { + let (skip, page) = (format!("--skip={skip}"), format!("--max-count={PAGE}")); + let mut args: Vec = ["log", "--topo-order", FORMAT, &page, &skip] + .map(String::from) + .to_vec(); + match query { + Some(Query { + find: Find::Hash, + text, + }) => { + let found = git.run_or_one( + root, + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("{text}^{{commit}}"), + ], + )?; + let id = String::from_utf8_lossy(&found).trim().to_string(); + if id.is_empty() || skip != "--skip=0" { + return Ok(Vec::new()); + } + args = ["log", FORMAT, "--max-count=1", &id] + .map(String::from) + .to_vec(); + } + Some(Query { find, text }) => { + args.extend( + [ + "--exclude=refs/stash", + "--all", + "--regexp-ignore-case", + "--fixed-strings", + ] + .map(String::from), + ); + args.push(match find { + Find::Author => format!("--author={text}"), + Find::Content => format!("-S{text}"), + _ => format!("--grep={text}"), + }); + } + None => args.extend(["--exclude=refs/stash", "--all"].map(String::from)), + } + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + parse(&git.run(root, &args)?) +} + +/// The refs as they stand, to see whether history moved. +pub fn refs_now(git: &Git, root: &Path) -> io::Result> { + git.run_or_one(root, &["show-ref", "--head"]) +} + +/// A file's commit, the path it had, and its lines. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Touch { + pub commit: Logged, + pub path: String, + pub from: Option, + /// None for binary files or commits without a diff. + pub lines: Option<(usize, usize)>, +} + +/// The file's commits newest first, through renames. +pub fn file(git: &Git, root: &Path, path: &str) -> io::Result> { + let format = "--format=%x1e%H%x00%P%x00%an%x00%at%x00%s"; + let out = git.run( + root, + &["log", "--follow", format, "--numstat", "-z", "--", path], + )?; + let mut now = path.to_string(); + let mut touches = Vec::new(); + for chunk in text(&out)? + .split('\u{1e}') + .filter(|chunk| !chunk.is_empty()) + { + let fields: Vec<&str> = chunk.splitn(6, '\0').collect(); + let commit = record(&fields[..fields.len().min(5)], chunk)?; + let stat = fields + .get(5) + .copied() + .unwrap_or("") + .trim_start_matches('\n'); + let mut parts = stat.split('\0').filter(|part| !part.is_empty()); + let (lines, path, from) = match parts.next() { + None => (None, now.clone(), None), + Some(head) => { + let mut cells = head.splitn(3, '\t'); + let (added, removed, named) = + (cells.next(), cells.next(), cells.next().unwrap_or("")); + let lines = added + .zip(removed) + .and_then(|(a, r)| Some((a.parse().ok()?, r.parse().ok()?))); + match named { + "" => { + let from = parts.next().ok_or_else(|| bad(chunk))?.to_string(); + ( + lines, + parts.next().ok_or_else(|| bad(chunk))?.to_string(), + Some(from), + ) + } + named => (lines, named.to_string(), None), + } + } + }; + now = from.clone().unwrap_or_else(|| path.clone()); + touches.push(Touch { + commit, + path, + from, + lines, + }); + } + Ok(touches) +} diff --git a/apps/ely-tree/src/committing.rs b/apps/ely-tree/src/committing.rs new file mode 100644 index 0000000..c638684 --- /dev/null +++ b/apps/ely-tree/src/committing.rs @@ -0,0 +1,224 @@ +//! The commit box: message, amend, identity (TREE-05). + +use ely_gpui_component::forms::TextInput; +use gpui::{AppContext as _, Context, Window}; + +use crate::{ + asks::Ask, + commit::{self, Me}, + tree::{Tree, text}, + work::Write, +}; + +/// A repository's commit box, kept while another is shown. +#[derive(Clone, Debug, Default)] +pub struct Held { + pub subject: String, + pub body: String, + pub amend: bool, + pub draft: Option<(String, String)>, +} + +impl Tree { + /// Shows repository `id`, swapping its commit box in. + pub fn activate(&mut self, id: Option, cx: &mut Context) { + if self.active == id { + return cx.notify(); + } + // A last message still loading is not carried across. + if self.amend_loading { + self.set_amend(false, cx); + } + let held = Held { + subject: self.subject.read(cx).text().to_string(), + body: self.body.read(cx).text().to_string(), + amend: self.amend, + draft: self.draft.take(), + }; + if let Some(old) = self.active.and_then(|old| self.opened_mut(old)) { + old.held = held; + } + self.active = id; + self.amend_ask += 1; + let held = id + .and_then(|id| self.opened_mut(id)) + .map(|opened| std::mem::take(&mut opened.held)) + .unwrap_or_default(); + (self.amend, self.draft) = (held.amend, held.draft); + self.fill(&held.subject, &held.body, cx); + self.ensure_side(cx); + cx.notify(); + } + + /// Rereads who commits and whether Git signs. + pub fn read_me(&mut self, id: u64, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(opened) = self.opened_mut(id) else { + return; + }; + opened.me_asks += 1; + let (root, ask) = (opened.repo.root.clone(), opened.me_asks); + let task = cx.background_spawn(async move { commit::me(&git, &root) }); + cx.spawn(async move |this, cx| { + let me = task.await.map_err(|error| error.to_string()); + this.update(cx, |tree, cx| { + if let Some(opened) = tree.opened_mut(id).filter(|opened| opened.me_asks == ask) { + opened.me = Some(me); + cx.notify(); + } + }) + .ok(); + }) + .detach(); + } + + fn fill(&self, subject: &str, body: &str, cx: &mut Context) { + self.subject + .update(cx, |field, cx| field.set_text(subject.to_string(), cx)); + self.body + .update(cx, |field, cx| field.set_text(body.to_string(), cx)); + } + + /// Amend loads the last message; off restores the draft. + pub fn set_amend(&mut self, on: bool, cx: &mut Context) { + // Any later choice voids a read still on its way. + self.amend_ask += 1; + if !on { + (self.amend, self.amend_loading) = (false, false); + if let Some((subject, body)) = self.draft.take() { + self.fill(&subject, &body, cx); + } + return cx.notify(); + } + let Some(git) = self.git(cx) else { return }; + let Some((repo, root)) = self + .active() + .map(|opened| (opened.id, opened.repo.root.clone())) + else { + return; + }; + // The tick shows at once; the message follows. + (self.amend, self.amend_loading) = (true, true); + self.draft = Some(( + self.subject.read(cx).text().to_string(), + self.body.read(cx).text().to_string(), + )); + let ask = self.amend_ask; + let task = cx.background_spawn(async move { commit::last_message(&git, &root) }); + cx.spawn(async move |this, cx| { + let last = task.await; + this.update(cx, |tree, cx| { + if tree.amend_ask != ask || tree.active != Some(repo) { + return log::info!("tree: an amend read came back late"); + } + match last { + Ok(message) => { + let (subject, body) = commit::split(&message); + tree.fill(&subject, &body, cx); + tree.amend_loading = false; + cx.notify(); + } + Err(error) => { + tree.set_amend(false, cx); + tree.failed("tree.error.amend", &error.to_string(), cx); + } + } + }) + .ok(); + }) + .detach(); + cx.notify(); + } + + pub fn commit(&mut self, cx: &mut Context) { + let Some(id) = self.active.filter(|_| !self.amend_loading) else { + return; + }; + let message = commit::message(self.subject.read(cx).text(), self.body.read(cx).text()); + let amend = self.amend; + log::info!("tree: commit asked, amend {amend}"); + self.write(id, Write::Commit { message, amend }, cx); + } + + /// A commit went in: its box empties if unchanged since. + pub fn committed(&mut self, id: u64, message: &str, cx: &mut Context) { + if self.active == Some(id) { + let now = commit::message(self.subject.read(cx).text(), self.body.read(cx).text()); + if now == message { + (self.amend, self.draft) = (false, None); + self.fill("", "", cx); + } + } else if let Some(opened) = self.opened_mut(id) { + if commit::message(&opened.held.subject, &opened.held.body) == message { + opened.held = Held::default(); + } + } + cx.notify(); + } + + /// Opens the identity dialog, filled with Git's settings. + pub fn ask_identity(&mut self, window: &mut Window, cx: &mut Context) { + let me: Me = match self.active().and_then(|opened| opened.me.clone()) { + Some(Ok(me)) => me, + _ => Me::default(), + }; + let field = + |value: Option, key: &str, window: &mut Window, cx: &mut Context| { + // Named for assistive tech; the dialog draws the label. + let label = text(cx, key, &[]); + cx.new(|cx| { + let mut field = TextInput::new(window, cx).label(label); + field.set_text(value.unwrap_or_default(), cx); + field + }) + }; + let name = field(me.name, "tree.identity.name", window, cx); + let email = field(me.email, "tree.identity.email", window, cx); + let Some(repo) = self.active else { return }; + self.ask = Some(Ask::Identity { + repo, + name, + email, + global: false, + }); + cx.notify(); + } + + /// Writes the name and address, here or for every repository. + pub fn set_identity(&mut self, cx: &mut Context) { + let Some(Ask::Identity { + repo, + name, + email, + global, + }) = &self.ask + else { + return; + }; + let id = *repo; + let (name, email, global) = ( + name.read(cx).text().trim().to_string(), + email.read(cx).text().trim().to_string(), + *global, + ); + if name.is_empty() || email.is_empty() { + let words = text(cx, "tree.identity.empty", &[]); + return self.toast(words, ely_gpui_component::primitives::Severity::Warning, cx); + } + self.ask = None; + for (key, value) in [("user.name", name), ("user.email", email)] { + self.write( + id, + Write::Config { + key: key.into(), + value, + global, + }, + cx, + ); + } + cx.notify(); + } +} diff --git a/apps/ely-tree/src/conflict.rs b/apps/ely-tree/src/conflict.rs new file mode 100644 index 0000000..0385a7d --- /dev/null +++ b/apps/ely-tree/src/conflict.rs @@ -0,0 +1,313 @@ +//! A conflict's three stages and hand-kept result (TREE-09). + +use std::{io, path::Path, rc::Rc}; + +use ely_gpui_component::{ + git::{MARKER, Region, RegionKind, Take, conflicts, marked, resolve}, + primitives::Severity, +}; +use gpui::Context; + +use crate::{ + asks::Ask, + git::Git, + tree::{Tree, text}, + work::Write, +}; + +/// What Git holds for a conflicted path. +#[derive(Clone, Debug, PartialEq)] +pub enum Stages { + /// Text on all three sides, and the marked work file. + Text { + sides: [Option; 3], + work: String, + regions: Vec, + /// Marker width, from `conflict-marker-size`. + size: usize, + }, + /// Links, submodules, bytes, missing sides: taken whole. + Whole { present: [bool; 3] }, +} + +/// Stage, mode and blob id from `ls-files -u -z`. +fn staged(bytes: &[u8]) -> io::Result> { + let text = std::str::from_utf8(bytes).map_err(io::Error::other)?; + text.split('\0') + .filter(|record| !record.is_empty()) + .map(|record| { + let bad = || { + io::Error::new( + io::ErrorKind::InvalidData, + format!("an unread stage {record:?}"), + ) + }; + let (head, _) = record.split_once('\t').ok_or_else(bad)?; + let mut fields = head.split(' '); + let (mode, id, stage) = ( + fields.next().ok_or_else(bad)?, + fields.next().ok_or_else(bad)?, + fields.next().ok_or_else(bad)?, + ); + let stage: usize = stage.parse().map_err(|_| bad())?; + if !(1..=3).contains(&stage) { + return Err(bad()); + } + Ok((stage, mode.to_string(), id.to_string())) + }) + .collect() +} + +/// Each block's base, from Git's own diff3 merge. +fn bases( + git: &Git, + root: &Path, + sides: &[Option; 3], + size: usize, +) -> io::Result>> { + let dir = tempfile::tempdir()?; + let mut names = Vec::new(); + for (side, text) in ["base", "ours", "theirs"].iter().zip(sides) { + let file = dir.path().join(side); + std::fs::write(&file, text.as_deref().unwrap_or(""))?; + names.push(file.to_string_lossy().into_owned()); + } + let out = git.run_counted( + root, + &[ + "merge-file", + "-p", + "--zdiff3", + &format!("--marker-size={size}"), + &names[1], + &names[0], + &names[2], + ], + )?; + let merged = String::from_utf8(out).map_err(io::Error::other)?; + Ok(marked(&merged, size) + .into_iter() + .filter(|region| region.kind == RegionKind::Conflict) + .map(|region| region.base) + .collect()) +} + +/// The marker width `conflict-marker-size` sets for `path`. +fn marker_size(git: &Git, root: &Path, path: &str) -> io::Result { + let said = String::from_utf8(git.run( + root, + &["check-attr", "-z", "conflict-marker-size", "--", path], + )?) + .map_err(io::Error::other)?; + // `-z` gives path, attribute and value, each NUL-ended. + Ok(said.split('\0').nth(2).map_or(MARKER, git_width)) +} + +/// As Git's `strtol_i`: whole value, else 7. +fn git_width(value: &str) -> usize { + match value + .trim_start_matches(|c: char| c.is_ascii_whitespace() || c == '\x0b') + .parse::() + { + Ok(size) if size > 0 => size as usize, + _ => MARKER, + } +} + +/// A NUL early on: Git calls it binary. +fn binary(bytes: &[u8]) -> bool { + bytes[..bytes.len().min(8000)].contains(&0) +} + +/// What Git holds for `path`: marked text, or whole files. +pub fn read(git: &Git, root: &Path, path: &str) -> io::Result { + let mut sides: [Option; 3] = Default::default(); + let (mut present, mut text) = ([false; 3], true); + for (stage, mode, id) in staged(&git.run(root, &["ls-files", "-u", "-z", "--", path])?)? { + present[stage - 1] = true; + // Links and submodules are no text to merge by line. + if mode == "120000" || mode == "160000" { + text = false; + continue; + } + let bytes = git.run(root, &["cat-file", "blob", &id])?; + match String::from_utf8(bytes) + .ok() + .filter(|side| !binary(side.as_bytes())) + { + Some(side) => sides[stage - 1] = Some(side), + None => text = false, + } + } + let file = root.join(path); + let work = match std::fs::symlink_metadata(&file) { + Ok(meta) if meta.file_type().is_file() => String::from_utf8(std::fs::read(&file)?).ok(), + _ => None, + }; + let Some(work) = work.filter(|_| text && present[1] && present[2]) else { + return Ok(Stages::Whole { present }); + }; + let size = marker_size(git, root, path)?; + let mut regions = marked(&work, size); + let blocks = conflicts(&work, size); + let bases = bases(git, root, &sides, size)?; + // Fill only blocks Git left without a base. + if bases.len() == blocks.len() { + let conflicted = regions + .iter_mut() + .filter(|region| region.kind == RegionKind::Conflict); + for ((region, block), base) in conflicted.zip(&blocks).zip(bases) { + if block.base.is_none() { + region.base = base; + } + } + } + Ok(Stages::Text { + sides, + work, + regions, + size, + }) +} + +/// A text conflict as the middle holds it, being worked. +pub struct Merging { + pub stages: Stages, + pub regions: Rc>, + pub takes: Vec>, + /// The text last loaded or saved, to tell unsaved edits. + pub saved: String, +} + +impl Merging { + /// This file's marker width. + pub fn size(&self) -> usize { + match self.stages { + Stages::Text { size, .. } => size, + Stages::Whole { .. } => MARKER, + } + } + + /// None for a whole-file conflict. + pub fn new(stages: Stages) -> Option { + let Stages::Text { work, regions, .. } = &stages else { + return None; + }; + let count = regions + .iter() + .filter(|region| region.kind == RegionKind::Conflict) + .count(); + let (regions, saved) = (Rc::new(regions.clone()), work.clone()); + Some(Merging { + stages, + regions, + takes: vec![None; count], + saved, + }) + } +} + +impl Tree { + /// Settles conflict `at` in the result, among those open. + pub fn take(&mut self, at: usize, take: Take, cx: &mut Context) { + let Some(merging) = self.merging.as_mut() else { + return; + }; + let result = self.merge_editor.read(cx).text().to_string(); + let open = merging.takes.iter().filter(|taken| taken.is_none()).count(); + // Hand edits that moved the markers end block choices. + if merging.takes.get(at).copied().flatten().is_some() + || conflicts(&result, merging.size()).len() != open + { + let words = text(cx, "tree.conflict.hand", &[]); + return self.toast(words, Severity::Warning, cx); + } + let place = merging.takes[..at] + .iter() + .filter(|taken| taken.is_none()) + .count(); + let Some(settled) = resolve(&result, place, take, merging.size()) else { + return; + }; + merging.takes[at] = Some(take); + self.merge_editor + .update(cx, |editor, cx| editor.set_text(settled, cx)); + cx.notify(); + } + + /// Queues the result for its work file; staging is apart. + pub fn save_merge(&mut self, cx: &mut Context) { + let Some(shown) = &self.shown else { return }; + let (repo, path) = (shown.repo, shown.path.clone()); + let text = self.merge_editor.read(cx).text().to_string(); + self.write(repo, Write::Save { path, text }, cx); + } + + /// A save landed; it counts if still this file's. + pub fn merge_saved(&mut self, id: u64, path: &str, text: &str, cx: &mut Context) { + let here = self + .shown + .as_ref() + .is_some_and(|shown| shown.repo == id && shown.path == path); + if let Some(merging) = self.merging.as_mut().filter(|_| here) { + merging.saved = text.to_string(); + cx.notify(); + } + } + + /// Marks the saved result resolved; markers left ask first. + pub fn mark_resolved(&mut self, cx: &mut Context) { + let Some(shown) = &self.shown else { return }; + let (repo, path) = (shown.repo, shown.path.clone()); + let result = self.merge_editor.read(cx).text().to_string(); + if self + .merging + .as_ref() + .is_some_and(|merging| merging.saved != result) + { + let words = text(cx, "tree.conflict.unsaved", &[]); + return self.toast(words, Severity::Warning, cx); + } + let write = Write::Git(vec!["add".into(), "--".into(), path.clone()]); + let size = self.merging.as_ref().map_or(MARKER, Merging::size); + if conflicts(&result, size).is_empty() { + return self.write(repo, write, cx); + } + self.ask = Some(Ask::Confirm { + repo, + title: text(cx, "tree.conflict.marked.title", &[("name", &path)]), + message: text(cx, "tree.conflict.marked.message", &[("name", &path)]), + go: text(cx, "tree.conflict.marked.go", &[]), + write, + }); + cx.notify(); + } + + /// One side's whole file into the work tree. + pub fn take_side(&mut self, ours: bool, cx: &mut Context) { + let Some(shown) = &self.shown else { return }; + let side = if ours { "--ours" } else { "--theirs" }; + let write = Write::Git(vec![ + "checkout".into(), + side.into(), + "--".into(), + shown.path.clone(), + ]); + let repo = shown.repo; + self.write(repo, write, cx); + } + + /// Settles the conflict by removing the file; asks first. + pub fn resolve_deleted(&mut self, cx: &mut Context) { + let Some(shown) = &self.shown else { return }; + let (repo, path) = (shown.repo, shown.path.clone()); + self.ask = Some(Ask::Confirm { + repo, + title: text(cx, "tree.conflict.delete.title", &[("name", &path)]), + message: text(cx, "tree.conflict.delete.message", &[("name", &path)]), + go: text(cx, "tree.conflict.delete.go", &[]), + write: Write::Git(vec!["rm".into(), "--".into(), path]), + }); + cx.notify(); + } +} diff --git a/apps/ely-tree/src/conflict_pane.rs b/apps/ely-tree/src/conflict_pane.rs new file mode 100644 index 0000000..11ddbcb --- /dev/null +++ b/apps/ely-tree/src/conflict_pane.rs @@ -0,0 +1,222 @@ +//! A conflict's middle, and the running operation's alert. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + feedback::{Alert, EmptyState}, + git::ThreeWayMerge, + primitives::{IconName, Severity}, + theme::{ActiveTheme as _, ControlSize}, +}; +use gpui::{ + AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Styled as _, Window, + div, +}; + +use crate::{ + conflict::Stages, + ops::{Doing, Op}, + tree::{Tree, text}, +}; + +type Act = Box)>; + +/// Words for ours and theirs, as this operation means. +pub fn sides(doing: Option<&Doing>, cx: &App) -> [SharedString; 2] { + match doing { + Some(doing) => { + let (ours, theirs) = match doing.op { + Op::Merge => ("tree.ours.merge", "tree.theirs.merge"), + Op::Rebase => ("tree.ours.rebase", "tree.theirs.rebase"), + Op::CherryPick => ("tree.ours.pick", "tree.theirs.pick"), + Op::Revert => ("tree.ours.revert", "tree.theirs.revert"), + }; + [ + text(cx, ours, &[("name", &doing.ours)]), + text(cx, theirs, &[("name", &doing.theirs)]), + ] + } + None => [text(cx, "tree.ours", &[]), text(cx, "tree.theirs", &[])], + } +} + +fn button( + id: &str, + key: &str, + variant: ButtonVariant, + act: Act, + cx: &mut Context, +) -> AnyElement { + let this = cx.weak_entity(); + Button::new(SharedString::from(id.to_string()), text(cx, key, &[])) + .size(ControlSize::Sm) + .variant(variant) + .on_click(move |_, _: &mut Window, cx: &mut App| { + this.update(cx, |tree, cx| act(tree, cx)).ok(); + }) + .into_any_element() +} + +impl Tree { + /// A stopped operation: continue, skip or abort. + pub fn op_alert(&self, cx: &mut Context) -> Option { + let Some(Some(Ok(Some(doing)))) = self.active().map(|opened| opened.doing.as_ref()) else { + return None; + }; + let op = doing.op; + let key = match op { + Op::Merge => "tree.op.merging", + Op::Rebase => "tree.op.rebasing", + Op::CherryPick => "tree.op.picking", + Op::Revert => "tree.op.reverting", + }; + let title = text(cx, key, &[("ours", &doing.ours), ("theirs", &doing.theirs)]); + let mut alert = Alert::new("tree-op", Severity::Warning, title) + .body(text(cx, "tree.op.next", &[])) + .action(button( + "tree-op-continue", + "tree.op.continue", + ButtonVariant::Primary, + Box::new(move |tree, cx| tree.op_continue(op, cx)), + cx, + )); + if op.skips() { + alert = alert.action(button( + "tree-op-skip", + "tree.op.skip", + ButtonVariant::Secondary, + Box::new(move |tree, cx| tree.op_skip(op, cx)), + cx, + )); + } + Some( + alert + .action(button( + "tree-op-abort", + "tree.op.abort", + ButtonVariant::Danger, + Box::new(move |tree, cx| tree.op_abort(op, cx)), + cx, + )) + .into_any_element(), + ) + } + + pub fn conflict_pane(&self, path: &str, stages: &Stages, cx: &mut Context) -> AnyElement { + let colors = cx.theme().colors.clone(); + let doing = self.active().and_then(|opened| match &opened.doing { + Some(Ok(doing)) => doing.clone(), + _ => None, + }); + let [ours, theirs] = sides(doing.as_ref(), cx); + let (text_view, present) = match stages { + Stages::Text { .. } => (self.merging.as_ref(), [true; 3]), + Stages::Whole { present } => (None, *present), + }; + let mut actions: Vec<(&str, &str, ButtonVariant, Act)> = Vec::new(); + if text_view.is_some() { + actions.push(( + "tree-merge-save", + "tree.conflict.save", + ButtonVariant::Secondary, + Box::new(|tree, cx| tree.save_merge(cx)), + )); + } + actions.push(( + "tree-merge-done", + "tree.conflict.resolved", + ButtonVariant::Primary, + Box::new(|tree, cx| tree.mark_resolved(cx)), + )); + // A side's whole file only where that side has one. + if present[1] { + actions.push(( + "tree-merge-ours", + "tree.conflict.use_ours", + ButtonVariant::Ghost, + Box::new(|tree, cx| tree.take_side(true, cx)), + )); + } + if present[2] { + actions.push(( + "tree-merge-theirs", + "tree.conflict.use_theirs", + ButtonVariant::Ghost, + Box::new(|tree, cx| tree.take_side(false, cx)), + )); + } + if !present[1] || !present[2] { + actions.push(( + "tree-merge-delete", + "tree.conflict.delete", + ButtonVariant::Danger, + Box::new(|tree, cx| tree.resolve_deleted(cx)), + )); + } + let buttons: Vec = actions + .into_iter() + .map(|(id, key, variant, act)| button(id, key, variant, act, cx)) + .collect(); + let head = div() + .flex() + .items_center() + .gap_2() + .px_2() + .py_1p5() + .border_b_1() + .border_color(colors.border) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg) + .child(path.to_string()), + ) + .children(buttons); + let Some(merging) = text_view else { + let key = match present { + [_, false, _] => "tree.conflict.gone_ours", + [_, _, false] => "tree.conflict.gone_theirs", + _ => "tree.conflict.whole", + }; + let body = EmptyState::new( + "tree-conflict-whole", + IconName::TriangleAlert, + text(cx, key, &[]), + ) + .body(format!("{ours} · {theirs}")); + return div() + .size_full() + .flex() + .flex_col() + .child(head) + .child(body) + .into_any_element(); + }; + let base = text(cx, "tree.base", &[]); + let this = cx.weak_entity(); + let merge = ThreeWayMerge::new( + SharedString::from(format!("tree-merge-{path}")), + [ours.as_ref(), base.as_ref(), theirs.as_ref()], + merging.regions.clone(), + ) + .takes(merging.takes.clone()) + .on_take(move |at, take, _, cx| { + this.update(cx, |tree, cx| tree.take(at, take, cx)).ok(); + }); + div() + .size_full() + .flex() + .flex_col() + .child(head) + .child(div().flex_1().min_h_0().p_2().child(merge)) + .child( + div() + .flex_1() + .min_h_0() + .border_t_1() + .border_color(colors.border) + .child(self.merge_editor.clone()), + ) + .into_any_element() + } +} diff --git a/apps/ely-tree/src/detail.rs b/apps/ely-tree/src/detail.rs new file mode 100644 index 0000000..aae14f0 --- /dev/null +++ b/apps/ely-tree/src/detail.rs @@ -0,0 +1,218 @@ +//! One commit: who, when, its message, signature and files (TREE-06). + +use std::{io, path::Path}; + +use crate::{ + diff::Space, + git::Git, + shown::{self, Found}, + status, +}; + +/// A name, an address and a time. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Person { + pub name: String, + pub email: String, + pub at: i64, +} + +/// A file the commit changed, against its first parent. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Touched { + pub path: String, + pub from: Option, + /// Git's letter: `A`, `M`, `D`, `R`, `C`, `T`. + pub status: char, + pub lines: Option<(usize, usize)>, + /// The commit holding it, when not the detail's own. + pub commit: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Detail { + pub id: String, + pub parents: Vec, + pub author: Person, + pub committer: Person, + pub refs: String, + /// `%G?`: `G` good, `N` none, and Git's other letters. + pub signed: char, + pub signer: String, + pub message: String, + pub files: Vec, +} + +fn bad(what: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!("an unread commit {what:?}"), + ) +} + +const FORMAT: &str = + "--format=%H%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%D%x00%G?%x00%GS%x00%B"; + +/// What a commit is compared with: first parent, or nothing. +pub fn base(git: &Git, root: &Path, parents: &[String]) -> io::Result { + match parents.first() { + Some(parent) => Ok(parent.clone()), + None => { + let empty = git.run_input(root, &["hash-object", "-t", "tree", "--stdin"], b"")?; + Ok(String::from_utf8_lossy(&empty).trim().to_string()) + } + } +} + +/// `--name-status -z`: letters, then one path or two. +fn statuses(bytes: &[u8]) -> io::Result, String)>> { + let text = std::str::from_utf8(bytes).map_err(|_| bad("names not UTF-8"))?; + let mut parts = text.split('\0').filter(|part| !part.is_empty()); + let mut out = Vec::new(); + while let Some(letters) = parts.next() { + let letter = letters.chars().next().ok_or_else(|| bad(letters))?; + let first = parts.next().ok_or_else(|| bad(letters))?.to_string(); + match letter { + 'R' | 'C' => out.push(( + letter, + Some(first), + parts.next().ok_or_else(|| bad(letters))?.to_string(), + )), + _ => out.push((letter, None, first)), + } + } + Ok(out) +} + +pub fn read(git: &Git, root: &Path, id: &str) -> io::Result { + let out = git.run(root, &["show", "-s", FORMAT, id, "--"])?; + let text = String::from_utf8(out).map_err(|_| bad("not UTF-8"))?; + let fields: Vec<&str> = text.splitn(12, '\0').collect(); + let [ + id, + parents, + an, + ae, + at, + cn, + ce, + ct, + refs, + signed, + signer, + message, + ] = fields[..] + else { + return Err(bad(&text)); + }; + let time = |at: &str| at.parse::().map_err(|_| bad(at)); + let mut detail = Detail { + id: id.to_string(), + parents: parents + .split(' ') + .filter(|p| !p.is_empty()) + .map(String::from) + .collect(), + author: Person { + name: an.into(), + email: ae.into(), + at: time(at)?, + }, + committer: Person { + name: cn.into(), + email: ce.into(), + at: time(ct)?, + }, + refs: refs.to_string(), + signed: signed.chars().next().unwrap_or('N'), + signer: signer.to_string(), + message: message.trim_end().to_string(), + files: Vec::new(), + }; + let base = base(git, root, &detail.parents)?; + let names = git.run( + root, + &["diff", "--name-status", "-z", "-M", &base, id, "--"], + )?; + let lines = + status::lines(&git.run(root, &["diff", "--numstat", "-z", "-M", &base, id, "--"])?)?; + detail.files = statuses(&names)? + .into_iter() + .map(|(status, from, path)| Touched { + lines: lines.get(&path).copied().flatten(), + commit: None, + path, + from, + status, + }) + .collect(); + Ok(detail) +} + +/// Whether `name`, as `rev:path`, names something. +pub fn exists(git: &Git, root: &Path, name: &str) -> io::Result { + Ok(!git + .run_or_one(root, &["rev-parse", "--verify", "--quiet", name])? + .is_empty()) +} + +/// A file's change in `id`; status from which sides exist. +pub fn change( + git: &Git, + root: &Path, + id: &str, + parents: &[String], + path: &str, + from: Option, +) -> io::Result { + let base = base(git, root, parents)?; + let was = from.as_deref().unwrap_or(path); + let status = match ( + exists(git, root, &format!("{base}:{was}"))?, + exists(git, root, &format!("{id}:{path}"))?, + ) { + (false, _) => 'A', + (_, false) => 'D', + _ if from.is_some() => 'R', + _ => 'M', + }; + file( + git, + root, + id, + parents, + &Touched { + path: path.to_string(), + from, + status, + lines: None, + commit: None, + }, + ) +} + +/// One file's change in a commit, for the middle. +pub fn file( + git: &Git, + root: &Path, + id: &str, + parents: &[String], + touched: &Touched, +) -> io::Result { + let base = base(git, root, parents)?; + let mut args = shown::base(Space::Exact); + args.extend([ + "-M".into(), + base.clone(), + id.to_string(), + "--".into(), + touched.path.clone(), + ]); + args.extend(touched.from.clone()); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let seen = git.run(root, &args)?; + let was = touched.from.as_deref().unwrap_or(&touched.path); + let old = (touched.status != 'A').then(|| format!("{base}:{was}")); + let new = (touched.status != 'D').then(|| Some(format!("{id}:{}", touched.path))); + shown::compare(git, root, seen, &touched.path, old, new) +} diff --git a/apps/ely-tree/src/diff.rs b/apps/ely-tree/src/diff.rs new file mode 100644 index 0000000..935dc2c --- /dev/null +++ b/apps/ely-tree/src/diff.rs @@ -0,0 +1,256 @@ +//! Git's own diff, read and cut into patches (TREE-03, TREE-04). + +use std::{collections::BTreeSet, io}; + +use ely_gpui_component::git::LineKind; + +/// How whitespace counts: exact, `-b`, or `-w`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Space { + #[default] + Exact, + Changes, + All, +} + +impl Space { + pub fn flag(self) -> Option<&'static str> { + match self { + Space::Exact => None, + Space::Changes => Some("--ignore-space-change"), + Space::All => Some("--ignore-all-space"), + } + } +} + +/// One line as Git printed it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Raw { + pub kind: LineKind, + pub text: String, + /// False when Git said "No newline at end of file". + pub newline: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Hunk { + /// Start and count, as in `@@ -s,c +s,c @@`. + pub old: (usize, usize), + pub new: (usize, usize), + pub tail: String, + pub lines: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Body { + Text(Vec), + Binary, + /// Not UTF-8; shown as a file, staged as a file. + Undecodable, +} + +/// One file's diff: its header lines and body. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FileDiff { + pub head: Vec, + pub body: Body, +} + +impl FileDiff { + /// One per `diff --git` header; a type change prints two. + pub fn sections(&self) -> usize { + self.head + .iter() + .filter(|line| line.starts_with("diff --git ")) + .count() + } + + /// Hunks and lines stage only for a plain content change. + pub fn partial(&self) -> bool { + const WHOLE: [&str; 7] = [ + "new file", + "deleted file", + "rename ", + "copy ", + "old mode", + "new mode", + "similarity", + ]; + matches!(self.body, Body::Text(_)) + && !self + .head + .iter() + .any(|line| WHOLE.iter().any(|w| line.starts_with(w))) + } +} + +fn bad(line: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!("an unread diff line {line:?}"), + ) +} + +/// `-s,c` or `+s` as start and count. +fn range(part: &str, sign: char, line: &str) -> io::Result<(usize, usize)> { + let part = part.strip_prefix(sign).ok_or_else(|| bad(line))?; + let (start, count) = part.split_once(',').unwrap_or((part, "1")); + Ok(( + start.parse().map_err(|_| bad(line))?, + count.parse().map_err(|_| bad(line))?, + )) +} + +/// Reads one file's `git diff` output. +pub fn parse(bytes: &[u8]) -> io::Result { + let Ok(text) = std::str::from_utf8(bytes) else { + return Ok(FileDiff { + head: Vec::new(), + body: Body::Undecodable, + }); + }; + let mut head = Vec::new(); + let mut hunks: Vec = Vec::new(); + let mut lines = text.split('\n').peekable(); + // A type change prints one path as two sections. + let mut in_head = true; + while let Some(line) = lines.next() { + if line.is_empty() && lines.peek().is_none() { + break; + } + if let Some(rest) = line.strip_prefix("@@ ") { + let (ranges, tail) = rest.split_once(" @@").ok_or_else(|| bad(line))?; + let (old, new) = ranges.split_once(' ').ok_or_else(|| bad(line))?; + hunks.push(Hunk { + old: range(old, '-', line)?, + new: range(new, '+', line)?, + tail: tail.trim_start().to_string(), + lines: Vec::new(), + }); + in_head = false; + continue; + } + in_head |= line.starts_with("diff --git "); + let Some(hunk) = hunks.last_mut().filter(|_| !in_head) else { + if line.starts_with("Binary files ") { + return Ok(FileDiff { + head, + body: Body::Binary, + }); + } + head.push(line.to_string()); + continue; + }; + let (kind, rest) = match line.split_at_checked(1) { + Some((" ", rest)) => (LineKind::Same, rest), + Some(("+", rest)) => (LineKind::Added, rest), + Some(("-", rest)) => (LineKind::Removed, rest), + Some(("\\", _)) => { + hunk.lines.last_mut().ok_or_else(|| bad(line))?.newline = false; + continue; + } + _ => return Err(bad(line)), + }; + hunk.lines.push(Raw { + kind, + text: rest.to_string(), + newline: true, + }); + } + Ok(FileDiff { + head, + body: Body::Text(hunks), + }) +} + +/// Lines before a range: the start, less one unless empty. +fn before((start, count): (usize, usize)) -> usize { + if count == 0 { start } else { start - 1 } +} + +fn start(before: usize, count: usize) -> usize { + if count == 0 { before } else { before + 1 } +} + +/// Patch of chosen `(hunk, line)`s; `reverse` unstages. +pub fn patch(diff: &FileDiff, chosen: &BTreeSet<(usize, usize)>, reverse: bool) -> Option { + let Body::Text(hunks) = &diff.body else { + return None; + }; + let mut out = String::new(); + // New less old lines in the hunks written so far. + let mut shift: isize = 0; + for (at, hunk) in hunks.iter().enumerate() { + let picked = |line: usize| chosen.contains(&(at, line)); + if !hunk + .lines + .iter() + .enumerate() + .any(|(line, raw)| raw.kind != LineKind::Same && picked(line)) + { + continue; + } + // Kept lines: sign, text, newline. + let mut cut: Vec<(char, &str, bool)> = Vec::new(); + for (line, raw) in hunk.lines.iter().enumerate() { + let sign = match (raw.kind, picked(line), reverse) { + (LineKind::Same, _, _) + | (LineKind::Removed, false, false) + | (LineKind::Added, false, true) => ' ', + (LineKind::Removed, true, _) => '-', + (LineKind::Added, true, _) => '+', + (LineKind::Removed, false, true) | (LineKind::Added, false, false) => continue, + }; + cut.push((sign, &raw.text, raw.newline)); + } + let mut body = String::new(); + let (mut olds, mut news) = (0, 0); + let mut line = |sign: char, text: &str, newline: bool| { + olds += usize::from(sign != '+'); + news += usize::from(sign != '-'); + body.push(sign); + body.push_str(text); + body.push('\n'); + if !newline { + body.push_str("\\ No newline at end of file\n"); + } + }; + for (at, &(sign, text, newline)) in cut.iter().enumerate() { + // A line gains a newline where lines follow it. + let followed = |side: char| { + cut[at + 1..] + .iter() + .any(|(next, ..)| *next == ' ' || *next == side) + }; + let (old, new) = (newline || followed('-'), newline || followed('+')); + match sign { + '-' => line('-', text, old), + '+' => line('+', text, new), + _ if old == new => line(' ', text, old), + _ => { + line('-', text, old); + line('+', text, new); + } + } + } + // The index side keeps Git's numbers; the other follows. + let (old, new) = if reverse { + let fixed = before(hunk.new); + let other = fixed.checked_add_signed(-shift)?; + ((start(other, olds), olds), (start(fixed, news), news)) + } else { + let fixed = before(hunk.old); + let other = fixed.checked_add_signed(shift)?; + ((start(fixed, olds), olds), (start(other, news), news)) + }; + shift += news as isize - olds as isize; + out.push_str(&format!( + "@@ -{},{} +{},{} @@\n{body}", + old.0, old.1, new.0, new.1 + )); + } + if out.is_empty() { + return None; + } + Some(format!("{}\n{out}", diff.head.join("\n"))) +} diff --git a/apps/ely-tree/src/git.rs b/apps/ely-tree/src/git.rs new file mode 100644 index 0000000..a9433a0 --- /dev/null +++ b/apps/ely-tree/src/git.rs @@ -0,0 +1,132 @@ +//! Git as a program: argument arrays, never a shell (§13.1). + +use std::{ + io::{self, Write as _}, + path::{Path, PathBuf}, + process::{Command, Output, Stdio}, +}; + +/// A Git binary and its version. +#[derive(Clone, Debug)] +pub struct Git { + pub path: PathBuf, + pub version: String, + /// Ely's own build, not a system Git. + pub bundled: bool, +} + +impl Git { + /// `ELY_GIT` or the setting picks Git; else Ely's own. + pub fn find() -> io::Result { + let (path, bundled) = ely_platform::git::locate()?; + if !path.is_file() { + let hint = if bundled { + "run scripts/build-git.sh" + } else { + "check ELY_GIT or the Git setting" + }; + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("no Git at {} ({hint})", path.display()), + )); + } + let version = match bundled { + true => bundled_version(&path)?, + false => ely_platform::git::system_version(&path)?, + }; + log::info!( + "tree: {version} at {} ({})", + path.display(), + if bundled { "bundled" } else { "system" } + ); + Ok(Git { + path, + version, + bundled, + }) + } + + /// Runs Git in `dir`; failure carries Git's words. + pub fn run(&self, dir: &Path, args: &[&str]) -> io::Result> { + let output = self.command(dir, args).output()?; + checked(args, output) + } + + /// Runs Git with `input` on its standard input. + pub fn run_input(&self, dir: &Path, args: &[&str], input: &[u8]) -> io::Result> { + let mut child = self + .command(dir, args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| io::Error::other("no stdin"))?; + let input = input.to_vec(); + // Writing apart from reading cannot fill both pipes. + let writer = std::thread::spawn(move || stdin.write_all(&input)); + let output = child.wait_with_output()?; + writer + .join() + .map_err(|_| io::Error::other("the stdin writer panicked"))??; + checked(args, output) + } + + /// Exit 1 is an answer: files differ, key unset. + pub fn run_or_one(&self, dir: &Path, args: &[&str]) -> io::Result> { + let output = self.command(dir, args).output()?; + match output.status.code() { + Some(1) => Ok(output.stdout), + _ => checked(args, output), + } + } + + /// `merge-file` exits with its conflict count; past 127 fails. + pub fn run_counted(&self, dir: &Path, args: &[&str]) -> io::Result> { + let output = self.command(dir, args).output()?; + match output.status.code() { + Some(0..=127) => Ok(output.stdout), + _ => checked(args, output), + } + } + + pub(crate) fn command(&self, dir: &Path, args: &[&str]) -> Command { + ely_platform::git::command(&self.path, dir, args) + } +} + +fn checked(args: &[&str], output: Output) -> io::Result> { + // The verb follows any `-c name=value` pairs. + let verb = args + .iter() + .copied() + .find(|arg| !arg.starts_with('-') && !arg.contains('=')) + .unwrap_or(""); + match output.status.success() { + true => { + log::debug!("tree: git {verb} done"); + Ok(output.stdout) + } + false => { + // Conflicts go to stdout, hooks to stderr. + let said: Vec = [&output.stdout, &output.stderr] + .into_iter() + .map(|bytes| String::from_utf8_lossy(bytes).trim().to_string()) + .filter(|said| !said.is_empty()) + .collect(); + log::warn!("tree: git {verb} failed, {}", output.status); + Err(io::Error::other(format!( + "git {verb} ({}):\n{}", + output.status, + said.join("\n") + ))) + } + } +} + +/// Ely's own Git, whatever version it was pinned at. +fn bundled_version(path: &Path) -> io::Result { + ely_platform::git::version_of(path).map(|(version, _)| version) +} diff --git a/apps/ely-tree/src/hist.rs b/apps/ely-tree/src/hist.rs new file mode 100644 index 0000000..b776392 --- /dev/null +++ b/apps/ely-tree/src/hist.rs @@ -0,0 +1,379 @@ +//! History in the window: pages, search, a picked commit (TREE-06). + +use std::rc::Rc; + +use ely_gpui_component::git::{Commit, GraphRow, lanes}; +use gpui::{AppContext as _, Context, SharedString}; + +use crate::{ + commits::{self, Logged, PAGE, Query}, + detail::{self, Detail}, + shown::{self, View}, + stash, + tree::Tree, +}; + +/// One repository's history as read so far. +#[derive(Default)] +pub struct Hist { + pub logged: Vec, + pub rows: Rc>, + pub graph: Rc>, + pub loading: bool, + /// The last page came back short. + pub done: bool, + /// Read at least once. + pub read: bool, + pub query: Option, + pub error: Option, + /// `show-ref --head` when last read. + refs: Vec, + /// Refs may have moved during a read; check after it. + stale: bool, + serial: u64, + pub pick: Picked, +} + +/// A commit in the middle: its files, one file's diff. +#[derive(Default)] +pub struct Picked { + pub commit: Option, + pub detail: Option>, + pub file: Option<(usize, Option>)>, +} + +/// Whose picked commit: history's or a stash's. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Whose { + History, + Stash, + Reflog, +} + +/// A time in this machine's zone, to the minute. +pub fn when(at: i64) -> SharedString { + match jiff::Timestamp::from_second(at) { + Ok(at) => at + .to_zoned(jiff::tz::TimeZone::system()) + .strftime("%Y-%m-%d %H:%M") + .to_string() + .into(), + Err(error) => { + log::warn!("tree: time {at} is out of range: {error}"); + at.to_string().into() + } + } +} + +/// The rows a commit list draws. +pub fn rows(logged: &[Logged]) -> Vec { + logged + .iter() + .map(|commit| Commit { + id: commit.id.clone().into(), + parents: commit.parents.iter().map(|id| id.clone().into()).collect(), + subject: commit.subject.clone().into(), + author: commit.author.clone().into(), + when: when(commit.at), + refs: commit.refs.iter().map(|name| name.clone().into()).collect(), + }) + .collect() +} + +fn graph(logged: &[Logged]) -> Vec { + let parents: Vec> = logged + .iter() + .map(|commit| commit.parents.iter().map(String::as_str).collect()) + .collect(); + lanes( + logged + .iter() + .zip(&parents) + .map(|(commit, parents)| (commit.id.as_str(), parents.as_slice())), + ) +} + +impl Tree { + pub fn set_side(&mut self, side: crate::tree::Side, cx: &mut Context) { + self.side = side; + self.ensure_side(cx); + cx.notify(); + } + + /// A panel's lists are read once it first shows. + pub fn ensure_side(&mut self, cx: &mut Context) { + let Some(id) = self.active else { return }; + let Some(opened) = self.opened(id) else { + return; + }; + match self.side { + crate::tree::Side::History if !opened.hist.read && !opened.hist.loading => { + self.load_log(id, true, cx) + } + crate::tree::Side::Branches if opened.refs.is_none() => self.read_refs(id, cx), + crate::tree::Side::Places if opened.places.read.is_none() => self.read_places(id, cx), + _ => {} + } + } + + /// The next page, once the list nears its end. + pub fn more_log(&mut self, cx: &mut Context) { + if let Some(id) = self.active { + self.load_log(id, false, cx); + } + } + + fn hist_mut(&mut self, id: u64) -> Option<&mut Hist> { + self.opened_mut(id).map(|opened| &mut opened.hist) + } + + /// Reads history from the top, or the next page. + pub fn load_log(&mut self, id: u64, fresh: bool, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(root) = self.opened(id).map(|opened| opened.repo.root.clone()) else { + return; + }; + let Some(hist) = self.hist_mut(id) else { + return; + }; + if !fresh && (hist.loading || hist.done) { + return; + } + hist.serial += 1; + hist.loading = true; + let (serial, skip, query) = ( + hist.serial, + if fresh { 0 } else { hist.logged.len() }, + hist.query.clone(), + ); + let task = cx.background_spawn(async move { + let refs = commits::refs_now(&git, &root)?; + Ok::<_, std::io::Error>((refs, commits::read(&git, &root, skip, query.as_ref())?)) + }); + cx.spawn(async move |this, cx| { + let read = task.await; + this.update(cx, |tree, cx| tree.log_read(id, serial, fresh, read, cx)) + .ok(); + }) + .detach(); + } + + fn log_read( + &mut self, + id: u64, + serial: u64, + fresh: bool, + read: std::io::Result<(Vec, Vec)>, + cx: &mut Context, + ) { + let Some(hist) = self.hist_mut(id).filter(|hist| hist.serial == serial) else { + return; + }; + hist.loading = false; + hist.read = true; + match read { + Ok((refs, page)) => { + if fresh { + hist.logged.clear(); + } + hist.done = page.len() < PAGE; + hist.logged.extend(page); + hist.refs = refs; + hist.error = None; + hist.rows = Rc::new(rows(&hist.logged)); + hist.graph = Rc::new(if hist.query.is_none() { + graph(&hist.logged) + } else { + Vec::new() + }); + log::info!("tree: history shows {} commits, searched {}", hist.logged.len(), hist.query.is_some()); + } + Err(error) => { + log::warn!("tree: reading history failed: {error}"); + hist.error = Some(error.to_string()); + } + } + if std::mem::take(&mut hist.stale) { + self.refresh_log(id, cx); + } + cx.notify(); + } + + /// After a status read: reread history if refs moved. + pub fn refresh_log(&mut self, id: u64, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(hist) = self.hist_mut(id) else { + return; + }; + if hist.loading { + hist.stale = true; + return; + } + if !hist.read { + return; + } + let was = hist.refs.clone(); + let Some(root) = self.opened(id).map(|opened| opened.repo.root.clone()) else { + return; + }; + let task = cx.background_spawn(async move { commits::refs_now(&git, &root) }); + cx.spawn(async move |this, cx| { + let now = task.await; + this.update(cx, |tree, cx| match now { + Ok(now) if now != was => tree.load_log(id, true, cx), + // A shown error goes once a read succeeds again. + Ok(_) + if tree + .opened(id) + .is_some_and(|opened| opened.hist.error.is_some()) => + { + tree.load_log(id, true, cx) + } + Ok(_) => {} + // Shown in the panel, in Git's words. + Err(error) => { + log::warn!("tree: reading refs failed: {error}"); + if let Some(hist) = tree.hist_mut(id) { + hist.error = Some(error.to_string()); + cx.notify(); + } + } + }) + .ok(); + }) + .detach(); + } + + /// Runs the search field's query; empty shows all history. + pub fn search(&mut self, cx: &mut Context) { + let Some(id) = self.active else { return }; + let text = self.search.read(cx).text().trim().to_string(); + let query = (!text.is_empty()).then(|| Query { + find: self.find, + text, + }); + let Some(hist) = self.hist_mut(id) else { + return; + }; + hist.query = query; + self.load_log(id, true, cx); + } + + fn picked_mut(&mut self, id: u64, whose: Whose) -> Option<&mut Picked> { + let opened = self.opened_mut(id)?; + Some(match whose { + Whose::History => &mut opened.hist.pick, + Whose::Stash | Whose::Reflog => &mut opened.places.pick, + }) + } + + /// Shows a commit's details in the middle. + pub fn pick_commit(&mut self, commit: &str, cx: &mut Context) { + self.pick_in(Whose::History, commit, cx); + } + + /// Reads a commit, or a stash with its untracked files. + pub fn pick_in(&mut self, whose: Whose, commit: &str, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(id) = self.active else { return }; + let Some(root) = self.opened(id).map(|opened| opened.repo.root.clone()) else { + return; + }; + let Some(picked) = self.picked_mut(id, whose) else { + return; + }; + *picked = Picked { + commit: Some(commit.to_string()), + ..Picked::default() + }; + let commit = commit.to_string(); + let task = cx.background_spawn({ + let commit = commit.clone(); + async move { + match whose { + Whose::History | Whose::Reflog => detail::read(&git, &root, &commit), + Whose::Stash => stash::detail(&git, &root, &commit), + } + } + }); + cx.spawn(async move |this, cx| { + let read = task.await.map_err(|error| error.to_string()); + this.update(cx, |tree, cx| { + let Some(picked) = tree + .picked_mut(id, whose) + .filter(|picked| picked.commit.as_deref() == Some(commit.as_str())) + else { + return; + }; + picked.detail = Some(read); + cx.notify(); + }) + .ok(); + }) + .detach(); + cx.notify(); + } + + /// Shows one file of the picked commit. + pub fn pick_touched(&mut self, whose: Whose, at: usize, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(id) = self.active else { return }; + let Some(root) = self.opened(id).map(|opened| opened.repo.root.clone()) else { + return; + }; + let Some(picked) = self.picked_mut(id, whose) else { + return; + }; + let Some(Ok(detail)) = &picked.detail else { + return; + }; + let Some(touched) = detail.files.get(at).cloned() else { + return; + }; + // A stash's untracked files sit in their own commit. + let (holder, parents) = match &touched.commit { + Some(holder) => (holder.clone(), Vec::new()), + None => (detail.id.clone(), detail.parents.clone()), + }; + let commit = detail.id.clone(); + picked.file = Some((at, None)); + let task = + cx.background_spawn( + async move { detail::file(&git, &root, &holder, &parents, &touched) }, + ); + cx.spawn(async move |this, cx| { + let found = task.await; + this.update(cx, |tree, cx| { + // Only for the file and commit still shown. + let Some(picked) = tree + .picked_mut(id, whose) + .filter(|picked| picked.commit.as_deref() == Some(commit.as_str())) + else { + return; + }; + let Some((now, view)) = &mut picked.file else { + return; + }; + if *now == at { + *view = Some( + found + .and_then(shown::view) + .map_err(|error| error.to_string()), + ); + cx.notify(); + } + }) + .ok(); + }) + .detach(); + cx.notify(); + } +} diff --git a/apps/ely-tree/src/hist_pane.rs b/apps/ely-tree/src/hist_pane.rs new file mode 100644 index 0000000..3feaf44 --- /dev/null +++ b/apps/ely-tree/src/hist_pane.rs @@ -0,0 +1,322 @@ +//! The middle in History: the picked commit (TREE-06). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, IconButton}, + data_display::DescriptionList, + feedback::{EmptyState, ErrorView}, + lists::{List, ListItem}, + primitives::IconName, + theme::{ActiveTheme as _, ControlSize}, +}; +use gpui::{ + AnyElement, Context, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, + Role, SharedString, StatefulInteractiveElement as _, Styled as _, div, + prelude::FluentBuilder as _, px, +}; + +use crate::{ + detail::{Detail, Person}, + hist::{Hist, Picked, Whose, when}, + tree::{Tree, text}, +}; + +/// `%G?` in words. +fn signature(letter: char) -> &'static str { + match letter { + 'G' => "tree.sig.good", + 'B' => "tree.sig.bad", + 'U' => "tree.sig.untrusted", + 'X' => "tree.sig.expired", + 'Y' => "tree.sig.expired_key", + 'R' => "tree.sig.revoked", + 'E' => "tree.sig.unchecked", + _ => "tree.sig.none", + } +} + +fn person(who: &Person) -> SharedString { + format!("{} <{}>, {}", who.name, who.email, when(who.at)).into() +} + +impl Tree { + /// A file's history and blame, as two buttons. + pub fn look_buttons(&self, path: &str, cx: &mut Context) -> [IconButton; 2] { + let (log, blame, at, blamed) = ( + cx.weak_entity(), + cx.weak_entity(), + path.to_string(), + path.to_string(), + ); + [ + IconButton::new("tree-file-log", IconName::History) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(text(cx, "tree.file_log", &[])) + .on_click(move |_, _, cx| { + log.update(cx, |tree, cx| tree.file_log(&at, cx)).ok(); + }), + IconButton::new("tree-blame", IconName::User) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(text(cx, "tree.blame", &[])) + .on_click(move |_, _, cx| { + blame.update(cx, |tree, cx| tree.blame(&blamed, cx)).ok(); + }), + ] + } + + /// A committed file's path, its history and its blame. + fn file_head(&self, path: &str, cx: &mut Context) -> AnyElement { + let colors = cx.theme().colors.clone(); + div() + .flex() + .items_center() + .gap_2() + .px_2() + .py_1p5() + .border_b_1() + .border_color(colors.border) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg) + .child(path.to_string()), + ) + .children(self.look_buttons(path, cx)) + .into_any_element() + } + + pub fn commit_pane(&self, hist: &Hist, cx: &mut Context) -> AnyElement { + self.picked_pane(&hist.pick, Whose::History, cx) + } + + /// A history commit or a stash, as read so far. + pub fn picked_pane(&self, picked: &Picked, whose: Whose, cx: &mut Context) -> AnyElement { + match (&picked.commit, &picked.detail) { + (None, _) => EmptyState::new( + "tree-commit-none", + IconName::GitCommitHorizontal, + text(cx, "tree.commit.pick", &[]), + ) + .into_any_element(), + (Some(_), None) => div().into_any_element(), + (Some(_), Some(Err(error))) => ErrorView::new( + "tree-commit-error", + IconName::TriangleAlert, + text(cx, "tree.commit.unread", &[]), + ) + .detail(error.clone()) + .into_any_element(), + (Some(_), Some(Ok(detail))) => { + let head = match whose { + Whose::History => self.commit_head(detail, cx), + Whose::Stash => self.stash_head(detail, cx), + Whose::Reflog => self.reflog_head(detail, cx), + }; + self.commit_detail(detail, picked, whose, head, cx) + } + } + } + + fn commit_head(&self, detail: &Detail, cx: &mut Context) -> AnyElement { + let colors = cx.theme().colors.clone(); + let (subject, body) = detail + .message + .split_once('\n') + .unwrap_or((&detail.message, "")); + let label = |key: &str| text(cx, key, &[]); + let mut rows = + DescriptionList::new().text(label("tree.commit.author"), person(&detail.author)); + if (&detail.committer.name, &detail.committer.email) + != (&detail.author.name, &detail.author.email) + { + rows = rows.text(label("tree.commit.committer"), person(&detail.committer)); + } + let parents = detail.parents.iter().map(|parent| { + let (this, id) = (cx.weak_entity(), parent.clone()); + Button::new( + SharedString::from(format!("tree-parent-{parent}")), + parent[..parent.len().min(7)].to_string(), + ) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + this.update(cx, |tree, cx| tree.pick_commit(&id, cx)).ok(); + }) + }); + let signed = match detail.signer.as_str() { + "" => label(signature(detail.signed)), + signer => format!("{}, {signer}", label(signature(detail.signed))).into(), + }; + rows = rows + .text(label("tree.commit.id"), detail.id.clone()) + .item(label("tree.commit.parents"), div().flex().children(parents)) + .text(label("tree.commit.signature"), signed); + if !detail.refs.is_empty() { + rows = rows.text(label("tree.commit.refs"), detail.refs.clone()); + } + div() + .id("tree-commit-head") + .flex_none() + .max_h(px(280.0)) + .overflow_y_scroll() + .flex() + .flex_col() + .gap_2() + .p_3() + .border_b_1() + .border_color(colors.border) + .child( + div() + .id("tree-commit-subject") + .role(Role::Heading) + .aria_label(SharedString::from(subject.to_string())) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(subject.to_string()), + ) + .when(!body.trim().is_empty(), |head| { + let body = SharedString::from(body.trim().to_string()); + head.child( + div() + .id("tree-commit-body") + .role(Role::Label) + .aria_label(body.clone()) + .text_color(colors.fg_muted) + .child(body), + ) + }) + .child(rows) + .child(self.commit_actions(detail, cx)) + .into_any_element() + } + + /// Pick, revert, or branch or tag at this commit. + fn commit_actions(&self, detail: &Detail, cx: &mut Context) -> AnyElement { + let (id, parents) = (detail.id.clone(), detail.parents.len()); + let acts: [( + &str, + Box)>, + ); 4] = [ + ("tree.commit.pick_it", { + let id = id.clone(); + Box::new(move |tree, _, cx| tree.cherry_pick(&id, parents, cx)) + }), + ("tree.commit.revert_it", { + let id = id.clone(); + Box::new(move |tree, _, cx| tree.revert(&id, parents, cx)) + }), + ("tree.ref.branch_here", { + let id = id.clone(); + Box::new(move |tree, window, cx| tree.ask_branch(Some(id.clone()), window, cx)) + }), + ("tree.ref.tag_here", { + let id = id.clone(); + Box::new(move |tree, window, cx| tree.ask_tag(Some(id.clone()), window, cx)) + }), + ]; + div() + .flex() + .flex_wrap() + .gap_2() + .children(acts.into_iter().map(|(key, act)| { + let this = cx.weak_entity(); + Button::new( + SharedString::from(format!("{key}-button")), + text(cx, key, &[]), + ) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + this.update(cx, |tree, cx| act(tree, window, cx)).ok(); + }) + })) + .into_any_element() + } + + fn commit_detail( + &self, + detail: &Detail, + pick: &Picked, + whose: Whose, + head: AnyElement, + cx: &mut Context, + ) -> AnyElement { + let colors = cx.theme().colors.clone(); + let picked = pick.file.as_ref().map(|(at, _)| *at); + let weak = cx.weak_entity(); + let files: Vec = detail + .files + .iter() + .enumerate() + .map(|(at, file)| { + let this = weak.clone(); + let title = match &file.from { + Some(from) => format!("{from} → {}", file.path), + None => file.path.clone(), + }; + let about = match file.lines { + Some((added, removed)) => format!("{} · +{added} −{removed}", file.status), + None => file.status.to_string(), + }; + ListItem::new(SharedString::from(format!("tree-touched-{at}")), title) + .description(about) + .selected(picked == Some(at)) + .on_click(move |_, _, cx| { + this.update(cx, |tree, cx| tree.pick_touched(whose, at, cx)) + .ok(); + }) + .into_any_element() + }) + .collect(); + let shown = match &pick.file { + Some((at, view)) => { + let path = &detail.files[*at].path; + let diff = self.view_only( + SharedString::from(format!("tree-commit-diff-{}", detail.id)).into(), + path, + view.as_ref(), + cx, + ); + div() + .size_full() + .flex() + .flex_col() + .child(self.file_head(path, cx)) + .child(div().flex_1().min_h_0().child(diff)) + .into_any_element() + } + None => EmptyState::new( + "tree-file-none", + IconName::FileText, + text(cx, "tree.commit.pick_file", &[]), + ) + .into_any_element(), + }; + div() + .size_full() + .flex() + .flex_col() + .child(head) + .child( + div() + .flex_1() + .min_h_0() + .flex() + .child( + div() + .id("tree-touched") + .flex_none() + .w_72() + .h_full() + .overflow_y_scroll() + .p_2() + .border_r_1() + .border_color(colors.border) + .child(List::new().children(files)), + ) + .child(div().flex_1().min_w_0().h_full().child(shown)), + ) + .into_any_element() + } +} diff --git a/apps/ely-tree/src/look.rs b/apps/ely-tree/src/look.rs new file mode 100644 index 0000000..703e55d --- /dev/null +++ b/apps/ely-tree/src/look.rs @@ -0,0 +1,212 @@ +//! A file's history or blame, over the middle (TREE-06). + +use std::rc::Rc; + +use ely_gpui_component::git::Commit; +use gpui::{AppContext as _, Context}; + +use crate::{ + blame::{self, Blamed}, + commits::{self, Touch}, + hist, + shown::{self, View}, + tree::Tree, +}; + +pub enum Look { + FileLog { + repo: u64, + /// Unique per opening, so late reads are dropped. + serial: u64, + path: String, + touches: Option, String>>, + rows: Rc)>>, + picked: Option, + view: Option>, + }, + Blame { + repo: u64, + serial: u64, + path: String, + read: Option>, + }, +} + +impl Look { + pub fn repo(&self) -> u64 { + match self { + Look::FileLog { repo, .. } | Look::Blame { repo, .. } => *repo, + } + } +} + +impl Tree { + /// Opens `path`'s commits, newest first, through renames. + pub fn file_log(&mut self, path: &str, cx: &mut Context) { + let Some(git) = self.git(cx) else { return }; + let Some((repo, root)) = self + .active() + .map(|opened| (opened.id, opened.repo.root.clone())) + else { + return; + }; + let path = path.to_string(); + log::info!("tree: file history of {path}"); + self.looks += 1; + let serial = self.looks; + self.look = Some(Look::FileLog { + repo, + serial, + path: path.clone(), + touches: None, + rows: Rc::default(), + picked: None, + view: None, + }); + let task = cx.background_spawn({ + let path = path.clone(); + async move { commits::file(&git, &root, &path) } + }); + cx.spawn(async move |this, cx| { + let read = task.await.map_err(|error| error.to_string()); + this.update(cx, |tree, cx| { + let Some(Look::FileLog { + serial: now, + touches, + rows, + .. + }) = &mut tree.look + else { + return; + }; + if *now != serial { + return; + } + if let Ok(read) = &read { + let logged: Vec<_> = read.iter().map(|touch| touch.commit.clone()).collect(); + *rows = Rc::new( + hist::rows(&logged) + .into_iter() + .zip(read.iter().map(|touch| touch.lines)) + .collect(), + ); + } + *touches = Some(read); + cx.notify(); + }) + .ok(); + }) + .detach(); + cx.notify(); + } + + /// Shows the file's change in one of its commits. + pub fn pick_touch(&mut self, at: usize, cx: &mut Context) { + let Some(git) = self.git(cx) else { return }; + let Some(Look::FileLog { + repo, + serial, + touches: Some(Ok(touches)), + picked, + view, + .. + }) = &mut self.look + else { + return; + }; + let Some(touch) = touches.get(at).cloned() else { + return; + }; + (*picked, *view) = (Some(at), None); + let (repo, serial) = (*repo, *serial); + let Some(root) = self.opened(repo).map(|opened| opened.repo.root.clone()) else { + return; + }; + let task = cx.background_spawn(async move { + let commit = &touch.commit; + crate::detail::change( + &git, + &root, + &commit.id, + &commit.parents, + &touch.path, + touch.from.clone(), + ) + }); + cx.spawn(async move |this, cx| { + let found = task.await; + this.update(cx, |tree, cx| { + let Some(Look::FileLog { + serial: now, + picked: Some(was), + view, + .. + }) = &mut tree.look + else { + return; + }; + if *now == serial && *was == at { + *view = Some( + found + .and_then(shown::view) + .map_err(|error| error.to_string()), + ); + cx.notify(); + } + }) + .ok(); + }) + .detach(); + cx.notify(); + } + + /// Who last changed each line of `path`. + pub fn blame(&mut self, path: &str, cx: &mut Context) { + let Some(git) = self.git(cx) else { return }; + let Some((repo, root)) = self + .active() + .map(|opened| (opened.id, opened.repo.root.clone())) + else { + return; + }; + let path = path.to_string(); + self.looks += 1; + let serial = self.looks; + self.look = Some(Look::Blame { + repo, + serial, + path: path.clone(), + read: None, + }); + let task = cx.background_spawn({ + let path = path.clone(); + async move { blame::read(&git, &root, &path) } + }); + cx.spawn(async move |this, cx| { + let read = task.await.map_err(|error| error.to_string()); + this.update(cx, |tree, cx| { + let Some(Look::Blame { + serial: now, + read: slot, + .. + }) = &mut tree.look + else { + return; + }; + if *now == serial { + *slot = Some(read); + cx.notify(); + } + }) + .ok(); + }) + .detach(); + cx.notify(); + } + + /// Closes a file's history or blame. + pub fn close_look(&mut self, cx: &mut Context) { + self.look = None; + cx.notify(); + } +} diff --git a/apps/ely-tree/src/look_pane.rs b/apps/ely-tree/src/look_pane.rs new file mode 100644 index 0000000..4aee5d9 --- /dev/null +++ b/apps/ely-tree/src/look_pane.rs @@ -0,0 +1,195 @@ +//! The middle for a file's history or blame (TREE-06). + +use ely_gpui_component::{ + buttons::{ButtonVariant, IconButton}, + feedback::{EmptyState, ErrorView}, + git::{Blame, BlameView, FileHistory}, + primitives::IconName, + theme::{ActiveTheme as _, ControlSize}, +}; +use gpui::{ + AnyElement, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div, + prelude::FluentBuilder as _, +}; + +use crate::{ + blame::Blamed, + hist::when, + look::Look, + tree::{Side, Tree, text}, +}; + +/// Blame's rows: each owner's words and its age, newest 0. +fn blames(blamed: &Blamed, uncommitted: &SharedString) -> Vec { + let times = blamed + .owners + .iter() + .filter(|owner| !owner.uncommitted()) + .map(|owner| owner.at); + let (oldest, newest) = (times.clone().min().unwrap_or(0), times.max().unwrap_or(0)); + let span = (newest - oldest).max(1) as f32; + blamed + .owners + .iter() + .map(|owner| match owner.uncommitted() { + true => Blame { + commit: owner.commit.clone().into(), + author: uncommitted.clone(), + when: SharedString::default(), + subject: SharedString::default(), + age: 0.0, + }, + false => Blame { + commit: owner.commit.clone().into(), + author: owner.author.clone().into(), + when: when(owner.at), + subject: owner.subject.clone().into(), + age: (newest - owner.at) as f32 / span, + }, + }) + .collect() +} + +impl Tree { + /// Shows a commit from blame in History. + fn show_commit(&mut self, commit: &str, cx: &mut Context) { + self.look = None; + self.set_side(Side::History, cx); + self.pick_commit(commit, cx); + } + + pub fn look_pane(&self, look: &Look, cx: &mut Context) -> AnyElement { + let colors = cx.theme().colors.clone(); + let (key, path) = match look { + Look::FileLog { path, .. } => ("tree.file_log.of", path), + Look::Blame { path, .. } => ("tree.blame.of", path), + }; + let this = cx.weak_entity(); + let head = div() + .flex() + .items_center() + .gap_2() + .px_2() + .py_1p5() + .border_b_1() + .border_color(colors.border) + .child(div().flex_1().min_w_0().text_color(colors.fg).child(text( + cx, + key, + &[("path", path)], + ))) + .child( + IconButton::new("tree-look-close", IconName::X) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(text(cx, "tree.close_look", &[])) + .on_click(move |_, _, cx| { + this.update(cx, |tree, cx| tree.close_look(cx)).ok(); + }), + ); + let body = match look { + Look::FileLog { touches: None, .. } | Look::Blame { read: None, .. } => { + div().into_any_element() + } + Look::FileLog { + touches: Some(Err(error)), + .. + } + | Look::Blame { + read: Some(Err(error)), + .. + } => ErrorView::new( + "tree-look-error", + IconName::TriangleAlert, + text(cx, "tree.look.unread", &[]), + ) + .detail(error.clone()) + .into_any_element(), + Look::FileLog { + touches: Some(Ok(touches)), + rows, + picked, + view, + repo, + .. + } => { + let this = cx.weak_entity(); + let list = FileHistory::new(("tree-file-log", *repo), rows.clone()) + .when_some( + picked.map(|at| touches[at].commit.id.clone()), + |list, id| list.selected(id), + ) + .on_pick({ + let ids: Vec = touches + .iter() + .map(|touch| touch.commit.id.clone()) + .collect(); + move |id, _, cx| { + if let Some(at) = ids.iter().position(|known| known == id.as_ref()) { + this.update(cx, |tree, cx| tree.pick_touch(at, cx)).ok(); + } + } + }); + let shown = match picked { + Some(at) => { + let id = SharedString::from(format!( + "tree-file-log-diff-{}", + touches[*at].commit.id + )); + self.view_only(id.into(), &touches[*at].path, view.as_ref(), cx) + } + None => EmptyState::new( + "tree-file-log-none", + IconName::History, + text(cx, "tree.commit.pick", &[]), + ) + .into_any_element(), + }; + div() + .size_full() + .flex() + .child( + div() + .flex_none() + .w_96() + .h_full() + .p_2() + .border_r_1() + .border_color(colors.border) + .child(list), + ) + .child(div().flex_1().min_w_0().h_full().child(shown)) + .into_any_element() + } + Look::Blame { + read: Some(Ok(blamed)), + repo, + .. + } => { + let uncommitted = text(cx, "tree.blame.uncommitted", &[]); + let this = cx.weak_entity(); + BlameView::new( + ("tree-blame", *repo), + blamed.code.clone(), + blamed.lines.clone(), + blames(blamed, &uncommitted), + ) + .on_commit(move |commit, _, cx| { + // Lines not yet committed have no commit to show. + if !commit.bytes().all(|b| b == b'0') { + this.update(cx, |tree, cx| tree.show_commit(commit, cx)) + .ok(); + } + }) + .into_any_element() + } + }; + div() + .size_full() + .flex() + .flex_col() + .child(head) + .child(div().flex_1().min_h_0().child(body)) + .into_any_element() + } +} diff --git a/apps/ely-tree/src/main.rs b/apps/ely-tree/src/main.rs new file mode 100644 index 0000000..1332d32 --- /dev/null +++ b/apps/ely-tree/src/main.rs @@ -0,0 +1,53 @@ +mod asks; +mod asks_places; +mod blame; +mod branches; +mod changes; +mod cloning; +mod commit; +mod commits; +mod committing; +mod conflict; +mod conflict_pane; +mod detail; +mod diff; +mod git; +mod hist; +mod hist_pane; +mod look; +mod look_pane; +mod menu; +mod moves; +mod network; +mod ops; +mod pane; +mod pick; +mod places; +mod places_pane; +mod recover; +mod recovering; +mod refs_pane; +mod remotes; +mod repo; +mod running; +mod shown; +mod side_log; +mod side_places; +mod side_refs; +mod stash; +mod status; +mod structure; +mod tools; +mod tree; +mod view; +mod watch; +mod work; + +#[cfg(test)] +mod tests; + +use ely_platform::AppId; + +fn main() { + ely_ui::run(AppId::Tree, tree::open); +} diff --git a/apps/ely-tree/src/menu.rs b/apps/ely-tree/src/menu.rs new file mode 100644 index 0000000..59c532d --- /dev/null +++ b/apps/ely-tree/src/menu.rs @@ -0,0 +1,97 @@ +//! The Repository menu, and File's New and Open as repositories. + +use ely_ui::commands; +use std::path::PathBuf; + +use gpui::{App, Context, KeyBinding, Menu, MenuItem, PathPromptOptions, Window, actions}; + +use crate::tree::{Tree, text}; + +actions!( + tree, + [ + CloneRepository, + CloseRepository, + Fetch, + Pull, + Push, + ForcePush, + OpenTerminal, + Reveal, + CleanUntracked, + ChooseEditor + ] +); + +pub const CONTEXT: &str = "tree"; + +pub fn bind(cx: &mut App) { + cx.bind_keys([KeyBinding::new( + "secondary-w", + CloseRepository, + Some(CONTEXT), + )]); +} + +pub fn menus(cx: &App) -> Vec { + vec![Menu::new(text(cx, "tree.menu.repository", &[])).items([ + MenuItem::action(text(cx, "tree.clone", &[]), CloneRepository), + MenuItem::action(text(cx, "tree.close", &[]), CloseRepository), + MenuItem::separator(), + MenuItem::action(text(cx, "tree.net.fetch", &[]), Fetch), + MenuItem::action(text(cx, "tree.net.pull", &[]), Pull), + MenuItem::action(text(cx, "tree.net.push", &[]), Push), + MenuItem::action(text(cx, "tree.force.open", &[]), ForcePush), + MenuItem::separator(), + MenuItem::action(text(cx, "tree.tool.terminal", &[]), OpenTerminal), + MenuItem::action(text(cx, "tree.tool.reveal", &[]), Reveal), + MenuItem::action(text(cx, "tree.clean.open", &[]), CleanUntracked), + MenuItem::action(text(cx, "tree.tool.editor", &[]), ChooseEditor), + ])] +} + +impl Tree { + /// Asks for folders; `then` gets each one picked. + fn pick( + &mut self, + multiple: bool, + then: fn(&mut Tree, PathBuf, &mut Context), + cx: &mut Context, + ) { + let picked = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple, + prompt: None, + }); + cx.spawn(async move |this, cx| { + let picked = picked + .await + .map_err(|error| error.to_string()) + .and_then(|picked| picked.map_err(|error| error.to_string())); + this.update(cx, |tree, cx| match picked { + Ok(Some(paths)) => paths.into_iter().for_each(|path| then(tree, path, cx)), + Ok(None) => log::info!("tree: the folder panel was cancelled"), + Err(error) => tree.failed("tree.error.panel", &error, cx), + }) + .ok(); + }) + .detach(); + } + + pub fn on_open(&mut self, _: &commands::Open, _: &mut Window, cx: &mut Context) { + self.pick(true, Tree::open_path, cx); + } + + pub fn on_new(&mut self, _: &commands::New, _: &mut Window, cx: &mut Context) { + self.pick(false, Tree::init_at, cx); + } + + pub fn on_clone(&mut self, _: &CloneRepository, window: &mut Window, cx: &mut Context) { + self.ask_url(window, cx); + } + + pub fn on_close(&mut self, _: &CloseRepository, _: &mut Window, cx: &mut Context) { + self.close_active(cx); + } +} diff --git a/apps/ely-tree/src/moves.rs b/apps/ely-tree/src/moves.rs new file mode 100644 index 0000000..905a1e7 --- /dev/null +++ b/apps/ely-tree/src/moves.rs @@ -0,0 +1,320 @@ +//! Branches, tags, merge, rebase, pick and revert (TREE-07, TREE-08). + +use ely_gpui_component::{forms::TextInput, primitives::Severity}; +use gpui::{AppContext as _, Context, SharedString, Window}; + +use crate::{ + asks::Ask, + branches::{self, Kind}, + ops::{self, Op}, + tree::{Tree, text}, + work::Write, +}; + +fn args(words: &[&str]) -> Vec { + words.iter().map(|word| word.to_string()).collect() +} + +/// `-m 1`: a merge commit goes against its first parent. +fn mainline(parents: usize) -> Vec { + if parents > 1 { + args(&["-m", "1"]) + } else { + Vec::new() + } +} + +impl Tree { + /// Rereads branches and tags; only the latest read lands. + pub fn read_refs(&mut self, id: u64, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(opened) = self.opened_mut(id) else { + return; + }; + opened.refs_asks += 1; + let (root, ask) = (opened.repo.root.clone(), opened.refs_asks); + let task = cx.background_spawn(async move { branches::read(&git, &root) }); + cx.spawn(async move |this, cx| { + let read = task.await.map_err(|error| error.to_string()); + this.update(cx, |tree, cx| { + if let Some(opened) = tree.opened_mut(id).filter(|opened| opened.refs_asks == ask) { + opened.refs = Some(read); + cx.notify(); + } + }) + .ok(); + }) + .detach(); + } + + /// Rereads what operation is under way. + pub fn read_doing(&mut self, id: u64, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(opened) = self.opened_mut(id) else { + return; + }; + opened.doing_asks += 1; + let (root, dir, ask) = ( + opened.repo.root.clone(), + opened.repo.git_dir.clone(), + opened.doing_asks, + ); + let task = cx.background_spawn(async move { ops::doing(&git, &root, &dir) }); + cx.spawn(async move |this, cx| { + let read = task.await.map_err(|error| error.to_string()); + this.update(cx, |tree, cx| { + if let Some(opened) = tree + .opened_mut(id) + .filter(|opened| opened.doing_asks == ask) + { + opened.doing = Some(read); + cx.notify(); + } + }) + .ok(); + }) + .detach(); + } + + /// A failure: a conflict stop says so, else Git's words. + pub fn git_failed(&mut self, stopped: bool, error: String, cx: &mut Context) { + if stopped { + let words = text(cx, "tree.op.stopped", &[]); + return self.toast(words, Severity::Warning, cx); + } + self.ask = Some(Ask::Failed { + title: text(cx, "tree.op.failed", &[]), + detail: error.into(), + }); + cx.notify(); + } + + pub(crate) fn run_git(&mut self, words: Vec, cx: &mut Context) { + let Some(id) = self.active else { return }; + log::info!("tree: git {}", words.first().map_or("", String::as_str)); + self.write(id, Write::Git(words), cx); + } + + /// Asks before a command; each yes counts once. + pub(crate) fn confirm( + &mut self, + key: &str, + name: &str, + words: Vec, + cx: &mut Context, + ) { + let Some(repo) = self.active else { return }; + self.ask = Some(Ask::Confirm { + repo, + title: text(cx, &format!("{key}.title"), &[("name", name)]), + message: text(cx, &format!("{key}.message"), &[("name", name)]), + go: text(cx, &format!("{key}.go"), &[]), + write: Write::Git(words), + }); + cx.notify(); + } + + pub fn switch(&mut self, name: &str, kind: Kind, cx: &mut Context) { + match kind { + // Track by full ref, never a same-named tag. + Kind::Remote => self.run_git( + args(&["switch", "--track", &format!("refs/remotes/{name}")]), + cx, + ), + _ => self.run_git(args(&["switch", name]), cx), + } + } + + /// Asks which remote branch `branch` tracks. + pub fn ask_upstream(&mut self, branch: &str, cx: &mut Context) { + let choices: Vec = match self.active().and_then(|opened| opened.refs.as_ref()) { + Some(Ok(refs)) => refs + .iter() + .filter(|found| found.kind == Kind::Remote) + .map(|found| found.name.clone()) + .collect(), + _ => Vec::new(), + }; + if choices.is_empty() { + let words = text(cx, "tree.upstream.none", &[]); + return self.toast(words, Severity::Warning, cx); + } + let Some(repo) = self.active else { return }; + self.ask = Some(Ask::Choose { + repo, + title: text(cx, "tree.upstream.title", &[("branch", branch)]), + choices, + then: crate::asks::Choice::Upstream(branch.to_string()), + }); + cx.notify(); + } + + pub fn set_upstream(&mut self, repo: u64, branch: &str, remote: &str, cx: &mut Context) { + let to = format!("--set-upstream-to=refs/remotes/{remote}"); + self.write(repo, Write::Git(args(&["branch", &to, "--", branch])), cx); + } + + pub fn unset_upstream(&mut self, branch: &str, cx: &mut Context) { + self.run_git(args(&["branch", "--unset-upstream", "--", branch]), cx); + } + + pub fn merge(&mut self, name: &str, cx: &mut Context) { + self.run_git(args(&["merge", name]), cx); + } + + /// `onto` is the full ref; `name` is how it reads. + pub fn rebase(&mut self, onto: &str, name: &str, cx: &mut Context) { + self.confirm("tree.rebase", name, args(&["rebase", onto]), cx); + } + + pub fn delete_branch(&mut self, name: &str, force: bool, cx: &mut Context) { + log::info!("tree: delete of {name} asked, force {force}"); + let (key, flag) = if force { + ("tree.force_delete", "-D") + } else { + ("tree.delete_branch", "-d") + }; + self.confirm(key, name, args(&["branch", flag, name]), cx); + } + + pub fn delete_tag(&mut self, name: &str, cx: &mut Context) { + self.confirm("tree.delete_tag", name, args(&["tag", "-d", name]), cx); + } + + pub fn cherry_pick(&mut self, commit: &str, parents: usize, cx: &mut Context) { + let mut words = args(&["cherry-pick"]); + words.extend(mainline(parents)); + words.push(commit.to_string()); + self.run_git(words, cx); + } + + pub fn revert(&mut self, commit: &str, parents: usize, cx: &mut Context) { + let mut words = args(&["revert"]); + words.extend(mainline(parents)); + words.push(commit.to_string()); + self.run_git(words, cx); + } + + pub fn op_continue(&mut self, op: Op, cx: &mut Context) { + self.run_git(args(&[op.verb(), "--continue"]), cx); + } + + /// Abort and skip throw work away; each asks. + pub fn op_abort(&mut self, op: Op, cx: &mut Context) { + let name = text(cx, &format!("tree.opname.{}", op.verb()), &[]); + self.confirm("tree.abort", &name, args(&[op.verb(), "--abort"]), cx); + } + + pub fn op_skip(&mut self, op: Op, cx: &mut Context) { + let name = text(cx, &format!("tree.opname.{}", op.verb()), &[]); + self.confirm("tree.skip", &name, args(&[op.verb(), "--skip"]), cx); + } + + pub(crate) fn field_with( + &self, + value: &str, + key: &str, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Entity { + let label = text(cx, key, &[]); + let value = value.to_string(); + cx.new(|cx| { + let mut field = TextInput::new(window, cx).label(label); + field.set_text(value, cx); + field + }) + } + + /// A new branch at `at`, or at HEAD. + pub fn ask_branch(&mut self, at: Option, window: &mut Window, cx: &mut Context) { + let Some(repo) = self.active else { return }; + let name = self.field_with("", "tree.branch.name", window, cx); + self.ask = Some(Ask::NewBranch { + repo, + name, + at, + switch: true, + }); + cx.notify(); + } + + pub fn ask_tag(&mut self, at: Option, window: &mut Window, cx: &mut Context) { + let name = self.field_with("", "tree.tag.name", window, cx); + let message = self.field_with("", "tree.tag.message", window, cx); + let Some(repo) = self.active else { return }; + self.ask = Some(Ask::NewTag { + repo, + name, + message, + at, + }); + cx.notify(); + } + + pub fn ask_rename(&mut self, old: &str, window: &mut Window, cx: &mut Context) { + let Some(repo) = self.active else { return }; + let name = self.field_with(old, "tree.branch.name", window, cx); + self.ask = Some(Ask::Rename { + repo, + old: old.to_string(), + name, + }); + cx.notify(); + } + + /// The dialog's fields as arguments; empty names wait. + pub fn answer(&mut self, cx: &mut Context) { + let words = match &self.ask { + Some(Ask::NewBranch { + repo, + name, + at, + switch, + }) => { + let name = name.read(cx).text().trim().to_string(); + let mut words = if *switch { + args(&["switch", "-c", &name]) + } else { + args(&["branch", &name]) + }; + words.extend(at.clone()); + (*repo, name, words) + } + Some(Ask::NewTag { + repo, + name, + message, + at, + }) => { + let (name, message) = ( + name.read(cx).text().trim().to_string(), + message.read(cx).text().trim().to_string(), + ); + let mut words = match message.is_empty() { + true => args(&["tag", &name]), + false => args(&["tag", "-a", &name, "-m", &message]), + }; + words.extend(at.clone()); + (*repo, name, words) + } + Some(Ask::Rename { repo, old, name }) => { + let name = name.read(cx).text().trim().to_string(); + (*repo, name.clone(), args(&["branch", "-m", old, &name])) + } + _ => return self.answer_places(cx), + }; + let (repo, name, words) = words; + if name.is_empty() { + let words: SharedString = text(cx, "tree.name.empty", &[]); + return self.toast(words, Severity::Warning, cx); + } + self.ask = None; + self.write(repo, Write::Git(words), cx); + cx.notify(); + } +} diff --git a/apps/ely-tree/src/network.rs b/apps/ely-tree/src/network.rs new file mode 100644 index 0000000..21be4aa --- /dev/null +++ b/apps/ely-tree/src/network.rs @@ -0,0 +1,272 @@ +//! Fetch, pull and push, with progress and a stop (TREE-11). + +use std::io; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + feedback::Alert, + primitives::Severity, + theme::ControlSize, +}; +use futures::{ + StreamExt as _, + channel::{mpsc, oneshot}, +}; +use gpui::{AnyElement, AppContext as _, Context, IntoElement, SharedString}; + +use crate::{ + asks::{Ask, Choice}, + ops, remotes, + running::Stop, + tree::{Tree, text}, + work::Write, +}; + +/// A remote run under way in one repository. +pub struct Net { + pub verb: String, + pub said: SharedString, + pub stop: Stop, + /// Asked to stop; its failure is then expected. + pub stopping: bool, +} + +fn args(words: &[&str]) -> Vec { + words.iter().map(|word| word.to_string()).collect() +} + +impl Tree { + /// Runs a queued remote write, line by line. + pub(crate) fn start_remote(&mut self, id: u64, args: Vec, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(opened) = self.opened_mut(id) else { + return; + }; + let root = opened.repo.root.clone(); + let words: Vec<&str> = args.iter().map(String::as_str).collect(); + let running = match git.start(&root, &words) { + Ok(running) => running, + Err(error) => return self.written(id, &Write::Remote(args), Err(error), false, cx), + }; + opened.net = Some(Net { + verb: args.first().cloned().unwrap_or_default(), + said: SharedString::default(), + stop: running.stop(), + stopping: false, + }); + let (said, mut lines) = mpsc::unbounded(); + let (done, result) = oneshot::channel(); + let asked = args.clone(); + // Git's progress blocks a thread until it ends. + std::thread::spawn(move || { + let followed = running.follow(|line| { + let _ = said.unbounded_send(SharedString::from(line.to_string())); + }); + let stopped = match &followed { + Err(error) => ops::stopped(&git, &root, &asked, &error.to_string()), + Ok(_) => false, + }; + let _ = done.send((followed.map(drop), stopped)); + }); + cx.spawn(async move |this, cx| { + while let Some(line) = lines.next().await { + let shown = this.update(cx, |tree, cx| { + if let Some(net) = tree.opened_mut(id).and_then(|o| o.net.as_mut()) { + net.said = line; + cx.notify(); + } + }); + if shown.is_err() { + return; + } + } + let (ended, stopped) = result + .await + .unwrap_or_else(|_| (Err(io::Error::other("the run ended without a word")), false)); + this.update(cx, |tree, cx| { + tree.remote_done(id, args, ended, stopped, cx) + }) + .ok(); + }) + .detach(); + cx.notify(); + } + + fn remote_done( + &mut self, + id: u64, + args: Vec, + ended: io::Result<()>, + stopped: bool, + cx: &mut Context, + ) { + let Some(opened) = self.opened_mut(id) else { + return; + }; + let net = opened.net.take(); + if ended.is_err() && net.is_some_and(|net| net.stopping) { + // Asked for; later writes counted on it. + opened.writes.clear(); + let words = text(cx, "tree.net.stopped", &[]); + self.toast(words, Severity::Info, cx); + return self.written(id, &Write::Remote(args), Ok(()), false, cx); + } + self.written(id, &Write::Remote(args), ended, stopped, cx); + } + + /// Stops this repository's remote run; Git cleans up. + pub fn stop_remote(&mut self, id: u64, cx: &mut Context) { + let Some(net) = self.opened_mut(id).and_then(|opened| opened.net.as_mut()) else { + return; + }; + net.stopping = true; + if let Err(error) = net.stop.now() { + self.failed("tree.error.stop", &error.to_string(), cx); + } + cx.notify(); + } + + /// Nothing runs on behind a closed window. + pub fn stop_all(&mut self, cx: &mut Context) { + self.stop_clone(cx); + let running: Vec = self + .repos + .iter() + .filter(|opened| opened.net.is_some()) + .map(|opened| opened.id) + .collect(); + for id in running { + self.stop_remote(id, cx); + } + } + + fn remote(&mut self, words: &[&str], cx: &mut Context) { + let Some(id) = self.active else { return }; + self.write(id, Write::Remote(args(words)), cx); + } + + pub fn fetch_all(&mut self, cx: &mut Context) { + self.remote(&["fetch", "--progress", "--all"], cx); + } + + pub fn fetch_one(&mut self, name: &str, cx: &mut Context) { + self.remote(&["fetch", "--progress", "--", name], cx); + } + + pub fn pull(&mut self, cx: &mut Context) { + self.remote(&["pull", "--progress"], cx); + } + + /// With no upstream, asks where it goes first. + pub fn push(&mut self, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(opened) = self.active() else { return }; + let Some(Ok(status)) = &opened.status else { + return; + }; + let Some(branch) = status.head.branch.clone() else { + let words = text(cx, "tree.push.detached", &[]); + return self.toast(words, Severity::Warning, cx); + }; + if status.head.upstream.is_some() { + return self.remote(&["push", "--progress"], cx); + } + let (repo, root) = (opened.id, opened.repo.root.clone()); + let task = cx.background_spawn(async move { remotes::list(&git, &root) }); + cx.spawn(async move |this, cx| { + let read = task.await; + this.update(cx, |tree, cx| { + if tree.active != Some(repo) { + return; + } + tree.ask = Some(match read { + Ok(list) if list.is_empty() => { + let words = text(cx, "tree.push.no_remote", &[]); + return tree.toast(words, Severity::Warning, cx); + } + Ok(list) => Ask::Choose { + repo, + title: text(cx, "tree.push.where", &[("branch", &branch)]), + choices: list.into_iter().map(|remote| remote.name).collect(), + then: Choice::Publish(branch), + }, + Err(error) => Ask::Failed { + title: text(cx, "tree.remotes.unread", &[]), + detail: error.to_string().into(), + }, + }); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Pushes `branch` to `remote` and tracks it there. + pub fn publish(&mut self, repo: u64, branch: &str, remote: &str, cx: &mut Context) { + let target = format!("refs/heads/{branch}:refs/heads/{branch}"); + let words = [ + "push", + "--progress", + "--set-upstream", + "--", + remote, + &target, + ]; + self.write(repo, Write::Remote(args(&words)), cx); + } + + /// Reads what a forced push would overwrite, then asks. + pub fn ask_force(&mut self, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(opened) = self.active() else { return }; + let (repo, root) = (opened.id, opened.repo.root.clone()); + let task = cx.background_spawn(async move { remotes::force(&git, &root) }); + cx.spawn(async move |this, cx| { + let plan = task.await; + this.update(cx, |tree, cx| { + if tree.active != Some(repo) { + return; + } + tree.ask = Some(match plan { + Ok(plan) => Ask::Force { repo, plan }, + Err(error) => Ask::Failed { + title: text(cx, "tree.force.unready", &[]), + detail: error.to_string().into(), + }, + }); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// The running remote command, and its stop. + pub fn net_alert(&self, cx: &mut Context) -> Option { + let opened = self.active()?; + let net = opened.net.as_ref()?; + let id = opened.id; + let verb = text(cx, &format!("tree.net.running.{}", net.verb), &[]); + let this = cx.weak_entity(); + let stop = Button::new("tree-net-stop", text(cx, "tree.net.stop", &[])) + .size(ControlSize::Sm) + .variant(ButtonVariant::Secondary) + .disabled(net.stopping) + .on_click(move |_, _, cx| { + this.update(cx, |tree, cx| tree.stop_remote(id, cx)).ok(); + }); + Some( + Alert::new("tree-net", Severity::Info, verb) + .body(net.said.clone()) + .action(stop.into_any_element()) + .into_any_element(), + ) + } +} diff --git a/apps/ely-tree/src/ops.rs b/apps/ely-tree/src/ops.rs new file mode 100644 index 0000000..406d387 --- /dev/null +++ b/apps/ely-tree/src/ops.rs @@ -0,0 +1,121 @@ +//! A merge, rebase, cherry-pick or revert under way (TREE-08, §13.3). + +use std::{io, path::Path}; + +use crate::git::Git; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Op { + Merge, + Rebase, + CherryPick, + Revert, +} + +impl Op { + /// The Git command that continues, aborts or skips it. + pub fn verb(self) -> &'static str { + match self { + Op::Merge => "merge", + Op::Rebase => "rebase", + Op::CherryPick => "cherry-pick", + Op::Revert => "revert", + } + } + + /// A merge has no step to skip. + pub fn skips(self) -> bool { + self != Op::Merge + } +} + +/// The operation, and what its two sides stand for now. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Doing { + pub op: Op, + /// Git's "ours": what HEAD holds as the operation stands. + pub ours: String, + /// Git's "theirs": what is being brought in. + pub theirs: String, +} + +/// Stopped on a conflict: Git said so and left some. +pub fn stopped(git: &Git, root: &Path, args: &[String], said: &str) -> bool { + let operation = matches!( + args.first().map(String::as_str), + Some("merge" | "rebase" | "cherry-pick" | "revert" | "pull") + ); + if !operation || !said.lines().any(|line| line.starts_with("CONFLICT (")) { + return false; + } + match git.run(root, &["ls-files", "--unmerged", "-z"]) { + Ok(unmerged) => !unmerged.is_empty(), + Err(error) => { + log::error!("tree: unmerged entries unread: {error}"); + false + } + } +} + +/// A commit as `abc1234 subject`. +fn describe(git: &Git, root: &Path, rev: &str) -> io::Result { + let said = git.run(root, &["log", "-1", "--format=%h %s", rev, "--"])?; + Ok(String::from_utf8_lossy(&said).trim().to_string()) +} + +/// The branch HEAD is on, or its commit when detached. +fn head(git: &Git, root: &Path) -> io::Result { + let branch = git.run_or_one(root, &["symbolic-ref", "--short", "-q", "HEAD"])?; + match String::from_utf8_lossy(&branch).trim() { + "" => describe(git, root, "HEAD"), + branch => Ok(branch.to_string()), + } +} + +/// What is under way in the work tree of `git_dir`. +pub fn doing(git: &Git, root: &Path, git_dir: &Path) -> io::Result> { + let has = |name: &str| git_dir.join(name).exists(); + let read = |name: &str| { + std::fs::read_to_string(git_dir.join(name)).map(|text| text.trim().to_string()) + }; + let doing = if has("rebase-merge") || has("rebase-apply") { + let dir = if has("rebase-merge") { + "rebase-merge" + } else { + "rebase-apply" + }; + // In a rebase, ours is the base; theirs, the replay. + let onto = describe(git, root, &read(&format!("{dir}/onto"))?)?; + let theirs = if has("REBASE_HEAD") { + describe(git, root, "REBASE_HEAD")? + } else { + String::new() + }; + Doing { + op: Op::Rebase, + ours: onto, + theirs, + } + } else if has("MERGE_HEAD") { + Doing { + op: Op::Merge, + ours: head(git, root)?, + theirs: describe(git, root, "MERGE_HEAD")?, + } + } else if has("CHERRY_PICK_HEAD") { + Doing { + op: Op::CherryPick, + ours: head(git, root)?, + theirs: describe(git, root, "CHERRY_PICK_HEAD")?, + } + } else if has("REVERT_HEAD") { + Doing { + op: Op::Revert, + ours: head(git, root)?, + theirs: describe(git, root, "REVERT_HEAD")?, + } + } else { + return Ok(None); + }; + Ok(Some(doing)) +} diff --git a/apps/ely-tree/src/pane.rs b/apps/ely-tree/src/pane.rs new file mode 100644 index 0000000..a50a1f3 --- /dev/null +++ b/apps/ely-tree/src/pane.rs @@ -0,0 +1,341 @@ +//! The middle: the shown file's diff, picture or size (TREE-03). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, IconButton, SegmentedControl}, + data_display::BeforeAfter, + feedback::{EmptyState, ErrorView}, + git::{ChangeSection, DiffLayout, DiffStat, DiffViewer, LineKind, Stretch}, + primitives::{IconName, Image}, + theme::{ActiveTheme as _, ControlSize}, + typography::format, +}; +use gpui::{ + AnyElement, Context, ElementId, IntoElement, ObjectFit, ParentElement as _, Styled as _, div, + prelude::FluentBuilder as _, +}; + +use crate::{ + diff::Space, + pick::Shown, + shown::View, + tree::{Tree, text}, +}; + +fn space_key(space: Space) -> &'static str { + match space { + Space::Exact => "exact", + Space::Changes => "changes", + Space::All => "all", + } +} + +impl Tree { + /// The header: path, counts, whitespace, layout, picked lines. + fn pane_head(&self, shown: &Shown, cx: &mut Context) -> AnyElement { + let colors = cx.theme().colors.clone(); + let this = cx.weak_entity(); + let entry_from = self.active().and_then(|opened| match &opened.status { + Some(Ok(status)) => status + .entries + .iter() + .find(|entry| entry.path == shown.path)? + .from + .clone(), + _ => None, + }); + let path = match (&entry_from, shown.section) { + (Some(from), ChangeSection::Staged) => format!("{from} → {}", shown.path), + _ => shown.path.clone(), + }; + let stat = match &shown.view { + Some(Ok(View::Text { stretches, .. })) => Some(count(stretches)), + _ => None, + }; + let text_view = matches!(shown.view, Some(Ok(View::Text { .. }))); + let space = SegmentedControl::new("tree-space", space_key(self.space)) + .size(ControlSize::Sm) + .segment("exact", text(cx, "tree.space.exact", &[]), None) + .segment("changes", text(cx, "tree.space.changes", &[]), None) + .segment("all", text(cx, "tree.space.all", &[]), None) + .on_change({ + let this = this.clone(); + move |key, _, cx| { + let space = match key.as_ref() { + "changes" => Space::Changes, + "all" => Space::All, + _ => Space::Exact, + }; + this.update(cx, |tree, cx| tree.set_space(space, cx)).ok(); + } + }); + let layout = SegmentedControl::new( + "tree-layout", + if self.layout == DiffLayout::Split { + "split" + } else { + "unified" + }, + ) + .size(ControlSize::Sm) + .segment("unified", text(cx, "tree.layout.unified", &[]), None) + .segment("split", text(cx, "tree.layout.split", &[]), None) + .on_change({ + let this = this.clone(); + move |key, _, cx| { + let layout = if key.as_ref() == "split" { + DiffLayout::Split + } else { + DiffLayout::Unified + }; + this.update(cx, |tree, cx| { + tree.layout = layout; + cx.notify(); + }) + .ok(); + } + }); + let key = if shown.section == ChangeSection::Staged { + "tree.lines.unstage" + } else { + "tree.lines.stage" + }; + let lines = (!shown.picked.is_empty() && self.partial(shown)).then(|| { + let this = this.clone(); + let n = shown.picked.len().to_string(); + Button::new("tree-lines", text(cx, key, &[("n", &n)])) + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + this.update(cx, |tree, cx| tree.stage_picked(cx)).ok(); + }) + }); + // History and blame need a file Git already tracks. + let tracked = matches!( + shown.section, + ChangeSection::Staged | ChangeSection::Unstaged | ChangeSection::Conflicted + ); + let looks = tracked.then(|| self.look_buttons(&shown.path, cx)); + let open = { + let (this, path) = (this.clone(), shown.path.clone()); + IconButton::new("tree-open-file", IconName::ExternalLink) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(text(cx, "tree.open_file", &[])) + .on_click(move |_, _, cx| { + this.update(cx, |tree, cx| tree.open_file(&path, cx)).ok(); + }) + }; + let tools = self.tool_buttons(&shown.path, cx); + div() + .flex() + .items_center() + .gap_3() + .px_2() + .py_1p5() + .border_b_1() + .border_color(colors.border) + .child(div().flex_1().min_w_0().text_color(colors.fg).child(path)) + .children(stat.map(|(added, removed)| DiffStat::new(added, removed))) + .children(lines) + .when(text_view, |head| head.child(space).child(layout)) + .children(looks.into_iter().flatten()) + .children(tools) + .child(open) + .into_any_element() + } + + /// Hunks and lines stage only on an exact plain diff. + fn partial(&self, shown: &Shown) -> bool { + let staging = matches!( + shown.section, + ChangeSection::Staged | ChangeSection::Unstaged + ); + let plain = matches!(&shown.view, Some(Ok(View::Text { diff, .. })) if diff.partial()); + staging && plain && self.space == Space::Exact + } + + /// Why hunks and lines do not stage here. + fn whole_note(&self, shown: &Shown, cx: &mut Context) -> Option { + let staging = matches!( + shown.section, + ChangeSection::Staged | ChangeSection::Unstaged | ChangeSection::Untracked + ); + if !matches!(shown.view, Some(Ok(View::Text { .. }))) || !staging || self.partial(shown) { + return None; + } + let key = if self.space == Space::Exact { + "tree.whole" + } else { + "tree.space.note" + }; + let colors = cx.theme().colors.clone(); + Some( + div() + .px_2() + .py_1() + .text_color(colors.fg_muted) + .child(text(cx, key, &[])) + .into_any_element(), + ) + } + + pub fn pane(&self, shown: &Shown, cx: &mut Context) -> AnyElement { + if let Some(Ok(View::Conflict(stages))) = &shown.view { + return self.conflict_pane(&shown.path, stages, cx); + } + let body = match &shown.view { + Some(Ok(View::Text { stretches, .. })) if hunks(stretches) => { + let this = cx.weak_entity(); + let (open, line, hunk) = (this.clone(), this.clone(), this); + let mut viewer = DiffViewer::from_stretches( + ("tree-diff", shown.repo), + shown.path.clone(), + stretches.clone(), + ) + .headless() + .layout(self.layout) + .open(shown.opened.clone()) + .on_open(move |stretch, _, cx| { + open.update(cx, |tree, cx| { + if let Some(shown) = &mut tree.shown { + shown.opened.push(stretch); + cx.notify(); + } + }) + .ok(); + }); + if self.partial(shown) { + let key = if shown.section == ChangeSection::Staged { + "tree.hunk.unstage" + } else { + "tree.hunk.stage" + }; + viewer = viewer + .selected(shown.picked.iter().copied()) + .on_line(move |place, shift, _, cx| { + line.update(cx, |tree, cx| tree.pick_line(place, shift, cx)) + .ok(); + }) + .hunk_action(text(cx, key, &[]), move |stretch, _, cx| { + hunk.update(cx, |tree, cx| tree.stage_hunk(stretch, cx)) + .ok(); + }); + } + viewer.into_any_element() + } + view => self.still(view.as_ref(), cx), + }; + div() + .size_full() + .flex() + .flex_col() + .child(self.pane_head(shown, cx)) + .children(self.whole_note(shown, cx)) + .child(div().flex_1().min_h_0().child(body)) + .into_any_element() + } + + /// A diff with nothing to stage: commits and file history. + pub fn view_only( + &self, + id: ElementId, + path: &str, + view: Option<&Result>, + cx: &mut Context, + ) -> AnyElement { + match view { + Some(Ok(View::Text { stretches, .. })) if hunks(stretches) => { + DiffViewer::from_stretches(id, path.to_string(), stretches.clone()) + .headless() + .layout(self.layout) + .into_any_element() + } + view => self.still(view, cx), + } + } + + /// Everything but a text diff with lines to show. + fn still(&self, view: Option<&Result>, cx: &mut Context) -> AnyElement { + match view { + None => div().into_any_element(), + Some(Err(error)) => ErrorView::new( + "tree-diff-error", + IconName::TriangleAlert, + text(cx, "tree.diff.unread", &[]), + ) + .detail(error.clone()) + .into_any_element(), + Some(Ok(View::Text { .. })) => EmptyState::new( + "tree-diff-none", + IconName::Check, + text(cx, "tree.diff.none", &[]), + ) + .into_any_element(), + Some(Ok(View::Picture { old, new })) => { + let picture = |id: &'static str, image: &std::sync::Arc| { + Image::new(id, image.clone()) + .fit(ObjectFit::Contain) + .size_full() + }; + let shown = match (old, new) { + (Some(old), Some(new)) => BeforeAfter::new( + "tree-picture", + picture("tree-old", old), + picture("tree-new", new), + ) + .size_full() + .into_any_element(), + (Some(only), None) | (None, Some(only)) => { + picture("tree-only", only).into_any_element() + } + (None, None) => div().into_any_element(), + }; + div().size_full().p_4().child(shown).into_any_element() + } + Some(Ok(View::Binary { old, new })) => { + let size = |bytes: &Option| { + bytes.map_or_else(|| "—".to_string(), |bytes| format::file_size(bytes, false)) + }; + let sizes = format!("{} → {}", size(old), size(new)); + EmptyState::new("tree-binary", IconName::File, text(cx, "tree.binary", &[])) + .body(sizes) + .into_any_element() + } + Some(Ok(View::Folder)) => EmptyState::new( + "tree-folder", + IconName::Folder, + text(cx, "tree.folder", &[]), + ) + .into_any_element(), + Some(Ok(View::Conflict(_))) => EmptyState::new( + "tree-conflicted", + IconName::TriangleAlert, + text(cx, "tree.conflicted", &[]), + ) + .into_any_element(), + } + } +} + +/// Whether Git printed any hunk; a pure rename has none. +fn hunks(stretches: &[Stretch]) -> bool { + stretches + .iter() + .any(|stretch| matches!(stretch, Stretch::Hunk { .. })) +} + +/// Lines added and removed in the hunks. +fn count(stretches: &[Stretch]) -> (usize, usize) { + stretches + .iter() + .filter_map(|stretch| match stretch { + Stretch::Hunk { lines, .. } => Some(lines), + Stretch::Folded(_) => None, + }) + .flatten() + .fold((0, 0), |(added, removed), line| match line.kind { + LineKind::Added => (added + 1, removed), + LineKind::Removed => (added, removed + 1), + LineKind::Same => (added, removed), + }) +} diff --git a/apps/ely-tree/src/pick.rs b/apps/ely-tree/src/pick.rs new file mode 100644 index 0000000..00abef7 --- /dev/null +++ b/apps/ely-tree/src/pick.rs @@ -0,0 +1,246 @@ +//! The shown file: reading it, picking lines, staging them (TREE-04). + +use std::{collections::BTreeSet, sync::Arc}; + +use ely_gpui_component::git::ChangeSection; +use gpui::{AppContext as _, Context}; + +use crate::{ + diff::{self, Space}, + shown::{self, View}, + status::{Entry, State}, + tree::Tree, + work::Write, +}; + +/// A line's place in the viewer: stretch, then line. +pub type Place = (usize, usize); + +pub struct Shown { + pub repo: u64, + pub section: ChangeSection, + pub path: String, + pub view: Option>, + pub picked: BTreeSet, + /// Where a Shift-press range starts. + pub anchor: Option, + /// Folded stretches opened. + pub opened: Vec, + /// Bumped per read; an older read is dropped. + serial: u64, +} + +/// Whether `entry` is listed in `section`. +fn listed(entry: &Entry, section: ChangeSection) -> bool { + match (&entry.state, section) { + (State::Tracked { staged, .. }, ChangeSection::Staged) => *staged != '.', + (State::Tracked { unstaged, .. }, ChangeSection::Unstaged) => *unstaged != '.', + (State::Untracked, ChangeSection::Untracked) => true, + (State::Conflicted(_), ChangeSection::Conflicted) => true, + _ => false, + } +} + +impl Tree { + /// Shows `path` as listed in `section`. + pub fn show(&mut self, path: &str, section: ChangeSection, cx: &mut Context) { + let Some(repo) = self.active else { return }; + self.shown = Some(Shown { + repo, + section, + path: path.to_string(), + view: None, + picked: BTreeSet::new(), + anchor: None, + opened: Vec::new(), + serial: 0, + }); + self.merging = None; + self.read_shown(cx); + } + + /// The shown file's entry, if its repository still lists it. + fn shown_entry(&self) -> Option<(Entry, bool)> { + let shown = self.shown.as_ref()?; + let Some(Ok(status)) = &self.opened(shown.repo)?.status else { + return None; + }; + let entry = status + .entries + .iter() + .find(|entry| entry.path == shown.path && listed(entry, shown.section))?; + Some((entry.clone(), status.head.oid.is_some())) + } + + /// Rereads the shown file; it goes when no longer listed. + pub fn read_shown(&mut self, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some((entry, born)) = self.shown_entry() else { + if self.shown.take().is_some() { + cx.notify(); + } + return; + }; + let space = self.space; + let Some(repo) = self.shown.as_ref().map(|shown| shown.repo) else { + return; + }; + let Some(root) = self.opened(repo).map(|opened| opened.repo.root.clone()) else { + return; + }; + let Some(shown) = &mut self.shown else { return }; + // Unique across files, so a late read is dropped. + self.reads += 1; + shown.serial = self.reads; + let (serial, section) = (shown.serial, shown.section); + let task = + cx.background_spawn( + async move { shown::read(&git, &root, &entry, section, space, born) }, + ); + cx.spawn(async move |this, cx| { + let found = task.await; + this.update(cx, |tree, cx| tree.shown_read(serial, found, cx)) + .ok(); + }) + .detach(); + } + + pub(crate) fn shown_read( + &mut self, + serial: u64, + found: std::io::Result, + cx: &mut Context, + ) { + let Some(shown) = self.shown.as_mut().filter(|shown| shown.serial == serial) else { + return; + }; + let view = found + .and_then(shown::view) + .map_err(|error| error.to_string()); + if let Err(error) = &view { + log::warn!("tree: reading {} failed: {error}", shown.path); + } + // Picks hold only while the diff is unchanged. + let same = matches!((&shown.view, &view), (Some(Ok(View::Text { seen: was, .. })), Ok(View::Text { seen: now, .. })) if was == now); + if !same { + (shown.picked, shown.anchor, shown.opened) = (BTreeSet::new(), None, Vec::new()); + } + let stages = match &view { + Ok(View::Conflict(stages)) => Some(stages.clone()), + _ => None, + }; + shown.view = Some(view); + if let Some(stages) = stages { + self.adopt_merge(stages, cx); + } + cx.notify(); + } + + /// A conflict read again keeps unsaved hand edits. + fn adopt_merge(&mut self, stages: crate::conflict::Stages, cx: &mut Context) { + let text = self.merge_editor.read(cx).text().to_string(); + match &self.merging { + Some(merging) if merging.stages == stages => {} + Some(merging) if merging.saved != text => { + let words = crate::tree::text(cx, "tree.conflict.kept", &[]); + self.toast(words, ely_gpui_component::primitives::Severity::Warning, cx); + } + _ => { + self.merging = crate::conflict::Merging::new(stages); + let work = self + .merging + .as_ref() + .map(|merging| merging.saved.clone()) + .unwrap_or_default(); + self.merge_editor + .update(cx, |editor, cx| editor.set_text(work, cx)); + } + } + } + + pub fn set_space(&mut self, space: Space, cx: &mut Context) { + self.space = space; + self.read_shown(cx); + cx.notify(); + } + + /// Toggles a line; Shift takes a range. + pub fn pick_line(&mut self, place: Place, shift: bool, cx: &mut Context) { + let Some(shown) = &mut self.shown else { return }; + let range = shown.anchor.filter(|anchor| shift && anchor.0 == place.0); + match range { + Some(anchor) => { + let (from, to) = (anchor.1.min(place.1), anchor.1.max(place.1)); + shown.picked.extend((from..=to).map(|line| (place.0, line))); + } + None if !shown.picked.remove(&place) => { + shown.picked.insert(place); + } + None => {} + } + shown.anchor = Some(place); + cx.notify(); + } + + /// Stages, or unstages, every change in one hunk. + pub fn stage_hunk(&mut self, stretch: usize, cx: &mut Context) { + let Some(Shown { + view: Some(Ok(View::Text { stretches, .. })), + .. + }) = &self.shown + else { + return; + }; + let Some(ely_gpui_component::git::Stretch::Hunk { lines, .. }) = stretches.get(stretch) + else { + return; + }; + let places: BTreeSet = (0..lines.len()).map(|line| (stretch, line)).collect(); + self.stage_places(&places, cx); + } + + pub fn stage_picked(&mut self, cx: &mut Context) { + let Some(shown) = &self.shown else { return }; + let picked = shown.picked.clone(); + self.stage_places(&picked, cx); + } + + /// Cuts a patch of `places` and queues it. + fn stage_places(&mut self, places: &BTreeSet, cx: &mut Context) { + let Some((entry, _)) = self.shown_entry() else { + return; + }; + let space = self.space; + let Some(shown) = &self.shown else { return }; + let Some(Ok(View::Text { + diff, seen, hunks, .. + })) = &shown.view + else { + return; + }; + let chosen: BTreeSet<(usize, usize)> = places + .iter() + .filter_map(|(stretch, line)| { + hunks + .get(*stretch) + .copied() + .flatten() + .map(|hunk| (hunk, *line)) + }) + .collect(); + let reverse = shown.section == ChangeSection::Staged; + let Some(patch) = diff::patch(diff, &chosen, reverse) else { + return log::info!("tree: no changed line was picked"); + }; + let write = Write::Patch { + patch, + reverse, + check: shown::args(&entry, shown.section, space), + seen: Arc::clone(seen), + }; + let repo = shown.repo; + self.write(repo, write, cx); + } +} diff --git a/apps/ely-tree/src/places.rs b/apps/ely-tree/src/places.rs new file mode 100644 index 0000000..e46558a --- /dev/null +++ b/apps/ely-tree/src/places.rs @@ -0,0 +1,319 @@ +//! Stashes, remotes, worktrees, submodules, LFS (TREE-10 to 12). + +use std::path::{Path, PathBuf}; + +use ely_gpui_component::{forms::TextInput, git::StashAction, primitives::Severity}; +use gpui::{AppContext as _, Context, Window}; + +use crate::{ + asks::Ask, + git::Git, + hist::{Picked, Whose}, + recover, + remotes::{self, Remote}, + stash::{self, Stash}, + structure::{self, LfsFile, Submodule, Worktree}, + tree::{Tree, text}, + work::Write, +}; + +/// The Repository panel's lists for one repository. +#[derive(Default)] +pub struct Places { + pub read: Option, + /// Reads started; only the latest lands. + pub asks: u64, + /// The stash shown in the middle. + pub pick: Picked, +} + +/// Each list apart; one failing hides none. +pub struct Read { + pub stashes: Result, String>, + pub remotes: Result, String>, + pub worktrees: Result, String>, + pub submodules: Result, String>, + pub lfs: Result, String>, + pub reflog: Result, String>, +} + +fn read_all(git: &Git, root: &Path) -> Read { + let said = |error: std::io::Error| error.to_string(); + Read { + stashes: stash::list(git, root).map_err(said), + remotes: remotes::list(git, root).map_err(said), + worktrees: structure::worktrees(git, root).map_err(said), + submodules: structure::submodules(git, root).map_err(said), + lfs: structure::lfs(git, root).map_err(said), + reflog: recover::reflog(git, root).map_err(said), + } +} + +/// What the Repository panel has picked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Place { + Stash(String), + Remote(String), + Worktree(PathBuf), + Submodule(String), + /// One reflog event, known by itself, not its place. + Reflog(recover::Moved), +} + +fn args(words: &[&str]) -> Vec { + words.iter().map(|word| word.to_string()).collect() +} + +impl Tree { + pub fn read_places(&mut self, id: u64, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(opened) = self.opened_mut(id) else { + return; + }; + opened.places.asks += 1; + let (root, ask) = (opened.repo.root.clone(), opened.places.asks); + let task = cx.background_spawn(async move { read_all(&git, &root) }); + cx.spawn(async move |this, cx| { + let read = task.await; + this.update(cx, |tree, cx| { + let Some(places) = tree + .opened_mut(id) + .map(|opened| &mut opened.places) + .filter(|places| places.asks == ask) + else { + return; + }; + places.read = Some(read); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + pub fn pick_place(&mut self, place: Place, cx: &mut Context) { + match &place { + Place::Stash(oid) => self.pick_in(Whose::Stash, oid, cx), + Place::Reflog(moved) => self.pick_in(Whose::Reflog, &moved.oid, cx), + _ => {} + } + self.picked_place = Some(place); + cx.notify(); + } + + /// The stash list as last read. + pub fn stashes(&self) -> &[Stash] { + match self.active().and_then(|opened| opened.places.read.as_ref()) { + Some(Read { + stashes: Ok(stashes), + .. + }) => stashes, + _ => &[], + } + } + + pub fn ask_stash(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repo) = self.active else { return }; + let label = text(cx, "tree.stash.message", &[]); + let message = cx.new(|cx| TextInput::new(window, cx).label(label)); + self.ask = Some(Ask::Stash { + repo, + message, + untracked: false, + }); + cx.notify(); + } + + /// Apply and pop run; a drop asks each time. + pub fn stash_act(&mut self, at: usize, action: StashAction, cx: &mut Context) { + let Some(repo) = self.active else { return }; + let Some(oid) = self.stashes().get(at).map(|stash| stash.oid.clone()) else { + return; + }; + let verb = match action { + StashAction::Apply => "apply", + StashAction::Pop => "pop", + StashAction::Drop => "drop", + }; + let write = Write::Stash { verb, at, oid }; + if action != StashAction::Drop { + return self.write(repo, write, cx); + } + let name = format!("stash@{{{at}}}"); + self.ask = Some(Ask::Confirm { + repo, + title: text(cx, "tree.stash.drop.title", &[("name", &name)]), + message: text(cx, "tree.stash.drop.message", &[("name", &name)]), + go: text(cx, "tree.stash.drop.go", &[]), + write, + }); + cx.notify(); + } + + /// A new remote, or `old` renamed or moved. + pub fn ask_remote(&mut self, old: Option, window: &mut Window, cx: &mut Context) { + let (name, url) = match &old { + Some(remote) => ( + remote.name.clone(), + remote.urls.first().cloned().unwrap_or_default(), + ), + None => (String::new(), String::new()), + }; + let name = self.field_with(&name, "tree.remote.name", window, cx); + let url = self.field_with(&url, "tree.remote.url", window, cx); + let old = old.map(|remote| { + let url = remote.urls.first().cloned().unwrap_or_default(); + (remote.name, url) + }); + let Some(repo) = self.active else { return }; + self.ask = Some(Ask::Remote { + repo, + old, + name, + url, + }); + cx.notify(); + } + + pub fn remove_remote(&mut self, name: &str, cx: &mut Context) { + let words = args(&["remote", "remove", "--", name]); + self.confirm("tree.remote.remove", name, words, cx); + } + + /// Asks for a new folder, then the branch it holds. + pub fn add_worktree(&mut self, window: &mut Window, cx: &mut Context) { + let Some(opened) = self.active() else { return }; + let repo = opened.id; + let Some(near) = opened.repo.root.parent().map(Path::to_path_buf) else { + return; + }; + let picked = cx.prompt_for_new_path(&near, None); + cx.spawn_in(window, async move |this, cx| { + let picked = picked + .await + .map_err(|error| error.to_string()) + .and_then(|picked| picked.map_err(|error| error.to_string())); + this.update_in(cx, |tree, window, cx| match picked { + Ok(Some(path)) => tree.ask_worktree(repo, path, window, cx), + Ok(None) => log::info!("tree: the worktree folder panel was cancelled"), + Err(error) => tree.failed("tree.error.panel", &error, cx), + }) + .ok(); + }) + .detach(); + } + + fn ask_worktree( + &mut self, + repo: u64, + path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + if self.opened(repo).is_none() { + return log::info!("tree: the worktree's repository closed"); + } + let branch = self.field_with("", "tree.worktree.branch", window, cx); + self.ask = Some(Ask::Worktree { + repo, + path, + branch, + new: true, + }); + cx.notify(); + } + + pub fn remove_worktree(&mut self, path: &Path, cx: &mut Context) { + let shown = path.display().to_string(); + let words = args(&["worktree", "remove", "--", &shown]); + self.confirm("tree.worktree.remove", &shown, words, cx); + } + + pub fn prune_worktrees(&mut self, cx: &mut Context) { + self.run_git(args(&["worktree", "prune"]), cx); + } + + pub fn update_submodule(&mut self, path: &str, cx: &mut Context) { + let Some(id) = self.active else { return }; + let words = ["submodule", "update", "--init", "--progress", "--", path]; + self.write(id, Write::Remote(args(&words)), cx); + } + + pub fn open_submodule(&mut self, path: &str, cx: &mut Context) { + let Some(root) = self.active().map(|opened| opened.repo.root.join(path)) else { + return; + }; + self.open_path(root, cx); + } + + /// The Repository panel's dialogs, as answered. + pub fn answer_places(&mut self, cx: &mut Context) { + let field = |field: &gpui::Entity, cx: &Context| { + field.read(cx).text().trim().to_string() + }; + let (repo, words): (u64, Vec>) = match &self.ask { + Some(Ask::Stash { + repo, + message, + untracked, + }) => { + let mut words = args(&["stash", "push"]); + words.extend(untracked.then(|| "--include-untracked".into())); + let message = field(message, cx); + if !message.is_empty() { + words.extend(["-m".into(), message]); + } + (*repo, vec![words]) + } + Some(Ask::Remote { + repo, + old, + name, + url, + }) => { + let (name, url) = (field(name, cx), field(url, cx)); + if name.is_empty() || url.is_empty() { + let words = text(cx, "tree.remote.empty", &[]); + return self.toast(words, Severity::Warning, cx); + } + let words = match old { + None => vec![args(&["remote", "add", "--", &name, &url])], + Some((was, was_url)) => { + let mut words = Vec::new(); + if *was != name { + words.push(args(&["remote", "rename", "--", was, &name])); + } + if *was_url != url { + words.push(args(&["remote", "set-url", "--", &name, &url])); + } + words + } + }; + (*repo, words) + } + Some(Ask::Worktree { + repo, + path, + branch, + new, + }) => { + let branch = field(branch, cx); + let path = path.display().to_string(); + let words = match (branch.is_empty(), new) { + (true, _) => args(&["worktree", "add", "--detach", "--", &path]), + (false, true) => args(&["worktree", "add", "-b", &branch, "--", &path]), + (false, false) => args(&["worktree", "add", "--", &path, &branch]), + }; + (*repo, vec![words]) + } + _ => return, + }; + self.ask = None; + for words in words { + self.write(repo, Write::Git(words), cx); + } + cx.notify(); + } +} diff --git a/apps/ely-tree/src/places_pane.rs b/apps/ely-tree/src/places_pane.rs new file mode 100644 index 0000000..6987583 --- /dev/null +++ b/apps/ely-tree/src/places_pane.rs @@ -0,0 +1,332 @@ +//! The middle in Repository: the picked stash, remote and more. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + feedback::EmptyState, + git::StashAction, + primitives::IconName, + theme::{ActiveTheme as _, ControlSize}, +}; +use gpui::{ + AnyElement, Context, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, + Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, +}; + +use crate::{ + detail::Detail, + hist::{Whose, when}, + places::{Place, Read}, + structure::Checkout, + tree::{Tree, text}, +}; + +type Act = Box)>; + +impl Tree { + fn acts(&self, acts: Vec<(&'static str, bool, Act)>, cx: &mut Context) -> AnyElement { + div() + .flex() + .flex_wrap() + .gap_2() + .children(acts.into_iter().map(|(key, danger, act)| { + let this = cx.weak_entity(); + Button::new( + SharedString::from(format!("{key}-button")), + text(cx, key, &[]), + ) + .size(ControlSize::Sm) + .variant(if danger { + ButtonVariant::Danger + } else { + ButtonVariant::Secondary + }) + .on_click(move |_, window, cx| { + this.update(cx, |tree, cx| act(tree, window, cx)).ok(); + }) + })) + .into_any_element() + } + + /// A heading, its facts, and what can be done. + fn card( + &self, + title: String, + rows: DescriptionList, + acts: AnyElement, + cx: &mut Context, + ) -> AnyElement { + let colors = cx.theme().colors.clone(); + div() + .size_full() + .flex() + .flex_col() + .gap_3() + .p_3() + .child( + div() + .id("tree-place-title") + .role(Role::Heading) + .aria_label(SharedString::from(title.clone())) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(title), + ) + .child(rows) + .child(acts) + .into_any_element() + } + + /// A stash's head: its words, when, and its three actions. + pub fn stash_head(&self, detail: &Detail, cx: &mut Context) -> AnyElement { + let at = self + .stashes() + .iter() + .position(|stash| stash.oid == detail.id); + let subject = detail.message.lines().next().unwrap_or("").to_string(); + let label = |key: &str| text(cx, key, &[]); + let rows = DescriptionList::new() + .text(label("tree.stash.when"), when(detail.author.at)) + .text(label("tree.commit.id"), detail.id.clone()); + let acts = match at { + Some(at) => self.acts( + vec![ + ( + "tree.stash.apply", + false, + Box::new(move |t, _, cx| t.stash_act(at, StashAction::Apply, cx)), + ), + ( + "tree.stash.pop", + false, + Box::new(move |t, _, cx| t.stash_act(at, StashAction::Pop, cx)), + ), + ( + "tree.stash.drop", + true, + Box::new(move |t, _, cx| t.stash_act(at, StashAction::Drop, cx)), + ), + ], + cx, + ), + None => div().into_any_element(), + }; + let colors = cx.theme().colors.clone(); + div() + .flex_none() + .border_b_1() + .border_color(colors.border) + .child(self.card(subject, rows, acts, cx)) + .into_any_element() + } + + /// Where HEAD was, and the two ways back. + pub fn reflog_head(&self, detail: &Detail, cx: &mut Context) -> AnyElement { + // The picked entry itself, while it still names this commit. + let moved = match ( + self.active().and_then(|o| o.places.read.as_ref()), + &self.picked_place, + ) { + ( + Some(Read { + reflog: Ok(moves), .. + }), + Some(Place::Reflog(was)), + ) => moves + .iter() + .find(|m| m.same(was) && m.oid == detail.id) + .cloned(), + _ => None, + }; + let label = |key: &str| text(cx, key, &[]); + let mut rows = DescriptionList::new(); + if let Some(moved) = &moved { + rows = rows + .text(label("tree.reflog.where"), moved.name.clone()) + .text(label("tree.reflog.when"), when(moved.at)); + } + rows = rows.text(label("tree.commit.id"), detail.id.clone()); + let (branch, back) = (detail.id.clone(), detail.id.clone()); + let acts = self.acts( + vec![ + ( + "tree.ref.branch_here", + false, + Box::new(move |t, window, cx| t.ask_branch(Some(branch.clone()), window, cx)), + ), + ( + "tree.move.open", + true, + Box::new(move |t, _, cx| t.ask_move(&back, cx)), + ), + ], + cx, + ); + let title = moved.map_or_else( + || detail.message.lines().next().unwrap_or("").to_string(), + |moved| moved.said, + ); + let colors = cx.theme().colors.clone(); + div() + .flex_none() + .border_b_1() + .border_color(colors.border) + .child(self.card(title, rows, acts, cx)) + .into_any_element() + } + + pub fn places_pane(&self, cx: &mut Context) -> AnyElement { + let none = || { + EmptyState::new( + "tree-place-none", + IconName::Archive, + text(cx, "tree.places.pick", &[]), + ) + .into_any_element() + }; + let Some(opened) = self.active() else { + return none(); + }; + let (Some(read), Some(place)) = (&opened.places.read, &self.picked_place) else { + return none(); + }; + let label = |key: &str| text(cx, key, &[]); + match place { + Place::Stash(oid) if opened.places.pick.commit.as_ref() == Some(oid) => { + self.picked_pane(&opened.places.pick, Whose::Stash, cx) + } + Place::Remote(name) => { + let Some(remote) = found(read, |read| &read.remotes, |r| &r.name == name) else { + return none(); + }; + let push = match remote.push.is_empty() { + true => label("tree.remote.same"), + false => remote.push.join("\n").into(), + }; + let rows = DescriptionList::new() + .text(label("tree.remote.url"), remote.urls.join("\n")) + .text(label("tree.remote.push_url"), push); + let (fetch, edit, remove) = (name.clone(), remote.clone(), name.clone()); + let acts = self.acts( + vec![ + ( + "tree.net.fetch", + false, + Box::new(move |t, _, cx| t.fetch_one(&fetch, cx)), + ), + ( + "tree.remote.change", + false, + Box::new(move |t, window, cx| { + t.ask_remote(Some(edit.clone()), window, cx) + }), + ), + ( + "tree.remote.delete", + true, + Box::new(move |t, _, cx| t.remove_remote(&remove, cx)), + ), + ], + cx, + ); + self.card(name.clone(), rows, acts, cx) + } + Place::Worktree(path) => { + let Some(tree) = found(read, |read| &read.worktrees, |t| &t.path == path) else { + return none(); + }; + let mut rows = DescriptionList::new(); + rows = match (&tree.branch, &tree.head) { + (Some(branch), _) => rows.text(label("tree.worktree.on"), branch.clone()), + (None, Some(head)) => rows.text(label("tree.commit.id"), head.clone()), + (None, None) => { + rows.text(label("tree.worktree.on"), label("tree.worktree.bare")) + } + }; + if let Some(why) = &tree.locked { + rows = rows.text(label("tree.worktree.locked"), why.clone()); + } + if let Some(why) = &tree.prunable { + rows = rows.text(label("tree.worktree.prunable"), why.clone()); + } + // The main worktree and this one stay. + let main = found(read, |read| &read.worktrees, |_| true) + .is_some_and(|first| first.path == tree.path); + let here = tree.path == opened.repo.root; + let mut acts: Vec<(&'static str, bool, Act)> = Vec::new(); + if !here && tree.prunable.is_none() && !tree.bare { + let open = tree.path.clone(); + acts.push(( + "tree.worktree.open_it", + false, + Box::new(move |t, _, cx| t.open_path(open.clone(), cx)), + )); + } + if !main && !here { + let gone = tree.path.clone(); + acts.push(( + "tree.worktree.delete", + true, + Box::new(move |t, _, cx| t.remove_worktree(&gone, cx)), + )); + } + let acts = self.acts(acts, cx); + self.card(tree.path.display().to_string(), rows, acts, cx) + } + Place::Submodule(path) => { + let Some(sub) = found(read, |read| &read.submodules, |s| &s.path == path) else { + return none(); + }; + let held = match &sub.checkout { + Checkout::Missing => label("tree.submodule.missing"), + Checkout::At(head) => head.clone().into(), + Checkout::Unread(error) => error.clone().into(), + }; + let rows = DescriptionList::new() + .text(label("tree.submodule.recorded"), sub.recorded.clone()) + .text(label("tree.submodule.held"), held); + let mut acts: Vec<(&'static str, bool, Act)> = Vec::new(); + if !matches!(&sub.checkout, Checkout::At(head) if *head == sub.recorded) { + let update = sub.path.clone(); + acts.push(( + "tree.submodule.update", + false, + Box::new(move |t, _, cx| t.update_submodule(&update, cx)), + )); + } + if matches!(sub.checkout, Checkout::At(_)) { + let open = sub.path.clone(); + acts.push(( + "tree.submodule.open", + false, + Box::new(move |t, _, cx| t.open_submodule(&open, cx)), + )); + } + let acts = self.acts(acts, cx); + self.card(sub.path.clone(), rows, acts, cx) + } + // Gone from the reflog, the event shows nothing. + Place::Reflog(was) + if opened.places.pick.commit.as_ref() == Some(&was.oid) + && matches!(&read.reflog, Ok(moves) if moves.iter().any(|m| m.same(was))) => + { + self.picked_pane(&opened.places.pick, Whose::Reflog, cx) + } + Place::Stash(_) | Place::Reflog(_) => none(), + } + } +} + +/// The first item of a list that read, matching `is`. +fn found<'a, T: Clone + 'a>( + read: &'a Read, + list: impl Fn(&'a Read) -> &'a Result, String>, + is: impl Fn(&T) -> bool, +) -> Option { + list(read) + .as_ref() + .ok()? + .iter() + .find(|item| is(item)) + .cloned() +} diff --git a/apps/ely-tree/src/recover.rs b/apps/ely-tree/src/recover.rs new file mode 100644 index 0000000..7014741 --- /dev/null +++ b/apps/ely-tree/src/recover.rs @@ -0,0 +1,265 @@ +//! Reflog, moving a branch back, cleaning with a preview (TREE-13). + +use std::{io, path::Path}; + +use crate::git::Git; + +fn bad(what: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!("an unread record {what:?}"), + ) +} + +/// NUL-split records of `n` fields; a field may be empty. +pub fn records(bytes: &[u8], n: usize) -> io::Result>> { + let text = std::str::from_utf8(bytes).map_err(|_| bad("not UTF-8"))?; + let mut fields: Vec<&str> = text.split('\0').collect(); + if fields.last() == Some(&"") { + fields.pop(); + } + if fields.len() % n != 0 { + return Err(bad(text)); + } + Ok(fields.chunks(n).map(<[&str]>::to_vec).collect()) +} + +/// Where HEAD was; newest first. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Moved { + pub oid: String, + /// `HEAD@{3}`. + pub name: String, + /// What moved it, as Git wrote. + pub said: String, + pub at: i64, + /// Its count from the oldest; new moves leave it be. + pub seq: usize, +} + +impl Moved { + /// The same event, wherever it now sits. + pub fn same(&self, other: &Moved) -> bool { + (&self.oid, &self.said, self.at, self.seq) == (&other.oid, &other.said, other.at, other.seq) + } +} + +/// How many reflog entries the panel lists. +const KEEP: usize = 200; + +pub fn reflog(git: &Git, root: &Path) -> io::Result> { + // With a date mode, `%gd` carries when HEAD moved. + let format = "--format=%H%x00%gd%x00%gs"; + let count = format!("-n{KEEP}"); + let out = git.run_or_one( + root, + &[ + "reflog", + "show", + "-z", + "--date=unix", + format, + &count, + "HEAD", + "--", + ], + )?; + // Every entry, to count each one's place from the oldest. + let all = git.run_or_one(root, &["reflog", "show", "-z", "--format=%h", "HEAD", "--"])?; + let total = records(&all, 1)?.len(); + records(&out, 3)? + .into_iter() + .enumerate() + .map(|(at, fields)| { + let [oid, when, said] = fields[..] else { + return Err(bad(&fields.join(" "))); + }; + let time = when + .strip_prefix("HEAD@{") + .and_then(|when| when.strip_suffix('}')) + .and_then(|when| when.parse().ok()) + .ok_or_else(|| bad(when))?; + Ok(Moved { + oid: oid.to_string(), + name: format!("HEAD@{{{at}}}"), + said: said.to_string(), + at: time, + seq: total + .checked_sub(at + 1) + .ok_or_else(|| bad("the reflog grew"))?, + }) + }) + .collect() +} + +/// Moving HEAD's branch to a commit: what leaves it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Move { + /// `refs/heads/main`, or none when detached. + pub branch: Option, + pub head: String, + pub to: String, + /// Commits left behind: `abc1234 subject`. + pub left: Vec, +} + +fn words(bytes: Vec) -> String { + String::from_utf8_lossy(&bytes).trim().to_string() +} + +/// HEAD's branch ref, if any, and its commit. +pub fn head_now(git: &Git, root: &Path) -> io::Result<(Option, String)> { + let branch = words(git.run_or_one(root, &["symbolic-ref", "-q", "HEAD"])?); + let head = words(git.run(root, &["rev-parse", "--verify", "HEAD"])?); + Ok(((!branch.is_empty()).then_some(branch), head)) +} + +/// `reset --keep` would drop staged work; refuse instead. +pub fn nothing_staged(git: &Git, root: &Path) -> io::Result<()> { + if git + .run(root, &["diff", "--cached", "--name-only", "-z"])? + .is_empty() + { + return Ok(()); + } + Err(io::Error::other( + "staged changes would be lost; commit or unstage them first", + )) +} + +pub fn plan_move(git: &Git, root: &Path, to: &str) -> io::Result { + nothing_staged(git, root)?; + let (branch, head) = head_now(git, root)?; + let range = format!("{to}..{head}"); + let left = words(git.run(root, &["log", "--format=%h %s", &range, "--"])?); + Ok(Move { + branch, + head, + to: to.to_string(), + left: left.lines().map(str::to_string).collect(), + }) +} + +/// Undoes Git's C quoting of a path. +fn unquote(said: &str) -> io::Result { + let Some(inner) = said.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else { + return Ok(said.to_string()); + }; + let (mut out, mut chars) = (Vec::new(), inner.chars()); + while let Some(c) = chars.next() { + if c != '\\' { + let mut buffer = [0; 4]; + out.extend_from_slice(c.encode_utf8(&mut buffer).as_bytes()); + continue; + } + let byte = match chars.next().ok_or_else(|| bad(said))? { + 'a' => 7, + 'b' => 8, + 't' => b'\t', + 'n' => b'\n', + 'v' => 11, + 'f' => 12, + 'r' => b'\r', + '"' => b'"', + '\\' => b'\\', + digit @ '0'..='3' => { + let rest: String = chars.by_ref().take(2).collect(); + u8::from_str_radix(&format!("{digit}{rest}"), 8).map_err(|_| bad(said))? + } + _ => return Err(bad(said)), + }; + out.push(byte); + } + String::from_utf8(out).map_err(|_| bad(said)) +} + +/// The flags a clean runs with, preview and real. +pub fn clean_flags(ignored: bool) -> Vec { + let mut flags = vec!["-d".to_string()]; + flags.extend(ignored.then(|| "-x".to_string())); + flags +} + +/// What a clean removes, fixed when shown. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Clean { + /// Each file, folders opened up. + pub files: Vec, + /// Folders, deepest first; removed only once empty. + pub dirs: Vec, +} + +/// Folders under `dir`, skipping other repositories, deepest first. +fn folders(root: &Path, dir: &str, out: &mut Vec) -> io::Result<()> { + for entry in std::fs::read_dir(root.join(dir))? { + let entry = entry?; + let kind = entry.file_type()?; + let name = entry + .file_name() + .into_string() + .map_err(|_| bad("a folder name"))?; + let inner = format!("{dir}{name}/"); + if kind.is_dir() && !entry.path().join(".git").exists() { + folders(root, &inner, out)?; + } + } + out.push(dir.to_string()); + Ok(()) +} + +/// What `git clean` would remove, file by file. +pub fn clean_preview(git: &Git, root: &Path, ignored: bool) -> io::Result { + let mut args: Vec = ["-c", "core.quotePath=false", "clean", "-n"] + .map(String::from) + .into(); + args.extend(clean_flags(ignored)); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let out = String::from_utf8(git.run(root, &args)?).map_err(|_| bad("not UTF-8"))?; + let mut clean = Clean::default(); + for path in out + .lines() + .filter_map(|line| line.strip_prefix("Would remove ")) + { + let path = unquote(path)?; + if !path.ends_with('/') { + clean.files.push(path); + continue; + } + let mut list = vec!["ls-files", "--others", "-z"]; + if !ignored { + list.push("--exclude-standard"); + } + list.extend(["--", &path]); + let inside = git.run(root, &list)?; + let inside = std::str::from_utf8(&inside).map_err(|_| bad("not UTF-8"))?; + // Another repository inside stays, as clean leaves it. + clean.files.extend( + inside + .split('\0') + .filter(|file| !file.is_empty() && !file.ends_with('/')) + .map(str::to_string), + ); + folders(root, &path, &mut clean.dirs)?; + } + Ok(clean) +} + +#[cfg(test)] +mod tests { + #[test] + fn quoted_paths_come_back_whole() { + let read = |said| super::unquote(said).expect("reads"); + assert_eq!(read("plain name.txt"), "plain name.txt"); + assert_eq!(read(r#""tab\there.txt""#), "tab\there.txt"); + assert_eq!(read(r#""say \"hi\"\\.txt""#), "say \"hi\"\\.txt"); + assert_eq!(read(r#""\344\270\255.txt""#), "中.txt"); + assert!(super::unquote(r#""bad\q""#).is_err()); + } + + #[test] + fn records_keep_empty_fields() { + let read = super::records(b"a\0\0c\0d\0e\0f\0", 3).expect("reads"); + assert_eq!(read, [vec!["a", "", "c"], vec!["d", "e", "f"]]); + assert!(super::records(b"a\0b\0", 3).is_err()); + } +} diff --git a/apps/ely-tree/src/recovering.rs b/apps/ely-tree/src/recovering.rs new file mode 100644 index 0000000..306bdd7 --- /dev/null +++ b/apps/ely-tree/src/recovering.rs @@ -0,0 +1,263 @@ +//! Cleaning and moving back, each previewed; lock retries. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + forms::Checkbox, + overlays::{ConfirmDialog, Dialog}, + theme::ActiveTheme as _, +}; +use gpui::{ + AnyElement, App, AppContext as _, Context, InteractiveElement as _, IntoElement, + ParentElement as _, StatefulInteractiveElement as _, Styled as _, Window, div, px, +}; + +use crate::{ + asks::Ask, + recover, + tree::{Tree, text}, + work::Write, +}; + +/// Each file, then each folder, as the dialog lists them. +fn shown(clean: &recover::Clean) -> Vec { + clean.files.iter().chain(&clean.dirs).cloned().collect() +} + +impl Tree { + /// Opens the clean dialog; its list comes from Git. + pub fn ask_clean(&mut self, cx: &mut Context) { + let Some(repo) = self.active else { return }; + self.ask = Some(Ask::Clean { + repo, + ignored: false, + preview: None, + }); + self.preview_clean(cx); + } + + pub fn clean_ignored(&mut self, on: bool, cx: &mut Context) { + if let Some(Ask::Clean { + ignored, preview, .. + }) = &mut self.ask + { + (*ignored, *preview) = (on, None); + self.preview_clean(cx); + } + } + + /// Only the answer to the dialog as it stands lands. + fn preview_clean(&mut self, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(Ask::Clean { repo, ignored, .. }) = &self.ask else { + return; + }; + let (repo, ignored) = (*repo, *ignored); + let Some(root) = self.opened(repo).map(|opened| opened.repo.root.clone()) else { + return; + }; + let task = cx.background_spawn(async move { recover::clean_preview(&git, &root, ignored) }); + cx.spawn(async move |this, cx| { + let read = task.await.map_err(|error| error.to_string()); + this.update(cx, |tree, cx| { + if let Some(Ask::Clean { + repo: now, + ignored: still, + preview, + }) = &mut tree.ask + && (*now, *still) == (repo, ignored) + { + *preview = Some(read); + cx.notify(); + } + }) + .ok(); + }) + .detach(); + cx.notify(); + } + + /// Removes exactly the previewed paths, nothing found later. + fn clean(&mut self, cx: &mut Context) { + let Some(Ask::Clean { + repo, + ignored, + preview: Some(Ok(clean)), + }) = &self.ask + else { + return; + }; + let write = Write::Clean { + files: clean.files.clone(), + dirs: clean.dirs.clone(), + ignored: *ignored, + }; + let (repo, count) = (*repo, clean.files.len()); + self.ask = None; + log::info!("tree: cleaning {count} files"); + self.write(repo, write, cx); + } + + /// Reads what moving the branch to `to` leaves behind. + pub fn ask_move(&mut self, to: &str, cx: &mut Context) { + let Some(git) = self.git.as_ref().ok().cloned() else { + return; + }; + let Some(opened) = self.active() else { return }; + let (repo, root, to) = (opened.id, opened.repo.root.clone(), to.to_string()); + let task = cx.background_spawn(async move { recover::plan_move(&git, &root, &to) }); + cx.spawn(async move |this, cx| { + let plan = task.await; + this.update(cx, |tree, cx| { + if tree.active != Some(repo) { + return; + } + tree.ask = Some(match plan { + Ok(plan) => Ask::Move { repo, plan }, + Err(error) => Ask::Failed { + title: text(cx, "tree.op.failed", &[]), + detail: error.to_string().into(), + }, + }); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + pub fn recover_dialog( + &self, + dismiss: impl Fn(&mut Window, &mut App) + Clone + 'static, + cx: &mut Context, + ) -> Option { + let this = cx.weak_entity(); + let label = |key: &str| text(cx, key, &[]); + Some(match self.ask.as_ref()? { + Ask::Clean { + ignored, preview, .. + } => { + let colors = cx.theme().colors.clone(); + let toggle = { + let this = this.clone(); + Checkbox::new("tree-clean-ignored", *ignored) + .label(label("tree.clean.ignored")) + .on_change(move |on, _, cx| { + this.update(cx, |tree, cx| tree.clean_ignored(on, cx)).ok(); + }) + }; + let (body, ready): (AnyElement, bool) = match preview { + None => ( + div().child(label("tree.clean.reading")).into_any_element(), + false, + ), + Some(Err(error)) => ( + div() + .text_color(colors.danger) + .child(error.clone()) + .into_any_element(), + false, + ), + Some(Ok(clean)) if shown(clean).is_empty() => ( + div().child(label("tree.clean.nothing")).into_any_element(), + false, + ), + Some(Ok(clean)) => ( + div() + .id("tree-clean-paths") + .text_color(colors.fg) + .max_h(px(280.0)) + .overflow_y_scroll() + .flex() + .flex_col() + .children(shown(clean).into_iter().map(|path| div().child(path))) + .into_any_element(), + true, + ), + }; + let count = match preview { + Some(Ok(clean)) => shown(clean).len(), + _ => 0, + }; + let (go, cancel) = ( + text(cx, "tree.clean.go", &[("n", &count.to_string())]), + label("tree.cancel"), + ); + Dialog::new("tree-clean", label("tree.clean.title"), dismiss) + .child(toggle) + .child(body) + .action(move |close| { + Button::new("tree-clean-cancel", cancel) + .on_click(move |_, window, cx| close(window, cx)) + }) + .action(move |_| { + Button::new("tree-clean-go", go) + .variant(ButtonVariant::Danger) + .disabled(!ready) + .on_click(move |_, _, cx| { + this.update(cx, |tree, cx| tree.clean(cx)).ok(); + }) + }) + .into_any_element() + } + Ask::Move { repo, plan } => { + let short = |oid: &str| oid[..oid.len().min(7)].to_string(); + let left = match plan.left.len() { + 0 => label("tree.move.nothing"), + n => text( + cx, + "tree.move.left", + &[("n", &n.to_string()), ("commits", &plan.left.join("\n"))], + ), + }; + let what = match &plan.branch { + Some(branch) => branch.trim_start_matches("refs/heads/").to_string(), + None => label("tree.move.detached").to_string(), + }; + let message = text( + cx, + "tree.move.message", + &[ + ("what", &what), + ("from", &short(&plan.head)), + ("to", &short(&plan.to)), + ("left", &left), + ], + ); + let (repo, write) = ( + *repo, + Write::Move { + branch: plan.branch.clone(), + head: plan.head.clone(), + to: plan.to.clone(), + }, + ); + ConfirmDialog::new("tree-move", label("tree.move.title"), message, dismiss) + .confirm(label("tree.move.go")) + .destructive() + .on_confirm(move |_, cx| { + this.update(cx, |tree, cx| tree.write(repo, write.clone(), cx)) + .ok(); + }) + .into_any_element() + } + Ask::Locked { + repo, + write, + detail, + } => { + let (repo, write) = (*repo, write.clone()); + let message = format!("{}\n\n{detail}", label("tree.locked.message")); + ConfirmDialog::new("tree-locked", label("tree.locked.title"), message, dismiss) + .confirm(label("tree.locked.retry")) + .on_confirm(move |_, cx| { + this.update(cx, |tree, cx| tree.write(repo, write.clone(), cx)) + .ok(); + }) + .into_any_element() + } + _ => return None, + }) + } +} diff --git a/apps/ely-tree/src/refs_pane.rs b/apps/ely-tree/src/refs_pane.rs new file mode 100644 index 0000000..0e276cd --- /dev/null +++ b/apps/ely-tree/src/refs_pane.rs @@ -0,0 +1,176 @@ +//! The picked branch or tag, and its actions. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + feedback::EmptyState, + primitives::IconName, + theme::{ActiveTheme as _, ControlSize}, +}; +use gpui::{ + AnyElement, App, Context, FontWeight, IntoElement, ParentElement as _, SharedString, + Styled as _, Window, div, +}; + +use crate::{ + branches::{Kind, Ref}, + hist::when, + tree::{Tree, text}, +}; + +type Act = Box)>; + +impl Tree { + pub fn refs_pane(&self, cx: &mut Context) -> AnyElement { + let found = self + .active() + .and_then(|opened| match (&opened.refs, &self.picked_ref) { + (Some(Ok(refs)), Some((kind, name))) => refs + .iter() + .find(|r| r.kind == *kind && r.name == *name) + .cloned(), + _ => None, + }); + let Some(found) = found else { + return EmptyState::new( + "tree-ref-none", + IconName::GitBranch, + text(cx, "tree.ref.pick", &[]), + ) + .into_any_element(); + }; + let colors = cx.theme().colors.clone(); + let label = |key: &str| text(cx, key, &[]); + let mut rows = DescriptionList::new() + .text( + label("tree.ref.commit"), + format!( + "{} {}", + &found.commit[..found.commit.len().min(7)], + found.subject + ), + ) + .text(label("tree.ref.when"), when(found.at)); + if let Some(upstream) = &found.upstream { + let track = match found.track { + Some((ahead, behind)) => format!("{upstream} ↑{ahead} ↓{behind}"), + None => text(cx, "tree.upstream_gone", &[("upstream", upstream)]).to_string(), + }; + rows = rows.text(label("tree.ref.upstream"), track); + } + if let Some(message) = &found.message { + rows = rows.text(label("tree.ref.message"), message.clone()); + } + let buttons = self.ref_actions(&found, cx); + div() + .size_full() + .flex() + .flex_col() + .gap_3() + .p_3() + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(found.name.clone()), + ) + .child(rows) + .child(div().flex().flex_wrap().gap_2().children(buttons)) + .into_any_element() + } + + /// What applies to `found`: the current branch takes no switch. + fn ref_actions(&self, found: &Ref, cx: &mut Context) -> Vec { + let name = found.name.clone(); + let kind = found.kind; + let at = found.commit.clone(); + let mut acts: Vec<(&str, Act)> = Vec::new(); + let n = name.clone(); + let switchable = match kind { + Kind::Local => !found.current, + Kind::Remote => true, + Kind::Tag => false, + }; + if switchable { + acts.push(( + "tree.ref.switch", + Box::new(move |tree, _, cx| tree.switch(&n, kind, cx)), + )); + } + if !found.current && kind != Kind::Tag { + // Merge and rebase use full refs, never a same-named tag. + let (m, r, shown) = (found.full.clone(), found.full.clone(), name.clone()); + acts.push(( + "tree.ref.merge", + Box::new(move |tree, _, cx| tree.merge(&m, cx)), + )); + acts.push(( + "tree.ref.rebase", + Box::new(move |tree, _, cx| tree.rebase(&r, &shown, cx)), + )); + } + let here = at.clone(); + acts.push(( + "tree.ref.branch_here", + Box::new(move |tree, window, cx| tree.ask_branch(Some(here.clone()), window, cx)), + )); + match kind { + Kind::Local => { + let (r, d, f) = (name.clone(), name.clone(), name.clone()); + let (up, gone) = (name.clone(), name.clone()); + acts.push(( + "tree.upstream.set", + Box::new(move |tree, _, cx| tree.ask_upstream(&up, cx)), + )); + if found.upstream.is_some() { + acts.push(( + "tree.upstream.unset", + Box::new(move |tree, _, cx| tree.unset_upstream(&gone, cx)), + )); + } + acts.push(( + "tree.ref.rename", + Box::new(move |tree, window, cx| tree.ask_rename(&r, window, cx)), + )); + if !found.current { + acts.push(( + "tree.ref.delete", + Box::new(move |tree, _, cx| tree.delete_branch(&d, false, cx)), + )); + acts.push(( + "tree.ref.force_delete", + Box::new(move |tree, _, cx| tree.delete_branch(&f, true, cx)), + )); + } + } + Kind::Tag => { + let d = name.clone(); + acts.push(( + "tree.ref.delete_tag", + Box::new(move |tree, _, cx| tree.delete_tag(&d, cx)), + )); + } + Kind::Remote => {} + } + acts.into_iter() + .map(|(key, act)| { + let this = cx.weak_entity(); + let destructive = key.contains("delete"); + Button::new( + SharedString::from(format!("{key}-button")), + text(cx, key, &[]), + ) + .size(ControlSize::Sm) + .variant(if destructive { + ButtonVariant::Danger + } else { + ButtonVariant::Secondary + }) + .on_click(move |_, window, cx: &mut App| { + this.update(cx, |tree, cx| act(tree, window, cx)).ok(); + }) + .into_any_element() + }) + .collect() + } +} diff --git a/apps/ely-tree/src/remotes.rs b/apps/ely-tree/src/remotes.rs new file mode 100644 index 0000000..4e55b2d --- /dev/null +++ b/apps/ely-tree/src/remotes.rs @@ -0,0 +1,118 @@ +//! Remotes, and what a forced push would overwrite (TREE-11, §13.3). + +use std::{io, path::Path}; + +use crate::git::Git; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Remote { + pub name: String, + pub urls: Vec, + /// Push URLs, when set apart from fetch ones. + pub push: Vec, +} + +fn bad(what: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!("an unread remote {what:?}"), + ) +} + +fn words(bytes: Vec) -> io::Result { + String::from_utf8(bytes) + .map(|text| text.trim_end_matches('\n').to_string()) + .map_err(|_| bad("not UTF-8")) +} + +/// Each remote Git knows, with its URLs. +pub fn list(git: &Git, root: &Path) -> io::Result> { + let mut remotes: Vec = words(git.run(root, &["remote"])?)? + .lines() + .map(|name| Remote { + name: name.to_string(), + urls: Vec::new(), + push: Vec::new(), + }) + .collect(); + let pattern = r"^remote\..*\.(url|pushurl)$"; + let said = words(git.run_or_one(root, &["config", "-z", "--get-regexp", pattern])?)?; + // `-z`: key, newline, value, NUL. + for entry in said.split('\0').filter(|entry| !entry.is_empty()) { + let (key, value) = entry.split_once('\n').ok_or_else(|| bad(entry))?; + let (name, field) = key + .strip_prefix("remote.") + .and_then(|key| key.rsplit_once('.')) + .ok_or_else(|| bad(key))?; + let Some(remote) = remotes.iter_mut().find(|remote| remote.name == name) else { + return Err(bad(name)); + }; + match field { + "url" => remote.urls.push(value.to_string()), + _ => remote.push.push(value.to_string()), + } + } + Ok(remotes) +} + +/// A forced push: where, and what it would overwrite. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Force { + pub branch: String, + pub remote: String, + /// The branch on the remote: `refs/heads/main`. + pub target: String, + /// What the remote held when last fetched. + pub lease: String, + /// The commit shown; pushed whatever the branch is by then. + pub source: String, + /// Commits there and not here: `abc1234 subject`. + pub lost: Vec, +} + +/// The current branch's forced push to its upstream. +pub fn force(git: &Git, root: &Path) -> io::Result { + let branch = words(git.run_or_one(root, &["symbolic-ref", "--short", "-q", "HEAD"])?)?; + if branch.is_empty() { + return Err(io::Error::other("HEAD is not on a branch")); + } + let setting = |key: &str| -> io::Result { + words(git.run_or_one( + root, + &["config", "--get", &format!("branch.{branch}.{key}")], + )?) + }; + let (remote, target) = (setting("remote")?, setting("merge")?); + if remote.is_empty() || target.is_empty() { + return Err(io::Error::other(format!("{branch} has no upstream"))); + } + let upstream = format!("{branch}@{{upstream}}"); + let lease = words(git.run(root, &["rev-parse", "--verify", &upstream])?)?; + let source = words(git.run(root, &["rev-parse", "--verify", "HEAD"])?)?; + let lost = words(git.run( + root, + &["log", "--format=%h %s", &lease, "--not", &source, "--"], + )?)?; + Ok(Force { + lost: lost.lines().map(str::to_string).collect(), + branch, + remote, + target, + lease, + source, + }) +} + +impl Force { + /// With a lease: refused if the remote moved since. + pub fn args(&self) -> Vec { + vec![ + "push".into(), + "--progress".into(), + format!("--force-with-lease={}:{}", self.target, self.lease), + "--".into(), + self.remote.clone(), + format!("{}:{}", self.source, self.target), + ] + } +} diff --git a/apps/ely-tree/src/repo.rs b/apps/ely-tree/src/repo.rs new file mode 100644 index 0000000..f178fd7 --- /dev/null +++ b/apps/ely-tree/src/repo.rs @@ -0,0 +1,74 @@ +//! Repositories: open, start, clone, and the recent list (TREE-01). + +use std::{ + io, + path::{Path, PathBuf}, +}; + +use crate::{git::Git, running::Running}; + +/// How many repositories the recent list keeps. + +/// A work tree and its Git directory. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Repo { + /// The real top of the work tree, links resolved. + pub root: PathBuf, + pub git_dir: PathBuf, + /// Shared refs and objects; a linked worktree's lie elsewhere. + pub common_dir: PathBuf, +} + +fn text(bytes: Vec) -> io::Result { + String::from_utf8(bytes) + .map(|s| s.trim_end_matches('\n').to_string()) + .map_err(io::Error::other) +} + +/// The repository holding `path`; bare ones are refused. +pub fn open(git: &Git, path: &Path) -> io::Result { + if text(git.run(path, &["rev-parse", "--is-bare-repository"])?)? == "true" { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("{} is a bare repository", path.display()), + )); + } + let root = text(git.run(path, &["rev-parse", "--show-toplevel"])?)?; + let git_dir = text(git.run(path, &["rev-parse", "--absolute-git-dir"])?)?; + let common = ["rev-parse", "--path-format=absolute", "--git-common-dir"]; + let common_dir = text(git.run(path, &common)?)?; + let repo = Repo { + root: std::fs::canonicalize(root)?, + git_dir: std::fs::canonicalize(git_dir)?, + common_dir: std::fs::canonicalize(common_dir)?, + }; + log::info!("tree: opened {}", repo.root.display()); + Ok(repo) +} + +/// A new repository in `dir`, then opened. +pub fn init(git: &Git, dir: &Path) -> io::Result { + std::fs::create_dir_all(dir)?; + git.run(dir, &["init", "--quiet"])?; + open(git, dir) +} + +impl Git { + /// Starts `git clone url into`; never asks on a terminal. + pub fn clone_repo(&self, url: &str, into: &Path) -> io::Result { + if into.exists() && std::fs::read_dir(into)?.next().is_some() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("{} is not empty", into.display()), + )); + } + let parent = into + .parent() + .ok_or_else(|| io::Error::other("a clone needs a parent folder"))?; + let into_text = into + .to_str() + .ok_or_else(|| io::Error::other("a path that is not UTF-8"))?; + log::info!("tree: clone into {}", into.display()); + self.start(parent, &["clone", "--progress", "--", url, into_text]) + } +} diff --git a/apps/ely-tree/src/running.rs b/apps/ely-tree/src/running.rs new file mode 100644 index 0000000..5a63d1c --- /dev/null +++ b/apps/ely-tree/src/running.rs @@ -0,0 +1,198 @@ +//! A long Git run: progress, and a stop. + +use std::{ + io::{self, Read as _}, + path::Path, + process::{Child, ChildStderr, ChildStdout, Stdio}, + sync::{Arc, Mutex}, +}; + +use crate::git::Git; + +/// Git running; progress comes line by line. +pub struct Running { + verb: String, + stdout: Option, + stderr: Option, + child: Arc>, +} + +/// Stops a run from another thread. +#[derive(Clone)] +pub struct Stop(Arc>); + +impl Git { + /// Its own session: SSH finds no terminal. + pub fn start(&self, dir: &Path, args: &[&str]) -> io::Result { + let mut command = self.command(dir, args); + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + // SAFETY: setsid is async-signal-safe. + unsafe { + command.pre_exec(|| match libc::setsid() { + -1 => Err(io::Error::last_os_error()), + _ => Ok(()), + }); + } + } + let mut child = command.spawn()?; + let verb = args.first().copied().unwrap_or("").to_string(); + log::info!("tree: git {verb} started, {}", child.id()); + Ok(Running { + verb, + stdout: child.stdout.take(), + stderr: child.stderr.take(), + child: Arc::new(Mutex::new(child)), + }) + } +} + +/// Lines as shown; one ended by CR is rewritten. +#[derive(Default)] +struct Said { + lines: Vec, + rewrite: bool, +} + +impl Said { + /// Ends a line at `byte`; empty CRLF halves end nothing. + fn end(&mut self, text: String, byte: u8) -> Option<&str> { + if text.is_empty() { + self.rewrite &= byte == b'\r'; + return None; + } + if self.rewrite { + self.lines.pop(); + } + self.rewrite = byte == b'\r'; + self.lines.push(text); + self.lines.last().map(String::as_str) + } +} + +impl Running { + pub fn stop(&self) -> Stop { + Stop(Arc::clone(&self.child)) + } + + /// Reads to the end; failure quotes Git, both streams. + pub fn follow(mut self, mut seen: impl FnMut(&str)) -> io::Result { + let mut stdout = self + .stdout + .take() + .ok_or_else(|| io::Error::other("no output"))?; + let mut stderr = self + .stderr + .take() + .ok_or_else(|| io::Error::other("no progress"))?; + // Reading apart keeps either pipe from filling. + let out = std::thread::spawn(move || { + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).map(|_| bytes) + }); + let (mut said, mut line, mut buffer) = (Said::default(), Vec::new(), [0u8; 4096]); + let mut flush = |line: &mut Vec, said: &mut Said, byte: u8| { + let text = String::from_utf8_lossy(line).trim().to_string(); + line.clear(); + if let Some(text) = said.end(text, byte) { + seen(text); + } + }; + loop { + let read = stderr.read(&mut buffer)?; + // Progress rewrites its line with carriage returns. + for &byte in &buffer[..read] { + match byte { + b'\r' | b'\n' => flush(&mut line, &mut said, byte), + _ => line.push(byte), + } + } + if read == 0 { + flush(&mut line, &mut said, b'\n'); + break; + } + } + let out = out + .join() + .map_err(|_| io::Error::other("the output reader panicked"))??; + let out = String::from_utf8_lossy(&out).trim().to_string(); + let status = self.child.lock().expect("the run lock").wait()?; + let verb = &self.verb; + if !status.success() { + log::warn!("tree: git {verb} failed, {status}"); + let all: Vec = [out] + .into_iter() + .chain(said.lines) + .filter(|part| !part.is_empty()) + .collect(); + return Err(io::Error::other(format!( + "git {verb} ({status}):\n{}", + all.join("\n") + ))); + } + log::info!("tree: git {verb} done"); + Ok(out) + } +} + +impl Stop { + /// Asks Git and its helpers to stop; Git cleans up. + pub fn now(&self) -> io::Result<()> { + let mut child = self.0.lock().expect("the run lock"); + // An ended Git's number may be another's now. + if let Some(status) = child.try_wait()? { + log::info!("tree: git {} had ended, {status}", child.id()); + return Ok(()); + } + log::info!("tree: git {} asked to stop", child.id()); + terminate(child.id()) + } +} + +/// Its own session: the group is Git and its helpers. +#[cfg(unix)] +fn terminate(pid: u32) -> io::Result<()> { + let pid = libc::pid_t::try_from(pid).map_err(io::Error::other)?; + match unsafe { libc::kill(-pid, libc::SIGTERM) } { + 0 => Ok(()), + _ => Err(io::Error::last_os_error()), + } +} + +#[cfg(not(unix))] +fn terminate(_pid: u32) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "stopping Git is not built on this platform", + )) +} + +#[cfg(test)] +mod tests { + #[test] + fn only_a_line_ended_by_return_is_rewritten() { + let mut said = super::Said::default(); + for (text, byte) in [ + ("Receiving objects: 1%", b'\r'), + ("Receiving objects: 100%", b'\n'), + ("remote: POLICY_A required", b'\r'), + ("", b'\n'), + ("remote: POLICY_B required", b'\n'), + ] { + said.end(text.to_string(), byte); + } + assert_eq!( + said.lines, + [ + "Receiving objects: 100%", + "remote: POLICY_A required", + "remote: POLICY_B required" + ] + ); + } +} diff --git a/apps/ely-tree/src/shown.rs b/apps/ely-tree/src/shown.rs new file mode 100644 index 0000000..ca6d1bb --- /dev/null +++ b/apps/ely-tree/src/shown.rs @@ -0,0 +1,366 @@ +//! The middle's file, read off the UI thread (TREE-03). + +use std::{io, path::Path, rc::Rc, sync::Arc}; + +use ely_gpui_component::git::{ChangeSection, DiffLine, LineKind, Stretch, mark_words}; +use gpui::{Image, ImageFormat}; + +use crate::{ + diff::{self, Body, FileDiff, Space}, + git::Git, + status::{Entry, State}, +}; + +/// Picture formats shown side by side, by extension. +const PICTURES: [(&str, ImageFormat); 9] = [ + ("png", ImageFormat::Png), + ("jpg", ImageFormat::Jpeg), + ("jpeg", ImageFormat::Jpeg), + ("gif", ImageFormat::Gif), + ("webp", ImageFormat::Webp), + ("bmp", ImageFormat::Bmp), + ("tif", ImageFormat::Tiff), + ("tiff", ImageFormat::Tiff), + ("ico", ImageFormat::Ico), +]; + +/// What a read found, before it becomes a view. +pub enum Found { + Text { + diff: FileDiff, + seen: Vec, + old: Option, + }, + Picture { + format: ImageFormat, + old: Option>, + new: Option>, + }, + Binary { + old: Option, + new: Option, + }, + Folder, + Conflict(crate::conflict::Stages), +} + +/// What the middle draws. +pub enum View { + Text { + diff: Arc, + /// The output read, to see a change before a patch. + seen: Arc>, + stretches: Rc>, + /// Each stretch's hunk, if it is one. + hunks: Vec>, + }, + Picture { + old: Option>, + new: Option>, + }, + Binary { + old: Option, + new: Option, + }, + Folder, + Conflict(crate::conflict::Stages), +} + +/// How every diff is asked for, before what it compares. +pub fn base(space: Space) -> Vec { + // Blank context lines keep their leading space. + let mut args: Vec = [ + "-c", + "diff.suppressBlankEmpty=false", + "diff", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--src-prefix=a/", + "--dst-prefix=b/", + "-U3", + ] + .map(String::from) + .to_vec(); + args.extend(space.flag().map(String::from)); + args +} + +/// The arguments that read `entry` in `section`. +pub fn args(entry: &Entry, section: ChangeSection, space: Space) -> Vec { + let mut args = base(space); + match section { + ChangeSection::Staged => { + args.extend(["--cached", "-M", "--"].map(String::from)); + args.push(entry.path.clone()); + args.extend(entry.from.clone()); + } + ChangeSection::Untracked => { + args.extend(["--no-index", "--", "/dev/null"].map(String::from)); + args.push(entry.path.clone()); + } + _ => { + args.push("--".into()); + args.push(entry.path.clone()); + } + } + args +} + +/// What Git compares: a link's own path, else the file. +fn work_bytes(path: &Path) -> io::Result> { + match std::fs::symlink_metadata(path)?.file_type().is_symlink() { + true => Ok(std::fs::read_link(path)? + .into_os_string() + .into_encoded_bytes()), + false => std::fs::read(path), + } +} + +fn blob(git: &Git, root: &Path, name: &str) -> io::Result> { + git.run(root, &["cat-file", "blob", name]) +} + +fn size(git: &Git, root: &Path, name: &str) -> io::Result { + let said = git.run(root, &["cat-file", "-s", name])?; + String::from_utf8_lossy(&said) + .trim() + .parse() + .map_err(io::Error::other) +} + +/// Old and new blobs; None means the work tree. +fn sides( + entry: &Entry, + section: ChangeSection, + born: bool, +) -> (Option, Option>) { + let deleted = |letter| matches!(entry.state, State::Tracked { staged, unstaged } if (if section == ChangeSection::Staged { staged } else { unstaged }) == letter); + let old = match section { + ChangeSection::Staged if born && !deleted('A') => Some(format!( + "HEAD:{}", + entry.from.as_deref().unwrap_or(&entry.path) + )), + ChangeSection::Unstaged => Some(format!(":{}", entry.path)), + _ => None, + }; + let new = match (section, deleted('D')) { + (_, true) => None, + (ChangeSection::Staged, false) => Some(Some(format!(":{}", entry.path))), + _ => Some(None), + }; + (old, new) +} + +/// Reads what `entry` changed, as Git sees it. +pub fn read( + git: &Git, + root: &Path, + entry: &Entry, + section: ChangeSection, + space: Space, + born: bool, +) -> io::Result { + if matches!(entry.state, State::Conflicted(_)) { + return Ok(Found::Conflict(crate::conflict::read( + git, + root, + &entry.path, + )?)); + } + if entry.path.ends_with('/') { + return Ok(Found::Folder); + } + let args = args(entry, section, space); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let seen = match section { + ChangeSection::Untracked => git.run_or_one(root, &args)?, + _ => git.run(root, &args)?, + }; + let (old, new) = sides(entry, section, born); + compare(git, root, seen, &entry.path, old, new) +} + +/// Makes what Git printed for `path` into a view's parts. +pub fn compare( + git: &Git, + root: &Path, + seen: Vec, + path: &str, + old: Option, + new: Option>, +) -> io::Result { + let diff = diff::parse(&seen)?; + let work = root.join(path); + let extension = Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase); + let picture = PICTURES + .iter() + .find(|(e, _)| extension.as_deref() == Some(*e)) + .map(|(_, f)| *f); + match (&diff.body, picture) { + (Body::Binary, Some(format)) => { + let old = old.map(|name| blob(git, root, &name)).transpose()?; + let new = match new { + Some(Some(name)) => Some(blob(git, root, &name)?), + Some(None) => Some(work_bytes(&work)?), + None => None, + }; + Ok(Found::Picture { format, old, new }) + } + (Body::Binary | Body::Undecodable, _) => { + let old = old.map(|name| size(git, root, &name)).transpose()?; + let new = match new { + Some(Some(name)) => Some(size(git, root, &name)?), + Some(None) => Some(std::fs::symlink_metadata(&work)?.len()), + None => None, + }; + Ok(Found::Binary { old, new }) + } + (Body::Text(_), _) => { + let old = old.map(|name| blob(git, root, &name)).transpose()?; + let new = match new { + Some(Some(name)) => Some(blob(git, root, &name)?), + Some(None) => Some(work_bytes(&work)?), + None => None, + }; + // Not UTF-8 throughout: shown by size. + let text = |bytes: &Option>| { + bytes + .as_deref() + .is_none_or(|b| std::str::from_utf8(b).is_ok()) + }; + if !text(&old) || !text(&new) { + let size = |bytes: Option>| bytes.map(|b| b.len() as u64); + return Ok(Found::Binary { + old: size(old), + new: size(new), + }); + } + let old = old + .map(String::from_utf8) + .transpose() + .map_err(io::Error::other)?; + Ok(Found::Text { diff, seen, old }) + } + } +} + +fn line(raw: &diff::Raw, old: Option, new: Option) -> DiffLine { + DiffLine { + kind: raw.kind, + old, + new, + text: raw.text.trim_end_matches('\r').to_string().into(), + words: Vec::new(), + } +} + +/// Hunks from Git, the unchanged lines between folded. +pub fn stretches( + diff: &FileDiff, + old: Option<&str>, +) -> io::Result<(Vec, Vec>)> { + let Body::Text(hunks) = &diff.body else { + return Ok((Vec::new(), Vec::new())); + }; + // An empty file has no lines, not one empty line. + let olds: Vec<&str> = match old { + Some(text) if !text.is_empty() => text + .strip_suffix('\n') + .unwrap_or(text) + .split('\n') + .collect(), + _ => Vec::new(), + }; + let (mut out, mut places) = (Vec::new(), Vec::new()); + // The next old line not yet shown, from one. + let mut next = 1; + let fold = |from: usize, to: usize, shift: isize| -> io::Result> { + (from..to) + .map(|n| { + let text = olds + .get(n - 1) + .ok_or_else(|| io::Error::other("the old file is shorter than its diff"))?; + Ok(DiffLine { + kind: LineKind::Same, + old: Some(n), + new: n.checked_add_signed(shift), + text: text.trim_end_matches('\r').to_string().into(), + words: Vec::new(), + }) + }) + .collect() + }; + // Two sections share no old text to fold. + let folds = old.is_some() && diff.sections() < 2; + // New less old line numbers, after the last hunk. + let mut shift = 0isize; + for (at, hunk) in hunks.iter().enumerate() { + let (mut o, mut n) = (diff_start(hunk.old), diff_start(hunk.new)); + let before = n as isize - o as isize; + if folds && o > next { + out.push(Stretch::Folded(fold(next, o, before)?)); + places.push(None); + } + let mut lines = Vec::new(); + for raw in &hunk.lines { + lines.push(match raw.kind { + LineKind::Same => line(raw, Some(o), Some(n)), + LineKind::Removed => line(raw, Some(o), None), + LineKind::Added => line(raw, None, Some(n)), + }); + o += usize::from(raw.kind != LineKind::Added); + n += usize::from(raw.kind != LineKind::Removed); + } + mark_words(&mut lines); + let header = format!( + "@@ -{},{} +{},{} @@ {}", + hunk.old.0, hunk.old.1, hunk.new.0, hunk.new.1, hunk.tail + ); + out.push(Stretch::Hunk { + header: header.trim_end().to_string().into(), + lines, + }); + places.push(Some(at)); + next = o; + shift = n as isize - o as isize; + } + if folds && next <= olds.len() { + out.push(Stretch::Folded(fold(next, olds.len() + 1, shift)?)); + places.push(None); + } + Ok((out, places)) +} + +/// A range's first line number; an empty range starts after. +fn diff_start((start, count): (usize, usize)) -> usize { + if count == 0 { start + 1 } else { start } +} + +/// Makes the view on the UI thread. +pub fn view(found: Found) -> io::Result { + Ok(match found { + Found::Text { diff, seen, old } => { + let (stretches, hunks) = stretches(&diff, old.as_deref())?; + View::Text { + diff: Arc::new(diff), + seen: Arc::new(seen), + stretches: Rc::new(stretches), + hunks, + } + } + Found::Picture { format, old, new } => { + let image = |bytes: Vec| Arc::new(Image::from_bytes(format, bytes)); + View::Picture { + old: old.map(image), + new: new.map(image), + } + } + Found::Binary { old, new } => View::Binary { old, new }, + Found::Folder => View::Folder, + Found::Conflict(stages) => View::Conflict(stages), + }) +} diff --git a/apps/ely-tree/src/side_log.rs b/apps/ely-tree/src/side_log.rs new file mode 100644 index 0000000..a8d5dea --- /dev/null +++ b/apps/ely-tree/src/side_log.rs @@ -0,0 +1,171 @@ +//! The commit box, and history in the left panel. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + feedback::{EmptyState, ErrorView}, + forms::{Choice, SearchInput, Select}, + git::{CommitInput, CommitList}, + primitives::IconName, + theme::{ActiveTheme as _, ControlSize}, +}; +use gpui::{ + AnyElement, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, div, + prelude::FluentBuilder as _, +}; + +use crate::{ + changes::Changes, + commits::Find, + tree::{Tree, text}, +}; + +/// Who commits, how Git signs, and the message. +pub fn commit_box( + tree: &Entity, + branch: SharedString, + staged: usize, + cx: &mut Context, +) -> AnyElement { + let read = tree.read(cx); + let colors = cx.theme().colors.clone(); + let (who, signs) = match read.active().and_then(|opened| opened.me.as_ref()) { + Some(Ok(me)) => { + let who = match (&me.name, &me.email) { + (Some(name), Some(email)) => format!("{name} <{email}>").into(), + _ => text(cx, "tree.me.unset", &[]), + }; + let signs = me + .signs + .as_deref() + .map(|format| text(cx, "tree.me.signs", &[("format", format)])); + (who, signs) + } + Some(Err(error)) => (text(cx, "tree.me.unread", &[("error", error)]), None), + None => (SharedString::default(), None), + }; + let (me, amend, commit) = (tree.downgrade(), tree.downgrade(), tree.downgrade()); + let input = CommitInput::new("tree-commit", &read.subject, &read.body, branch, staged) + .amend(read.amend) + .on_amend(move |on, _, cx| { + amend.update(cx, |tree, cx| tree.set_amend(on, cx)).ok(); + }) + .when(!read.amend_loading, |input| { + input.on_commit(move |_, cx| { + commit.update(cx, |tree, cx| tree.commit(cx)).ok(); + }) + }); + div() + .flex() + .flex_col() + .gap_1() + .p_2() + .border_t_1() + .border_color(colors.border) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + Button::new("tree-me", who) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + me.update(cx, |tree, cx| tree.ask_identity(window, cx)).ok(); + }), + ) + .children(signs.map(|signs| div().text_color(colors.fg_muted).child(signs))), + ) + .child(input) + .into_any_element() +} + +fn find_key(find: Find) -> &'static str { + match find { + Find::Message => "message", + Find::Author => "author", + Find::Hash => "hash", + Find::Content => "content", + } +} + +/// The search row, then the commits. +pub fn history(tree: &Entity, cx: &mut Context) -> AnyElement { + let read = tree.read(cx); + let Some(opened) = read.active() else { + return div().into_any_element(); + }; + let (id, hist) = (opened.id, &opened.hist); + let choices = ["message", "author", "hash", "content"] + .map(|key| Choice::new(key, text(cx, &format!("tree.find.{key}"), &[]))); + let finding = tree.downgrade(); + let find = Select::new("tree-find", choices) + .label(text(cx, "tree.find", &[])) + .size(ControlSize::Sm) + .selected(find_key(read.find)) + .on_change(move |key, _, cx| { + let find = match key.as_ref() { + "author" => Find::Author, + "hash" => Find::Hash, + "content" => Find::Content, + _ => Find::Message, + }; + finding + .update(cx, |tree, cx| { + tree.find = find; + tree.search(cx); + }) + .ok(); + }); + let list = match (&hist.error, hist.rows.is_empty()) { + (Some(error), _) => ErrorView::new( + "tree-log-error", + IconName::TriangleAlert, + text(cx, "tree.log.unread", &[]), + ) + .detail(error.clone()) + .into_any_element(), + (None, true) if hist.read && !hist.loading => { + let key = if hist.query.is_some() { + "tree.log.none_found" + } else { + "tree.log.none" + }; + EmptyState::new( + "tree-log-empty", + IconName::GitCommitHorizontal, + text(cx, key, &[]), + ) + .into_any_element() + } + _ => { + let (pick, more) = (tree.downgrade(), tree.downgrade()); + CommitList::new(("tree-log", id), hist.rows.clone()) + .when(hist.query.is_none(), |list| list.graph(hist.graph.clone())) + .when_some(hist.pick.commit.clone(), |list, picked| { + list.selected(picked) + }) + .on_pick(move |commit, _, cx| { + pick.update(cx, |tree, cx| tree.pick_commit(commit, cx)) + .ok(); + }) + .on_end(move |cx| { + more.update(cx, |tree, cx| tree.more_log(cx)).ok(); + }) + .into_any_element() + } + }; + div() + .size_full() + .flex() + .flex_col() + .child( + div().flex().gap_2().p_2().child(find).child( + div() + .flex_1() + .child(SearchInput::new("tree-search", &read.search).size(ControlSize::Sm)), + ), + ) + .child(div().flex_1().min_h_0().child(list)) + .into_any_element() +} diff --git a/apps/ely-tree/src/side_places.rs b/apps/ely-tree/src/side_places.rs new file mode 100644 index 0000000..1cdd5de --- /dev/null +++ b/apps/ely-tree/src/side_places.rs @@ -0,0 +1,336 @@ +//! The Repository panel: stashes, remotes, worktrees, more. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + git::{Stash, StashList}, + lists::{List, ListItem}, + theme::{ActiveTheme as _, ControlSize}, +}; +use gpui::{ + AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, + Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, + prelude::FluentBuilder as _, +}; + +use crate::{ + changes::Changes, + hist::when, + places::{Place, Read}, + structure::{Checkout, LfsFile}, + tree::{Tree, text}, +}; + +type Act = Box)>; + +fn button(tree: &Entity, id: &'static str, words: SharedString, act: Act) -> Button { + let this = tree.downgrade(); + Button::new(id, words) + .size(ControlSize::Sm) + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| { + this.update(cx, |tree, cx| act(tree, window, cx)).ok(); + }) +} + +/// A list's name, with its own buttons. +fn heading(name: SharedString, buttons: Vec
6
"; + assert!(cx.update(|app| !view.read(app).holds_copy(other))); + let foreign = "6"; + assert!( + cx.update(|app| !view.read(app).holds_copy(foreign)), + "another copy's mark" + ); +} diff --git a/crates/ely-lok/src/view/tests/commands.rs b/crates/ely-lok/src/view/tests/commands.rs new file mode 100644 index 0000000..ee189a9 --- /dev/null +++ b/crates/ely-lok/src/view/tests/commands.rs @@ -0,0 +1,167 @@ +use super::*; +use gpui::TestAppContext; + +/// Steps the scheduler until `done` or the budget ends. +fn settle(cx: &TestAppContext, done: impl Fn(&App) -> bool) -> bool { + for _ in 0..60 { + for _ in 0..20 { + cx.executor().tick(); + } + if cx.update(|app| done(app)) { + return true; + } + } + false +} + +#[test] +fn run_reaches_the_kernel_and_state_comes_back() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = TestAppContext::single(); + let (view, _) = cx.update(|app| live_view(app, &install)); + cx.update(|app| { + view.update(app, |view, _| { + assert!( + view.run("format.dialog", &[]).is_err(), + "unknown ids fail fast" + ); + assert!( + view.run("format.size", &[]).is_err(), + "missing values fail fast" + ); + view.commit(None, "a"); + view.run("edit.select_all", &[]).expect("select all queues"); + view.run("format.bold", &[]).expect("bold queues"); + view.run("format.size", &["20"]).expect("size queues"); + }) + }); + let landed = settle(&cx, |app| { + let view = view.read(app); + view.state(".uno:Bold") == Some("true") + && view.state(".uno:FontHeight") == Some("20") + && view.a11y.text == "aHello 世界" + }); + assert!(landed, "the kernel state reaches the view"); + cx.update(|app| { + let view = view.read(app); + assert_eq!( + view.a11y.text, "aHello 世界", + "the a11y text follows the paragraph" + ); + }); +} + +#[test] +fn a_kernel_dialog_reaches_apps_as_an_error() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = TestAppContext::single(); + let body = crate::client::tests::input::FODS; + let (view, _) = cx.update(|app| live_view_of(app, &install, "in.fods", body)); + let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + cx.update(|app| { + let errors = errors.clone(); + app.subscribe(&view, move |_, error: &LokError, _| { + errors.borrow_mut().push(error.0.clone()) + }) + .detach(); + view.update(app, |view, _| { + view.key_down(&gpui::Keystroke::parse("backspace").expect("backspace")); + }); + }); + let reported = settle(&cx, |_| !errors.borrow().is_empty()); + assert!(reported, "the blocked dialog reaches subscribers"); + assert!( + errors.borrow()[0].contains("Delete Contents"), + "{:?}", + errors.borrow() + ); +} + +#[test] +fn a_dead_worker_reaches_apps_while_idle() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = TestAppContext::single(); + let (view, shared) = cx.update(|app| live_view(app, &install)); + let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + cx.update(|app| { + let errors = errors.clone(); + app.subscribe(&view, move |_, error: &LokError, _| { + errors.borrow_mut().push(error.0.clone()) + }) + .detach(); + }); + let pid = shared.lock().expect("the worker lock").child.id(); + std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .status() + .expect("kill runs"); + let reported = settle(&cx, |_| !errors.borrow().is_empty()); + assert!(reported, "the idle watch reports the dead engine"); + assert!( + errors.borrow()[0].contains("engine stopped"), + "{:?}", + errors.borrow() + ); +} + +#[test] +fn a_handle_drag_resizes_the_selected_image() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = TestAppContext::single(); + let (view, _) = cx.update(|app| live_view(app, &install)); + let dir = tempfile::tempdir().expect("a temp dir"); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(300, 200, image::Rgba([0, 0, 255, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("the png encodes"); + let path = dir.path().join("blue.png"); + std::fs::write(&path, png).expect("the png writes"); + let url = crate::file_url(&path); + cx.update(|app| view.update(app, |view, _| view.run("image.insert", &[&url]))) + .expect("the insert queues"); + assert!( + settle(&cx, |app| view.read(app).graphic.is_some()), + "the image is selected" + ); + let frame = cx.update(|app| view.read(app).graphic).expect("selected"); + let corner = super::super::handles::points(frame)[7]; + let to = (corner.0 - frame.2 / 3, corner.1 - frame.3 / 3); + cx.update(|app| { + view.update(app, |view, _| { + assert!( + view.grab(view.window_point(corner)), + "the corner handle grabs" + ); + view.grip = view + .grip + .map(|grip| super::super::handles::Grip { to, ..grip }); + assert_eq!( + view.frame(), + Some(super::super::handles::dragged(frame, 7, to)) + ); + let grip = view.grip.take().expect("held"); + view.send(super::super::input::Input::Drag { + from: grip.from, + to: grip.to, + }); + }) + }); + let resized = settle(&cx, |app| { + view.read(app) + .graphic + .is_some_and(|now| now.2 < frame.2 && now.3 < frame.3) + }); + assert!(resized, "the kernel resized the image"); +} diff --git a/crates/ely-lok/src/view/tests/fit.rs b/crates/ely-lok/src/view/tests/fit.rs new file mode 100644 index 0000000..fcea4fb --- /dev/null +++ b/crates/ely-lok/src/view/tests/fit.rs @@ -0,0 +1,90 @@ +use super::*; + +#[test] +fn a_fitted_slide_fills_the_viewport_centred() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = gpui::TestAppContext::single(); + cx.update(|app| { + let deck = crate::client::tests::show::DECK; + let (view, _) = live_view_of(app, &install, "deck.fodp", deck); + view.update(app, |view, cx| { + view.fit_page(cx); + let bounds = Bounds { + origin: point(px(10.0), px(20.0)), + size: size(px(800.0), px(600.0)), + }; + view.prepare(bounds); + let (w, h) = view.doc_size; + let want = (800.0 * 15.0 / w as f32).min(600.0 * 15.0 / h as f32) * 0.94; + assert!( + (view.geom.zoom - want).abs() < 0.001, + "{} vs {want}", + view.geom.zoom + ); + assert!( + view.spare.0 > 0.0 && view.spare.1 > 0.0, + "centred: {:?}", + view.spare + ); + let corner = view.window_point((0, 0)); + assert_eq!(view.doc_point(corner), (0, 0), "points map both ways"); + // A cursor far out must not move a fitted page. + view.reach = (w * 3, h * 3); + view.prepare(bounds); + assert_eq!(view.window_point((0, 0)), corner, "the page stays put"); + let far = view.window_point((w, h)); + assert!( + f32::from(far.y) < 620.0 && f32::from(far.x) < 810.0, + "the page fits" + ); + }); + }); +} + +#[test] +fn a_resized_slide_reaches_the_view_and_still_fits() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = gpui::TestAppContext::single(); + let view = cx.update(|app| { + let deck = crate::client::tests::show::DECK; + live_view_of(app, &install, "deck.fodp", deck).0 + }); + cx.update(|app| { + view.update(app, |view, cx| { + view.fit_page(cx); + view.run("slide.size", &["300000", "300000", "true"]) + .expect("sent"); + }) + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while cx.update(|app| view.read(app).doc_size.0 < 100_000) { + assert!( + std::time::Instant::now() < deadline, + "the view never read the new size" + ); + if !cx.executor().tick() { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + cx.update(|app| { + view.update(app, |view, _| { + let bounds = Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(800.0), px(600.0)), + }; + view.prepare(bounds); + let (w, h) = view.doc_size; + let far = view.window_point((w, h)); + assert!( + f32::from(far.x) <= 800.0 && f32::from(far.y) <= 600.0, + "a 3 m page fits: {far:?}" + ); + }) + }); +} diff --git a/crates/ely-lok/src/view/tests/input.rs b/crates/ely-lok/src/view/tests/input.rs new file mode 100644 index 0000000..ce3f6cf --- /dev/null +++ b/crates/ely-lok/src/view/tests/input.rs @@ -0,0 +1,271 @@ +use super::*; +use crate::client::tests::input::paragraphs; +use ely_platform::clipboard::{self, Clip}; +use gpui::{InputHandler, Keystroke, TestAppContext, Window}; + +/// Steps the scheduler so queued input reaches LOK. +fn flush(cx: &TestAppContext) { + for _ in 0..80 { + cx.executor().tick(); + } +} + +/// The document's paragraphs, saved through the shared worker. +fn saved(view: &Entity, cx: &mut TestAppContext) -> Vec { + let out = tempfile::tempdir() + .expect("a temp dir") + .keep() + .join("out.fodt"); + cx.update(|app| { + let view = view.read(app); + let mut worker = view.worker.lock().expect("the worker lock"); + view.doc + .save_as(&mut worker, &format!("file://{}", out.display()), "fodt") + .expect("the save works"); + }); + paragraphs(&std::fs::read_to_string(&out).expect("the save reads")) +} + +fn typed(key: &str) -> Keystroke { + let mut stroke = Keystroke::parse(key).expect("a keystroke"); + stroke.key_char = Some(key.to_string()); + stroke +} + +#[test] +fn typing_and_composing_reach_the_document() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let mut cx = TestAppContext::single(); + let (view, _) = cx.update(|app| live_view(app, &install)); + cx.update(|app| { + view.update(app, |view, _| { + assert!(!view.key_down(&typed("a")), "text waits for the IME"); + view.commit(None, "ab"); + view.compose(None, "n", None); + view.compose(None, "ni", None); + view.compose(None, "你", None); + assert_eq!(view.marked.as_ref().map(|m| m.text.as_str()), Some("你")); + view.commit(None, "你"); + assert!(view.marked.is_none()); + view.compose(None, "x", None); + view.compose(None, "", None); + assert!(view.marked.is_none(), "an empty composition cancels"); + assert!(view.key_down(&Keystroke::parse("enter").expect("enter"))); + }) + }); + flush(&cx); + assert_eq!(saved(&view, &mut cx), ["ab你", "Hello 世界"]); +} + +struct Blank; + +impl gpui::Render for Blank { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl gpui::IntoElement { + gpui::Empty + } +} + +#[test] +fn the_platform_ime_callbacks_compose_in_utf16() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let mut cx = TestAppContext::single(); + let (view, _) = cx.update(|app| live_view(app, &install)); + let window = cx.add_window(|_, _| Blank); + cx.update_window(window.into(), |_, window, app| { + let bounds = Bounds::default(); + let mut ime = gpui::ElementInputHandler::new(bounds, view.clone()); + ime.replace_and_mark_text_in_range(None, "nihao", Some(2..2), window, app); + let selected = ime + .selected_text_range(false, window, app) + .expect("a selection"); + assert_eq!(selected.range, 2..2, "the IME caret sits inside"); + let mut adjusted = None; + let head = ime.text_for_range(0..2, &mut adjusted, window, app); + assert_eq!(head.as_deref(), Some("ni")); + assert_eq!(adjusted, Some(0..2)); + assert_eq!(ime.marked_text_range(window, app), Some(0..5)); + ime.replace_and_mark_text_in_range(Some(0..2), "你", Some(1..1), window, app); + let marked = view.read(app).marked.as_ref().map(|m| m.text.clone()); + assert_eq!(marked.as_deref(), Some("你hao")); + let selected = ime.selected_text_range(false, window, app).map(|s| s.range); + assert_eq!(selected, Some(1..1)); + ime.replace_text_in_range(Some(1..4), "好", window, app); + assert_eq!( + ime.marked_text_range(window, app), + None, + "the commit ends it" + ); + ime.replace_and_mark_text_in_range(None, "x", None, window, app); + ime.unmark_text(window, app); + }) + .expect("the window lives"); + flush(&cx); + assert_eq!(saved(&view, &mut cx), ["你好xHello 世界"]); +} + +#[test] +fn a_cut_keeps_the_text_when_the_clipboard_fails() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let mut cx = TestAppContext::single(); + let (view, shared) = cx.update(|app| live_view(app, &install)); + let doc = cx.update(|app| view.read(app).doc.clone()); + { + let mut worker = shared.lock().expect("the worker lock"); + worker + .run(&doc, "edit.select_all", &[]) + .expect("select all"); + let failed = crate::view::clip::copy_out(&mut worker, &doc, true, |_| { + Err(std::io::Error::other("the clipboard refused")) + }); + assert!(failed.is_err(), "the write failure surfaces"); + assert!( + !worker.copy(&doc).expect("copy runs").is_empty(), + "the selection stays" + ); + } + assert_eq!(saved(&view, &mut cx), ["Hello 世界"], "the text stays"); +} + +#[test] +fn a_drag_selects_and_events_carry_the_cursor() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = TestAppContext::single(); + let (view, _) = cx.update(|app| live_view(app, &install)); + cx.update(|app| { + view.update(app, |view, _| { + let none = gpui::Modifiers::default(); + let y = px(view.geom.px_of(1500)); + view.mouse(0, point(px(view.geom.px_of(1500)), y), 1, 1, &none); + view.mouse(2, point(px(view.geom.px_of(4000)), y), 1, 1, &none); + view.mouse(1, point(px(view.geom.px_of(4000)), y), 1, 1, &none); + }) + }); + let mut selected = false; + for _ in 0..40 { + flush(&cx); + selected = cx.update(|app| !view.read(app).selection.is_empty()); + if selected { + break; + } + } + assert!(selected, "the drag selection reaches the view"); + cx.update(|app| { + let view = view.read(app); + let bounds = view.cursor_bounds().expect("the cursor arrived"); + assert!(bounds.size.height > px(0.0), "the caret has height"); + }); +} + +#[test] +fn a_dead_document_surfaces_an_error() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = TestAppContext::single(); + let (view, shared) = cx.update(|app| live_view(app, &install)); + let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + cx.update(|app| { + let errors = errors.clone(); + app.subscribe(&view, move |_, error: &LokError, _| { + errors.borrow_mut().push(error.0.clone()) + }) + .detach(); + let doc = view.read(app).doc.clone(); + shared + .lock() + .expect("the worker lock") + .close_doc(doc) + .expect("the doc closes"); + view.update(app, |view, _| { + view.key_down(&Keystroke::parse("enter").expect("enter")); + }); + }); + flush(&cx); + let errors = errors.borrow(); + assert!( + errors + .iter() + .any(|error| error.contains("no such document")), + "the failure reaches subscribers: {errors:?}" + ); +} + +#[test] +fn the_clipboard_moves_html_tables_and_images() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() { + eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard"); + return; + } + let mut cx = TestAppContext::single(); + let (view, _) = cx.update(|app| live_view(app, &install)); + let primary = if cfg!(target_os = "macos") { + "cmd-a" + } else { + "ctrl-a" + }; + cx.update(|app| { + view.update(app, |view, _| { + view.key_down(&Keystroke::parse(primary).expect("select all")); + view.send(crate::view::input::Input::Copy { cut: false }); + }) + }); + flush(&cx); + let Some(Clip::Html { html, text }) = clipboard::read().expect("the clipboard reads") else { + panic!("the copy lands as html"); + }; + assert!( + html.contains("Hello") && text == "Hello 世界", + "{html} / {text}" + ); + + clipboard::write(&Clip::Html { + html: "
leftright
".into(), + text: "left\tright".into(), + }) + .expect("the table writes"); + cx.update(|app| view.update(app, |view, _| view.send(crate::view::input::Input::Paste))); + flush(&cx); + let text = saved(&view, &mut cx); + assert!( + text.contains(&"left".to_string()) && text.contains(&"right".to_string()), + "{text:?}" + ); + + let mut png = Vec::new(); + image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("the png encodes"); + clipboard::write(&Clip::Png(png)).expect("the image writes"); + cx.update(|app| view.update(app, |view, _| view.send(crate::view::input::Input::Paste))); + flush(&cx); + let out = tempfile::tempdir() + .expect("a temp dir") + .keep() + .join("img.fodt"); + cx.update(|app| { + let view = view.read(app); + let mut worker = view.worker.lock().expect("the worker lock"); + view.doc + .save_as(&mut worker, &format!("file://{}", out.display()), "fodt") + .expect("the save works"); + }); + let xml = std::fs::read_to_string(&out).expect("the save reads"); + assert!(xml.contains(" + + + top + + bottom + +"#; + +#[test] +fn a_tall_sheet_paints_and_keeps_only_tiles_near_the_screen() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = gpui::TestAppContext::single(); + cx.update(|app| { + let (view, _) = live_view_of(app, &install, "tall.fods", TALL); + view.update(app, |view, cx| { + let bounds = Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(800.0), px(600.0)), + }; + let side = view.geom.tile_pixels() as usize; + let painted = |generation| { + Ok(crate::Tile { + generation, + pixels: vec![0u8; side * side * 4], + }) + }; + // 800 by 600 needs 5 by 4 tiles. + view.prepare(bounds); + let top = view.on_screen.clone(); + assert!(!top.is_empty() && top.len() <= 20, "{} tiles", top.len()); + for key in &top { + view.finish_tile(*key, view.geom, painted(view.generation), cx); + } + assert_eq!(view.tile_count(), top.len(), "the top paints"); + view.scroll_by(0.0, -3.0e6, cx); + view.prepare(bounds); + let bottom = view.on_screen.clone(); + assert!(bottom.len() <= 20, "{} tiles at the bottom", bottom.len()); + assert!( + bottom.iter().all(|key| key.1 > 1000), + "far down: {bottom:?}" + ); + assert!( + top.iter().all(|key| !view.tiles.contains_key(key)), + "the top's tiles are gone" + ); + assert!( + view.fetching.len() <= 20 + top.len(), + "only shown tiles fetch" + ); + }); + }); +} diff --git a/crates/ely-lok/src/view/tests/theme.rs b/crates/ely-lok/src/view/tests/theme.rs new file mode 100644 index 0000000..af5b99b --- /dev/null +++ b/crates/ely-lok/src/view/tests/theme.rs @@ -0,0 +1,97 @@ +use super::*; +use gpui::TestAppContext; + +/// Steps the scheduler until `done` or ten seconds pass. +fn settle(cx: &TestAppContext, done: impl Fn(&App) -> bool) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline { + if !cx.executor().tick() { + cx.executor() + .advance_clock(std::time::Duration::from_millis(20)); + } + if cx.update(|app| done(app)) { + return true; + } + } + false +} + +/// The view's 800 by 600 pixel area. +fn area() -> Bounds { + Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(800.0), px(600.0)), + } +} + +#[test] +fn the_view_paints_the_app_theme_and_back() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = TestAppContext::single(); + let (view, worker) = cx.update(|app| live_view(app, &install)); + let doc = cx.update(|app| view.read(app).doc.clone()); + let mut shades = Vec::new(); + for dark in [true, false] { + cx.update(|app| view.update(app, |view, _| view.follow_theme(dark))); + assert!( + settle(&cx, |app| view.read(app).settled()), + "the theme goes out" + ); + let mut worker = worker.lock().expect("the worker"); + let tile = doc + .paint_tile(&mut worker, (1440, 1440, 3840, 3840), (64, 64)) + .expect("a tile"); + let sum: u64 = tile + .pixels + .chunks_exact(4) + .map(|px| u64::from(px[0]) + u64::from(px[1]) + u64::from(px[2])) + .sum(); + shades.push(sum / (64 * 64 * 3)); + } + assert!( + shades[0] < 100 && shades[1] > 200, + "dark then light: {shades:?}" + ); +} + +#[test] +fn a_failed_tile_reports_and_retries_on_a_move() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = TestAppContext::single(); + let (view, _) = cx.update(|app| live_view(app, &install)); + let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + cx.update(|app| { + let errors = errors.clone(); + app.subscribe(&view, move |_, error: &LokError, _| { + errors.borrow_mut().push(error.0.clone()) + }) + .detach(); + view.update(app, |view, cx| { + let geom = view.geom; + view.prepare(area()); + view.fetching.remove(&(0, 0)); + view.finish_tile((0, 0), geom, Err(io::Error::other("no paint")), cx); + assert!(view.failed.contains(&((0, 0), view.generation))); + view.prepare(area()); + assert!(!view.fetching.contains(&(0, 0)), "no retry storm in place"); + view.scroll_by(0.0, -40.0, cx); + view.prepare(area()); + assert!(view.fetching.contains(&(0, 0)), "a move retries the tile"); + }); + }); + assert!( + settle(&cx, |_| !errors.borrow().is_empty()), + "the failure reaches the app" + ); + assert!( + errors.borrow()[0].contains("no paint"), + "{:?}", + errors.borrow() + ); +} diff --git a/crates/ely-lok/src/view/zoom.rs b/crates/ely-lok/src/view/zoom.rs new file mode 100644 index 0000000..bf506af --- /dev/null +++ b/crates/ely-lok/src/view/zoom.rs @@ -0,0 +1,82 @@ +//! Zooming the page view about the pointer (LOK-04). + +use gpui::{Context, EventEmitter, PinchEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window}; + +use super::LokView; + +/// The zoom changed, by key, wheel or pinch. +pub struct Zoomed; + +impl EventEmitter for LokView {} + +/// The steps of zooming in and out. +pub const ZOOM_STEPS: [f32; 10] = [0.25, 0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; +const LEAST: f32 = 0.25; +const MOST: f32 = 4.0; + +impl LokView { + /// The zoom now; 1.0 is the page's actual size. + pub fn zoom(&self) -> f32 { + self.geom.zoom + } + + /// Whether the zoom follows the viewport (a whole page). + pub fn fitting(&self) -> bool { + self.fit + } + + /// Sets the zoom; the grid is repainted (LOK-04). + pub fn set_zoom(&mut self, zoom: f32, cx: &mut Context) { + self.zoom_at(zoom, None, cx); + } + + /// The next step in or out from the zoom now. + pub fn zoom_step(&mut self, larger: bool, cx: &mut Context) { + let now = self.geom.zoom; + let next = match larger { + true => ZOOM_STEPS.into_iter().find(|step| *step > now + 0.001).unwrap_or(MOST), + false => ZOOM_STEPS.into_iter().rev().find(|step| *step < now - 0.001).unwrap_or(LEAST), + }; + self.zoom_at(next, None, cx); + } + + /// Zooms keeping `at` (window pixels) still; none keeps the middle. + pub fn zoom_at(&mut self, zoom: f32, at: Option>, cx: &mut Context) { + let zoom = zoom.clamp(LEAST, MOST); + let (ax, ay) = match at { + Some(at) => (f32::from(at.x - self.origin.x), f32::from(at.y - self.origin.y)), + None => (self.viewport.0 / 2.0, self.viewport.1 / 2.0), + }; + let under = (self.scroll.0 + self.geom.twips_of(ax), self.scroll.1 + self.geom.twips_of(ay)); + self.fit = false; + if (zoom - self.geom.zoom).abs() > 0.0001 { + self.geom.zoom = zoom; + self.retire_tiles(); + self.failed.clear(); + } + self.scroll = (under.0 - self.geom.twips_of(ax), under.1 - self.geom.twips_of(ay)); + self.clamp_scroll(); + log::debug!("lok: zoom {:.0}%", zoom * 100.0); + cx.emit(Zoomed); + cx.notify(); + } + + /// Command with the wheel zooms; otherwise it scrolls. + pub(super) fn on_wheel(&mut self, event: &ScrollWheelEvent, window: &mut Window, cx: &mut Context) { + if !event.modifiers.platform { + return self.on_scroll(event, window, cx); + } + let dy = match event.delta { + ScrollDelta::Pixels(point) => f32::from(point.y), + ScrollDelta::Lines(point) => point.y * 40.0, + }; + let zoom = self.geom.zoom * (dy / 200.0).exp(); + self.zoom_at(zoom, Some(event.position), cx); + } + + /// A trackpad pinch zooms about its centre. + pub(super) fn on_pinch(&mut self, event: &PinchEvent, _: &mut Window, cx: &mut Context) { + let zoom = self.geom.zoom * (1.0 + event.delta); + self.zoom_at(zoom, Some(event.position), cx); + } +} diff --git a/crates/ely-lok/src/worker.rs b/crates/ely-lok/src/worker.rs new file mode 100644 index 0000000..b739f53 --- /dev/null +++ b/crates/ely-lok/src/worker.rs @@ -0,0 +1,469 @@ +use std::{ + io::{self, Write}, + path::Path, + sync::Mutex, +}; + +use crate::ffi::{Document, Lok}; +use crate::ipc::{self, Pack, Unpack, tag}; + +mod events; +#[cfg(unix)] +mod fatal; +mod input; +mod password; +mod revision; +mod slides; +mod teardown; +mod text; + +/// English kernel, desktop hints; macros stay off (LOK-11). +const LOAD_OPTIONS: &str = "Language=en-US,DeviceFormFactor=desktop"; + +/// The worker's whole life: init, serve frames, die on shutdown. +pub fn run( + install: &str, + profile_dir: &str, + input: impl io::Read + Send + 'static, + output: impl Write + Send + 'static, +) -> io::Result<()> { + set_output(Box::new(output)); + let profile_dir = std::fs::canonicalize(profile_dir)?; + let profile_url = profile_url_of(&profile_dir); + let _ = teardown::PROFILE.set(profile_dir.clone()); + let lok = Lok::hook(Path::new(install), &profile_url)?; + #[cfg(unix)] + fatal::watch(); + log::info!("lok-worker: up, profile {}", profile_dir.display()); + serve(lok, input) +} + +#[cfg(target_os = "macos")] +fn serve(lok: Lok, input: impl io::Read + Send + 'static) -> io::Result<()> { + let lok = Box::leak(Box::new(lok)); + pin(lok); + lok.register_callback(password::on_kit); + lok.register_any_input(waiting); + unsafe { + gcd::dispatch_async_f(gcd::main_queue(), std::ptr::null_mut(), mark_pumping); + } + std::thread::spawn(move || reader(input)); + std::thread::spawn(events::push); + lok.run_loop(); + Ok(()) +} + +/// Requests queued for the main thread and not yet answered. +#[cfg(target_os = "macos")] +static PENDING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Lets idle jobs yield to a waiting request. +#[cfg(target_os = "macos")] +extern "C" fn waiting(_: *mut std::ffi::c_void, _: i32) -> bool { + PENDING.load(std::sync::atomic::Ordering::SeqCst) > 0 +} + +/// The queue's first drained block. +#[cfg(target_os = "macos")] +extern "C" fn mark_pumping(_: *mut std::ffi::c_void) { + PUMPING.store(true, std::sync::atomic::Ordering::SeqCst); +} + +/// Set once the main queue drains its first block. +#[cfg(target_os = "macos")] +static PUMPING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// The queue's last drained block. +#[cfg(target_os = "macos")] +extern "C" fn quit_block(_: *mut std::ffi::c_void) { + log::info!("lok-worker: quit on drained queue"); + teardown::teardown(0); +} + +#[cfg(target_os = "macos")] +fn reader(mut input: impl io::Read) { + while !PUMPING.load(std::sync::atomic::Ordering::SeqCst) { + std::thread::yield_now(); + } + loop { + match ipc::read_frame(&mut input) { + Ok(Some((tag, id, payload))) => { + let request = Box::into_raw(Box::new((tag, id, payload))); + PENDING.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + unsafe { + gcd::dispatch_async_f(gcd::main_queue(), request.cast(), handle); + } + if tag == tag::SHUTDOWN { + return; + } + } + Ok(None) => { + log::info!("lok-worker: stdin closed"); + unsafe { + gcd::dispatch_async_f(gcd::main_queue(), std::ptr::null_mut(), quit_block); + } + return; + } + Err(error) => { + log::warn!("lok-worker: frame read failed: {error}"); + quit(1); + } + } + } +} + +#[cfg(target_os = "macos")] +extern "C" fn handle(context: *mut std::ffi::c_void) { + let (tag, id, payload) = *unsafe { Box::from_raw(context.cast::<(u8, u64, Vec)>()) }; + let lok = unsafe { &*LOK.load(std::sync::atomic::Ordering::SeqCst) }; + PENDING.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + answer(lok, tag, id, &payload); +} + +/// The pinned kit, for callbacks. +fn lok() -> &'static Lok { + unsafe { &*LOK.load(std::sync::atomic::Ordering::SeqCst) } +} + +static LOK: std::sync::atomic::AtomicPtr = + std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()); + +#[cfg(target_os = "macos")] +mod gcd { + use std::ffi::c_void; + unsafe extern "C" { + static _dispatch_main_q: c_void; + pub fn dispatch_async_f( + queue: *mut c_void, + context: *mut c_void, + work: extern "C" fn(*mut c_void), + ); + } + /// The main queue: the header's macro target. + pub fn main_queue() -> *mut c_void { + unsafe { &_raw as *const c_void as *mut c_void } + } + use _dispatch_main_q as _raw; +} + +#[cfg(not(target_os = "macos"))] +fn serve(lok: Lok, mut input: impl io::Read) -> io::Result<()> { + let lok = Box::leak(Box::new(lok)); + pin(lok); + lok.register_callback(password::on_kit); + std::thread::spawn(events::push); + loop { + match ipc::read_frame(&mut input)? { + Some((tag, id, payload)) => answer(lok, tag, id, &payload), + None => { + log::info!("lok-worker: stdin closed"); + teardown::teardown(0); + } + } + } +} + +/// Handles one frame and writes its answer. +fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) { + let out = OUT.get().expect("the output is set"); + let mut out = out.lock().expect("the output lock"); + if tag == tag::SHUTDOWN { + let outcome = teardown::teardown_work(); + let (reply_tag, body) = match &outcome { + Ok(()) => (tag::OK, Vec::new()), + Err(error) => (tag::ERR, Pack::new().str(&error.to_string()).bytes()), + }; + let _ = ipc::write_frame(&mut *out, reply_tag, id, &body); + log::info!("lok-worker: shutdown"); + quit(if outcome.is_ok() { 0 } else { 1 }); + } + // Bumped first: events it causes carry the new revision. + revision::bump(tag, payload); + let mut unpack = Unpack::new(payload); + let unpack = &mut unpack; + let outcome = match tag { + tag::BUILD_ID => Ok(Pack::new().str(&lok.build_id()).bytes()), + tag::LOAD => unpack + .str() + .and_then(|url| Ok((url, unpack.str()?))) + .and_then(|(url, password)| load(lok, &url, password, "")) + .map(|id| Pack::new().u64(id).bytes()), + tag::IMPORT => text::import(lok, unpack).map(|id| Pack::new().u64(id).bytes()), + tag::EXPORT_TEXT => text::export(unpack).map(|()| Vec::new()), + tag::DOC_SIZE => unpack + .u64() + .and_then(|id| with_doc(id, |doc| doc.size())) + .map(|(width, height)| Pack::new().i64(width).i64(height).bytes()), + tag::SAVE_AS => save(unpack).map(|()| Vec::new()), + tag::EXPORT_PDF => export_pdf(unpack).map(|()| Vec::new()), + tag::CLOSE_DOC => unpack.u64().map(|id| { + close(id); + Vec::new() + }), + tag::EXPECT => revision::expect(unpack), + tag::REVISION => revision::current(unpack), + tag::PAINT_TILE => input::painted(unpack), + tag::PAINT_PART => input::part_painted(unpack), + tag::SLIDE_LAYERS => slides::slide_layers(unpack), + tag::VERIFY => unpack + .str() + .and_then(|url| verify(lok, &url)) + .map(|count| Pack::new().u64(count).bytes()), + tag::RUN_MACRO => unpack.str().and_then(|url| { + if dev_mode() && cfg!(debug_assertions) { + Ok(Pack::new().u64(lok.run_macro(&url) as u64).bytes()) + } else { + Err(io::Error::other("run_macro is a dev-only probe")) + } + }), + tag::POST_KEY => input::key(unpack), + tag::POST_MOUSE => input::mouse(unpack), + tag::GRAPHIC => input::graphic(unpack), + tag::POST_TEXT => input::text(unpack), + tag::POST_UNO => input::uno(unpack), + tag::COPY => input::copy(unpack), + tag::PASTE => input::paste(unpack), + tag::CUT => input::cut(unpack), + tag::VALUES => input::values(unpack), + tag::PAGES => unpack + .u64() + .and_then(|id| with_doc(id, |doc| doc.page_rects())) + .and_then(|rects| rects) + .map(|rects| Pack::new().str(&rects).bytes()), + tag::SHEETS => unpack + .u64() + .and_then(|id| with_doc(id, sheets)) + .and_then(|sheets| sheets), + tag::SHOW_SHEET => unpack + .u64() + .and_then(|id| Ok((id, unpack.u64()?))) + .and_then(|(id, part)| { + let part = i32::try_from(part).map_err(io::Error::other)?; + with_doc(id, |doc| doc.set_part(part)) + }) + .map(|()| Vec::new()), + tag::VISIBLE_AREA => unpack + .u64() + .and_then(|id| { + let mut number = || i32::try_from(unpack.i64()?).map_err(io::Error::other); + Ok((id, (number()?, number()?, number()?, number()?))) + }) + .and_then(|(id, (x, y, w, h))| with_doc(id, |doc| doc.set_visible_area(x, y, w, h))) + .map(|()| Vec::new()), + tag::SELECTION_TEXT => unpack + .u64() + .and_then(|id| with_doc(id, |doc| doc.selection_text())) + .and_then(|text| text) + .map(|text| Pack::new().str(&text).bytes()), + tag::COMPLETE_FUNCTION => unpack + .u64() + .and_then(|id| { + let name = unpack.str()?; + with_doc(id, |doc| doc.complete_function(&name)).and_then(|done| done) + }) + .map(|()| Vec::new()), + tag::SET_AUTHOR => unpack + .str() + .and_then(|name| lok.set_option("author", &name)) + .map(|()| Vec::new()), + other => Err(io::Error::other(format!("unknown request {other}"))), + }; + let reply = match outcome { + Ok(body) => (tag::OK, body), + Err(error) => { + // Text may quote the document: reply, never log. + log::warn!("lok-worker: request {tag} failed ({:?})", error.kind()); + (tag::ERR, Pack::new().str(&error.to_string()).bytes()) + } + }; + let mut written = ipc::write_frame(&mut *out, reply.0, id, &reply.1); + // An oversized answer is refused; the worker lives. + if let Err(error) = &written + && error.kind() == io::ErrorKind::InvalidInput + { + log::warn!("lok-worker: request {tag}: {error}"); + written = ipc::write_frame(&mut *out, tag::ERR, id, &Pack::new().str(&error.to_string()).bytes()); + } + if let Err(error) = written { + log::warn!("lok-worker: frame write failed: {error}"); + quit(1); + } +} + +/// Whether the dev-only probes answer (a `--dev` argv flag only). +fn dev_mode() -> bool { + static DEV: std::sync::OnceLock = std::sync::OnceLock::new(); + *DEV.get_or_init(|| std::env::args().any(|arg| arg == "--dev")) +} + +/// Loads a document with macros disabled (LOK-11). +fn load(lok: &Lok, url: &str, password: String, filter: &str) -> io::Result { + if password.contains('\0') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "a password with NUL", + )); + } + password::arm(Some(password).filter(|password| !password.is_empty())); + // LOK hands what it does not take to the filter. + let options = match filter { + "" => LOAD_OPTIONS.to_owned(), + filter => format!("{LOAD_OPTIONS},{filter}"), + }; + let loaded = lok.load(url, &options); + let refused = password::refused(); + password::arm(None); + let doc = match loaded { + Err(_) if refused => return Err(io::Error::other(ipc::LOCKED)), + loaded => loaded?, + }; + // A session number per load; native pointers get reused. + let id = revision::session(); + crate::events::watch(id, &doc); + doc.enable_a11y(); + crate::events::seed_a11y(id, &doc.a11y_paragraph()?); + // Settle the initial layout now; later paints see it (LOK-03). + doc.post_uno(".uno:Repaginate", None)?; + let mut docs = docs().lock().expect("the docs lock"); + docs.insert(id, doc); + log::info!("lok-worker: {} documents open", docs.len()); + drop(docs); + log::info!("lok-worker: loaded {url}, filter options {filter:?}"); + Ok(id) +} + +/// Stops watching and frees document `id`. +fn close(id: u64) { + let mut docs = docs().lock().expect("the docs lock"); + if let Some(doc) = docs.get(&id) { + crate::events::unwatch(id, doc); + } + docs.remove(&id); + revision::forget(id); + log::info!("lok-worker: {} documents open", docs.len()); +} + +/// The live documents, freed on CLOSE_DOC. +fn docs() -> &'static Mutex> { + static DOCS: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + DOCS.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +/// Loads and paints; the bytes gained (R-01). +fn verify(lok: &Lok, url: &str) -> io::Result { + let doc = lok.load(url, LOAD_OPTIONS)?; + let mut tile = vec![0xABu8; 64 * 64 * 4]; + doc.paint_tile(&mut tile, 64, 64); + drop(doc); + let gained = tile.iter().filter(|byte| **byte != 0xAB).count() as u64; + log::info!("lok-worker: tile self-check gained {gained} bytes"); + Ok(gained) +} + +/// Borrows a live document for a read call. +fn with_doc(id: u64, call: impl FnOnce(&Document) -> T) -> io::Result { + let docs = docs().lock().expect("the docs lock"); + let Some(doc) = docs.get(&id) else { + return Err(io::Error::new(io::ErrorKind::NotFound, "no such document")); + }; + // Calc reads the current view; every request uses its own. + doc.activate(); + Ok(call(doc)) +} + +/// The shown part, then each part's name and info. +fn sheets(doc: &Document) -> io::Result> { + let count = doc.parts(); + let mut pack = Pack::new().u64(doc.part() as u64).u64(count as u64); + for part in 0..count { + pack = pack.str(&doc.part_name(part)?).str(&doc.part_info(part)?); + } + Ok(pack.bytes()) +} + +/// Saves a document under a new url and format (LOK-09). +fn save(unpack: &mut Unpack) -> io::Result<()> { + let id = unpack.u64()?; + let url = unpack.str()?; + let format = unpack.str()?; + let options = unpack.str()?; + if !options.is_empty() && options != "TakeOwnership" { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "unknown save option", + )); + } + with_doc(id, |doc| doc.save_as(&url, &format, &options))??; + log::info!("lok-worker: saved as {format} to {url} ({options})"); + Ok(()) +} + +/// Writes a PDF from typed options, never raw text. +fn export_pdf(unpack: &mut Unpack) -> io::Result<()> { + let (id, url) = (unpack.u64()?, unpack.str()?); + let (from, to) = (unpack.u64()?, unpack.u64()?); + let (comments, changes) = (unpack.u64()? != 0, unpack.u64()? != 0); + let handouts = unpack.u64()?; + let flag = |on: bool| serde_json::json!({ "type": "boolean", "value": on.to_string() }); + let mut options = serde_json::json!({ + "ExportNotes": flag(false), + "ExportNotesInMargin": flag(comments), + "ExportTrackedChanges": flag(changes), + }); + if handouts != 0 { + let per = handouts.to_string(); + options["ExportHandouts"] = serde_json::json!({ "type": "long", "value": per }); + } + if from != 0 { + if from > to { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "a page range ends first", + )); + } + let range = format!("{from}-{to}"); + options["PageRange"] = serde_json::json!({ "type": "string", "value": range }); + } + with_doc(id, |doc| doc.save_as(&url, "pdf", &options.to_string()))??; + log::info!( + "lok-worker: exported PDF pages {from}-{to}, comments {comments}, changes {changes}, handouts {handouts}" + ); + Ok(()) +} + +/// The one shared output for every handler. +static OUT: std::sync::OnceLock>> = std::sync::OnceLock::new(); + +/// Sets the response sink once. +pub fn set_output(out: Box) { + let _ = OUT.set(Mutex::new(out)); +} + +/// Sets the kit for the handlers once. +fn pin(lok: &'static Lok) { + LOK.store( + lok as *const Lok as *mut Lok, + std::sync::atomic::Ordering::SeqCst, + ); +} + +/// The profile directory as a `file:` URL. +fn profile_url_of(dir: &Path) -> String { + ipc::file_url_of(dir) +} + +/// Exits without LO's atexit handlers (R-01's probe does the same). +#[cfg(unix)] +fn quit(code: i32) -> ! { + unsafe { libc::_exit(code) }; +} + +/// Exits without LO's atexit handlers, the msvcrt way. +#[cfg(windows)] +fn quit(code: i32) -> ! { + unsafe extern "C" { + fn _exit(code: i32) -> !; + } + unsafe { _exit(code) }; +} diff --git a/crates/ely-lok/src/worker/events.rs b/crates/ely-lok/src/worker/events.rs new file mode 100644 index 0000000..1172ce5 --- /dev/null +++ b/crates/ely-lok/src/worker/events.rs @@ -0,0 +1,86 @@ +//! Event batches pushed to the client (LOK-03). + +use super::{OUT, quit}; +use crate::events::{Events, Rect}; +use crate::ipc::{self, Pack, tag}; + +/// Pushes each document's new events as they come (LOK-03). +pub(super) fn push() { + loop { + crate::events::wait_unpushed(); + // Drained under the output lock: current at send. + let out = OUT.get().expect("the output is set"); + let mut out = out.lock().expect("the output lock"); + for (id, batch) in crate::events::unpushed() { + let revision = super::revision::of(id).unwrap_or(0); + let body = [Pack::new().u64(id).u64(revision).bytes(), pack_events(batch)].concat(); + if let Err(error) = ipc::write_frame(&mut *out, tag::EVENTS, ipc::UNASKED, &body) { + log::warn!("lok-worker: event write failed: {error}"); + quit(1); + } + } + } +} + +/// One event batch on the wire. +fn pack_events(batch: Events) -> Vec { + let rects = |pack: Pack, rects: &[Rect]| { + rects + .iter() + .fold(pack.u64(rects.len() as u64), |pack, &(x, y, w, h)| { + pack.i64(x as i64).i64(y as i64).i64(w as i64).i64(h as i64) + }) + }; + let pack = Pack::new() + .u64(batch.seq) + .u64(batch.generation) + .u64(batch.all as u64); + let pack = rects(pack, &batch.rects); + let pack = rects(pack, batch.cursor.as_slice()); + let pack = pack.u64(batch.cursor_visible as u64); + let pack = rects(pack, &batch.selection); + let pack = rects(pack, batch.graphic.as_slice()).u64(batch.cropping as u64); + let pack = pack + .u64(batch.review) + .u64(batch.searched) + .u64(batch.resized); + let pack = rects(pack, &batch.found); + let pack = pack.str(batch.menu.as_deref().unwrap_or_default()); + let pack = batch + .faults + .iter() + .fold(pack.u64(batch.faults.len() as u64), |pack, fault| { + pack.str(fault) + }); + let pack = batch + .fonts + .iter() + .fold(pack.u64(batch.fonts.len() as u64), |pack, font| { + pack.str(font) + }); + let pack = batch.states.iter().fold( + pack.u64(batch.states.len() as u64), + |pack, (name, state)| pack.str(name).str(state), + ); + let pack = batch + .blocked + .iter() + .fold(pack.u64(batch.blocked.len() as u64), |pack, title| { + pack.str(title) + }); + let pack = batch + .tabs + .iter() + .fold(pack.u64(batch.tabs.len() as u64), |pack, tab| { + pack.i64(*tab as i64) + }); + let a11y = &batch.a11y; + let signed = |at: Option| at.map_or(-1, |at| at as i64); + let (start, end) = a11y.selection.unzip(); + let pack = pack + .str(&a11y.text) + .i64(signed(a11y.caret)) + .i64(signed(start)) + .i64(signed(end)); + batch.cell.pack(pack).bytes() +} diff --git a/crates/ely-lok/src/worker/fatal.rs b/crates/ely-lok/src/worker/fatal.rs new file mode 100644 index 0000000..2357366 --- /dev/null +++ b/crates/ely-lok/src/worker/fatal.rs @@ -0,0 +1,28 @@ +//! Fatal signals print the native stack first. + +use std::ffi::{c_int, c_void}; + +unsafe extern "C" { + fn backtrace(buffer: *mut *mut c_void, size: c_int) -> c_int; + fn backtrace_symbols_fd(buffer: *const *mut c_void, size: c_int, fd: c_int); +} + +extern "C" fn report(signal: c_int) { + let mut frames = [std::ptr::null_mut(); 64]; + // As a log record, so the app's relay keeps it. + let note = b"[fatal ERROR ely_lok::worker::fatal] native stack follows\n"; + unsafe { + libc::write(2, note.as_ptr().cast(), note.len()); + let count = backtrace(frames.as_mut_ptr(), frames.len() as c_int); + backtrace_symbols_fd(frames.as_ptr(), count, 2); + libc::signal(signal, libc::SIG_DFL); + libc::raise(signal); + } +} + +/// Prints a stack for crash signals. +pub(super) fn watch() { + for signal in [libc::SIGSEGV, libc::SIGBUS, libc::SIGILL, libc::SIGABRT] { + unsafe { libc::signal(signal, report as *const () as libc::sighandler_t) }; + } +} diff --git a/crates/ely-lok/src/worker/input.rs b/crates/ely-lok/src/worker/input.rs new file mode 100644 index 0000000..9661d39 --- /dev/null +++ b/crates/ely-lok/src/worker/input.rs @@ -0,0 +1,447 @@ +use std::io; + +use super::with_doc; +use crate::commands::{Arg, Check, Command}; + +use crate::ffi::Document; +use crate::history::Step; +use crate::ipc::{self, Pack, Unpack}; + +/// `.uno:Paste` arguments, as `doc_paste` sets them. +const PASTE_ARGS: &str = r#"{"AnchorType":{"type":"unsigned short","value":"1"},"IgnoreComments":{"type":"boolean","value":"true"},"SkipDetection":{"type":"boolean","value":"true"}}"#; +/// The clipboard flavors Ely moves (LOK-06). +const CLIP_MIMES: [&str; 3] = ["text/html", "text/plain;charset=utf-8", "image/png"]; + +/// A failed request as its error text. +fn invalid(what: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, what.to_string()) +} + +/// Paints one tile after checking the geometry (LOK-03). +fn paint(unpack: &mut Unpack) -> io::Result<(u64, Vec)> { + let id = unpack.u64()?; + let (x, y, w, h) = (unpack.i64()?, unpack.i64()?, unpack.i64()?, unpack.i64()?); + let (pw, ph) = (unpack.i64()?, unpack.i64()?); + let geometry_ok = (1..=i32::MAX as i64).contains(&w) + && (1..=i32::MAX as i64).contains(&h) + && pw > 0 + && ph > 0; + let len = (geometry_ok) + .then(|| (pw as u64).checked_mul(ph as u64)?.checked_mul(4)) + .flatten() + .filter(|len| *len <= (ipc::MAX_FRAME - 16) as u64); + let Some(len) = len else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "bad tile geometry", + )); + }; + let tile = with_doc(id, |doc| { + let mut tile = vec![0u8; len as usize]; + doc.paint_rect( + &mut tile, + (pw as i32, ph as i32), + (x as i32, y as i32, w as i32, h as i32), + ); + tile + })?; + Ok((id, tile)) +} + +/// One slide's whole picture at the asked pixels. +pub(super) fn part_painted(unpack: &mut Unpack) -> io::Result> { + let (id, part, master) = (unpack.u64()?, unpack.u64()?, unpack.u64()? != 0); + let (pw, ph) = (unpack.u64()?, unpack.u64()?); + let len = (pw > 0 && ph > 0) + .then(|| pw.checked_mul(ph)?.checked_mul(4)) + .flatten() + .filter(|len| *len <= (ipc::MAX_FRAME - 16) as u64) + .ok_or_else(|| invalid("bad part geometry"))?; + let part = i32::try_from(part).map_err(|_| invalid("bad part"))?; + with_doc(id, |doc| { + if part >= doc.parts() { + return Err(invalid("no such part")); + } + let mut pixels = vec![0u8; len as usize]; + doc.paint_part( + &mut pixels, + (part, i32::from(master)), + (pw as i32, ph as i32), + ); + Ok(pixels) + })? +} + +/// A painted tile behind its generation. +pub(super) fn painted(unpack: &mut Unpack) -> io::Result> { + let (id, tile) = paint(unpack)?; + let mut body = Pack::new().u64(crate::events::generation(id)).bytes(); + body.extend_from_slice(&tile); + Ok(body) +} + +/// The kernel's accelerators that close its frame or quit. +const CLOSING: [i64; 3] = [534, 771, 528]; + +/// A key press and release (LOK-06). +pub(super) fn key(unpack: &mut Unpack) -> io::Result> { + let (id, char_code, key_code) = (unpack.u64()?, unpack.i64()?, unpack.i64()?); + // Mod1 with W, F4 or Q closes our document. + if key_code & 0x2000 != 0 && CLOSING.contains(&(key_code & 0x0fff)) { + return Err(io::Error::other(format!("key {key_code} closes the kernel's frame"))); + } + with_doc(id, |doc| { + doc.post_key(0, char_code as i32, key_code as i32); + doc.post_key(1, char_code as i32, key_code as i32); + })?; + Ok(Vec::new()) +} + +/// One mouse event: kind, x, y, count, buttons, modifiers. +pub(super) fn mouse(unpack: &mut Unpack) -> io::Result> { + let id = unpack.u64()?; + let mut event = [0i32; 6]; + for slot in &mut event { + *slot = unpack.i64()? as i32; + } + if !(0..=2).contains(&event[0]) { + return Err(invalid("bad mouse event")); + } + with_doc(id, |doc| doc.post_mouse(event))?; + Ok(Vec::new()) +} + +/// A selected object's handle drag: 0 start, 1 end. +pub(super) fn graphic(unpack: &mut Unpack) -> io::Result> { + let (id, kind, x, y) = (unpack.u64()?, unpack.i64()?, unpack.i64()?, unpack.i64()?); + if !(0..=1).contains(&kind) { + return Err(invalid("bad graphic selection")); + } + with_doc(id, |doc| { + doc.set_graphic_selection(kind as i32, x as i32, y as i32) + })?; + Ok(Vec::new()) +} + +/// IME text: 0 composing, 2 commit (LOK-07). +pub(super) fn text(unpack: &mut Unpack) -> io::Result> { + let (id, kind, text) = (unpack.u64()?, unpack.i64()?, unpack.str()?); + if kind != 0 && kind != 2 { + return Err(invalid("bad text event")); + } + with_doc(id, |doc| doc.post_text(kind as i32, &text))??; + Ok(Vec::new()) +} + +/// How long a state check waits for the kernel. +const STATE_WAIT: std::time::Duration = std::time::Duration::from_secs(2); + +/// Runs a table command and confirms it took (LOK-02, LOK-08). +pub(super) fn uno(unpack: &mut Unpack) -> io::Result> { + let (id, name) = (unpack.u64()?, unpack.str()?); + let values = (0..unpack.u64()?) + .map(|_| unpack.str()) + .collect::>>()?; + let values: Vec<&str> = values.iter().map(String::as_str).collect(); + let command = crate::commands::find(&name)?; + let args = command.args_json(&values)?; + with_doc(id, |doc| { + run_checked(id, doc, command, &values, args.as_deref()) + })??; + log::info!("lok-worker: ran {name}"); + Ok(Vec::new()) +} + +/// Dispatches `command` and applies its check. +fn run_checked( + id: u64, + doc: &Document, + command: &Command, + values: &[&str], + args: Option<&str>, +) -> io::Result<()> { + let refuse = |why: String| Err(io::Error::other(format!("{}: {why}", command.id))); + for (arg, value) in command.args.iter().zip(values) { + if matches!(arg, Arg::Font(_)) && !font_listed(doc, value)? { + return refuse(format!("the kernel lists no font {value}")); + } + } + if command.check == Check::History && doc.kind() == 1 { + return sheet_history(id, doc, command, args); + } + let history = format!("{}Count", command.uno); + let before = if command.check == Check::History { + let count = history_count(doc, &history)?; + if count == 0 { + return refuse(format!("{history} is 0")); + } + count + } else { + 0 + }; + let undo_before = undo_entries(doc)?; + crate::events::clear_result(id); + doc.post_uno_notify(command.uno, args)?; + let checked = match command.check { + Check::Result => match crate::events::take_result(id) { + Some((_, true)) => Ok(()), + outcome => refuse(format!("the kernel reported {outcome:?}")), + }, + Check::History => match history_count(doc, &history)? { + after if after + 1 == before => Ok(()), + after => refuse(format!("{history} went {before} to {after}")), + }, + Check::Undoable => match new_steps(&undo_before, &undo_entries(doc)?) { + 0 => refuse("the undo history did not move".into()), + _ => Ok(()), + }, + Check::Listed => { + let json = doc.command_values(".uno:StyleApply")?; + let styles = crate::commands::styles(&json, values[1])?; + if styles.iter().any(|name| name == values[0]) { + Ok(()) + } else { + refuse(format!("the style list lacks {}", values[0])) + } + } + Check::Finished => match crate::events::take_result(id) { + Some(_) => Ok(()), + None => refuse("the kernel reported no completion".into()), + }, + Check::Cropping => match crate::events::wait_cropping(id, STATE_WAIT) { + true => Ok(()), + false => refuse("the image did not enter crop mode".into()), + }, + Check::Reports => { + let want = reported_form(&command.args[0], values[0]); + match crate::events::wait_state(id, command.uno, &want, STATE_WAIT) { + Some(state) if state == want => Ok(()), + state => refuse(format!("the kernel reports {state:?}, not {want}")), + } + } + }; + if checked.is_err() && command.check != Check::History { + let steps = new_steps(&undo_before, &undo_entries(doc)?); + for _ in 0..steps { + doc.post_uno(".uno:Undo", None)?; + } + log::warn!("lok-worker: {} failed, {steps} steps undone", command.id); + } + checked +} + +/// Calc undo acts on the sheet or the edited cell. +fn sheet_history(id: u64, doc: &Document, command: &Command, args: Option<&str>) -> io::Result<()> { + let gone = || io::Error::other("the document left the event watch"); + let history = format!("{}Count", command.uno); + let (count, text) = ( + history_count(doc, &history)?, + crate::events::a11y_text(id).ok_or_else(gone)?, + ); + doc.post_uno(command.uno, args)?; + if history_count(doc, &history)? + 1 == count { + return Ok(()); + } + let after = crate::events::wait_a11y_change(id, &text, STATE_WAIT).ok_or_else(gone)?; + if after == text { + return Err(io::Error::other(format!( + "{}: neither {history} nor the cell edit moved", + command.id + ))); + } + log::info!("lok-worker: {} in a cell edit", command.id); + Ok(()) +} + +/// The kernel's synchronous history depth. +fn history_count(doc: &Document, command: &str) -> io::Result { + let count = doc.command_values(command)?; + count + .trim() + .parse() + .map_err(|_| io::Error::other(format!("a bad {command}: {count}"))) +} + +/// How the kernel spells a value in its state. +fn reported_form(arg: &Arg, value: &str) -> String { + match arg { + Arg::Points(_) => value + .parse::() + .map_or_else(|_| value.to_string(), |points| points.to_string()), + _ => value.to_string(), + } +} + +/// Whether the kernel's font list names `family`. +fn font_listed(doc: &Document, family: &str) -> io::Result { + let json = doc.command_values(".uno:CharFontName")?; + Ok(crate::commands::fonts(&json)? + .iter() + .any(|name| name == family)) +} + +/// A kernel list: fonts or styles (LOK-05). +pub(super) fn values(unpack: &mut Unpack) -> io::Result> { + let (id, command) = (unpack.u64()?, unpack.str()?); + let base = command.split('?').next().expect("split has a first part"); + if !crate::commands::VALUES.contains(&command.as_str()) + && !crate::commands::QUERIES.contains(&base) + { + return Err(invalid(&format!("{command} is not a readable list"))); + } + let json = with_doc(id, |doc| doc.command_values(&command))??; + Ok(Pack::new().str(&json).bytes()) +} + +/// Copies the selection; its flavors come back (LOK-06). +pub(super) fn copy(unpack: &mut Unpack) -> io::Result> { + let id = unpack.u64()?; + let entries = with_doc(id, |doc| { + if doc.selection_type() == 0 { + return Ok(Vec::new()); + } + doc.post_uno(".uno:Copy", None)?; + let entries = doc.clipboard(&CLIP_MIMES)?; + if entries.is_empty() { + return Err(io::Error::other("the copy produced no flavor")); + } + Ok(entries) + })?? + .into_iter() + .map(|(mime, mut data)| { + // LOK's HTML ends in a NUL. + if mime.starts_with("text/") { + while data.last() == Some(&0) { + data.pop(); + } + } + (mime, data) + }) + .collect::>(); + log::info!("lok-worker: copied {} flavors", entries.len()); + let pack = Pack::new().u64(entries.len() as u64); + Ok(entries + .iter() + .fold(pack, |pack, (mime, data)| pack.str(mime).blob(data)) + .bytes()) +} + +/// Cuts the selection; a selection left behind is an error. +pub(super) fn cut(unpack: &mut Unpack) -> io::Result> { + let id = unpack.u64()?; + with_doc(id, |doc| { + doc.post_uno(".uno:Cut", None)?; + if doc.selection_type() != 0 { + return Err(io::Error::other("the selection survived the cut")); + } + Ok(()) + })??; + Ok(Vec::new()) +} + +/// The undo entries, newest first. +fn undo_entries(doc: &Document) -> io::Result> { + crate::history::steps(&doc.command_values(".uno:Undo")?) +} + +/// Entries added since `before`; a full history drops old ones. +fn new_steps(before: &[Step], after: &[Step]) -> usize { + match before.first() { + Some(top) => after + .iter() + .position(|entry| entry == top) + .unwrap_or(after.len()), + None => after.len(), + } +} + +/// Every image a `data:` URI carries must decode. +fn check_html_images(html: &str) -> io::Result<()> { + use base64::Engine; + for (at, _) in html.match_indices("data:image/") { + let rest = &html[at..]; + let Some(comma) = rest.find(";base64,") else { + return Err(invalid("an inline image is not base64")); + }; + let body = &rest[comma + 8..]; + let end = body + .find(|c: char| c == '"' || c == '\'' || c == ')' || c.is_whitespace()) + .unwrap_or(body.len()); + let bytes = base64::engine::general_purpose::STANDARD + .decode(&body[..end]) + .map_err(|error| invalid(&format!("an inline image is bad base64: {error}")))?; + image::load_from_memory(&bytes) + .map_err(|error| invalid(&format!("an inline image does not decode: {error}")))?; + } + Ok(()) +} + +/// Refuses payloads the engine would drop half way. +fn check_payload(mime: &str, data: &[u8]) -> io::Result<()> { + if data.is_empty() { + return Err(invalid("an empty paste")); + } + if mime == "image/png" { + image::load_from_memory_with_format(data, image::ImageFormat::Png) + .map_err(|error| invalid(&format!("the png does not decode: {error}")))?; + return Ok(()); + } + let text = std::str::from_utf8(data).map_err(|_| invalid("the text is not UTF-8"))?; + if mime == "text/html" { + check_html_images(text)?; + } + Ok(()) +} + +/// Pastes one flavor; a failed import is undone and reported. +pub(super) fn paste(unpack: &mut Unpack) -> io::Result> { + let (id, mime, data) = (unpack.u64()?, unpack.str()?, unpack.blob()?); + if !CLIP_MIMES.contains(&mime.as_str()) { + return Err(invalid("unsupported paste flavor")); + } + check_payload(&mime, &data)?; + with_doc(id, |doc| { + let before = undo_entries(doc)?; + doc.set_clipboard(&mime, &data)?; + doc.post_uno(".uno:Paste", Some(PASTE_ARGS))?; + let steps = new_steps(&before, &undo_entries(doc)?); + // A pasted image ends selected. + let landed = steps > 0 && (mime != "image/png" || doc.selection_type() == 3); + if landed { + return Ok(()); + } + for _ in 0..steps { + doc.post_uno(".uno:Undo", None)?; + } + log::warn!("lok-worker: paste of {mime} failed, {steps} steps undone"); + Err(io::Error::other(format!("the engine did not paste {mime}"))) + })??; + log::info!("lok-worker: pasted {mime}, {} bytes", data.len()); + Ok(Vec::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(comment: &str, time: &str) -> (String, String) { + (comment.into(), time.into()) + } + + #[test] + fn new_steps_survive_a_full_history() { + let old = vec![entry("Typing", "1"), entry("Typing", "0")]; + let grown = vec![entry("Paste", "2"), entry("Typing", "1")]; + assert_eq!(new_steps(&old, &grown), 1, "the cap dropped the oldest"); + assert_eq!(new_steps(&old, &old), 0); + assert_eq!(new_steps(&[], &grown), 2); + } + + #[test] + fn inline_images_must_decode() { + let bad = r#""#; + assert!(check_payload("text/html", bad.as_bytes()).is_err()); + assert!(check_payload("text/html", b"plain").is_ok()); + assert!(check_payload("text/html", br#""#).is_err()); + } +} diff --git a/crates/ely-lok/src/worker/password.rs b/crates/ely-lok/src/worker/password.rs new file mode 100644 index 0000000..14c5471 --- /dev/null +++ b/crates/ely-lok/src/worker/password.rs @@ -0,0 +1,45 @@ +//! Answers the kernel's password question during a load. + +use std::{ + ffi::{CStr, c_char, c_void}, + sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }, +}; + +/// LOK_CALLBACK_DOCUMENT_PASSWORD. +const PASSWORD: i32 = 20; + +/// The password for the load in progress, given once. +static GIVEN: Mutex> = Mutex::new(None); +/// The kernel asked and got none. +static REFUSED: AtomicBool = AtomicBool::new(false); + +/// Readies `password` for the next load. +pub(super) fn arm(password: Option) { + *GIVEN.lock().expect("the password lock") = password; + REFUSED.store(false, Ordering::SeqCst); +} + +/// Whether the last load lacked a right password. +pub(super) fn refused() -> bool { + REFUSED.load(Ordering::SeqCst) +} + +/// Answers once; a second ask means the password failed. +pub(super) unsafe extern "C" fn on_kit(kind: i32, payload: *const c_char, _: *mut c_void) { + if kind != PASSWORD || payload.is_null() { + return; + } + let url = unsafe { CStr::from_ptr(payload) } + .to_string_lossy() + .into_owned(); + let given = GIVEN.lock().expect("the password lock").take(); + log::info!( + "lok-worker: a password asked, one given: {}", + given.is_some() + ); + REFUSED.store(given.is_none(), Ordering::SeqCst); + super::lok().set_password(&url, given.as_deref()); +} diff --git a/crates/ely-lok/src/worker/revision.rs b/crates/ely-lok/src/worker/revision.rs new file mode 100644 index 0000000..9888a05 --- /dev/null +++ b/crates/ely-lok/src/worker/revision.rs @@ -0,0 +1,73 @@ +//! Each document's revision: requests that may change it count. + +use std::{collections::HashMap, io, sync::Mutex}; + +use crate::ipc::{self, Pack, Unpack, tag}; + +fn revisions() -> &'static Mutex> { + static REVISIONS: std::sync::OnceLock>> = std::sync::OnceLock::new(); + REVISIONS.get_or_init(Default::default) +} + +/// A new session number: this process's prefix, then a count. +pub fn session() -> u64 { + static PREFIX: std::sync::OnceLock = std::sync::OnceLock::new(); + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + // Another worker's numbers match nothing here. + let prefix = *PREFIX.get_or_init(|| { + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |at| at.subsec_nanos()); + (u64::from(std::process::id()) ^ u64::from(nanos)) & 0xffff_ffff + }); + let id = (prefix << 32) | NEXT.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + revisions().lock().expect("the revisions lock").insert(id, 0); + id +} + +pub fn forget(id: u64) { + revisions().lock().expect("the revisions lock").remove(&id); +} + +pub fn of(id: u64) -> io::Result { + let revisions = revisions().lock().expect("the revisions lock"); + revisions.get(&id).copied().ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no such document")) +} + +/// The revision now, answered. +pub fn current(unpack: &mut Unpack) -> io::Result> { + Ok(Pack::new().u64(of(unpack.u64()?)?).bytes()) +} + +/// Refuses unless the document is at the revision asked for. +pub fn expect(unpack: &mut Unpack) -> io::Result> { + let (id, expected) = (unpack.u64()?, unpack.u64()?); + let now = of(id)?; + match now == expected { + true => Ok(Vec::new()), + false => Err(io::Error::other(format!("{}document {id} is at {now}, not {expected}", ipc::STALE))), + } +} + +/// Before `request` runs: input and commands may change the document. +pub fn bump(request: u8, payload: &[u8]) { + let changes = matches!( + request, + tag::POST_KEY | tag::POST_MOUSE | tag::GRAPHIC | tag::POST_TEXT | tag::POST_UNO | tag::PASTE | tag::CUT | tag::COMPLETE_FUNCTION + ); + let mut unpack = Unpack::new(payload); + let Ok(id) = unpack.u64() else { + return; + }; + if !changes || request == tag::POST_MOUSE && hover(&mut unpack) { + return; + } + if let Some(revision) = revisions().lock().expect("the revisions lock").get_mut(&id) { + *revision += 1; + } +} + +/// A move with no button held changes nothing. +fn hover(unpack: &mut Unpack) -> bool { + // Kind, x, y, count, buttons; kind 2 is a move. + let fields: Result, _> = (0..5).map(|_| unpack.i64()).collect(); + fields.is_ok_and(|fields| fields[0] == 2 && fields[4] == 0) +} diff --git a/crates/ely-lok/src/worker/slides.rs b/crates/ely-lok/src/worker/slides.rs new file mode 100644 index 0000000..b43b6f5 --- /dev/null +++ b/crates/ely-lok/src/worker/slides.rs @@ -0,0 +1,37 @@ +use std::io; + +use super::with_doc; +use crate::ipc::{self, Pack, Unpack}; + +/// Slide `part`'s slideshow layers at about the asked pixels. +pub(super) fn slide_layers(unpack: &mut Unpack) -> io::Result> { + let (id, part) = (unpack.u64()?, unpack.u64()?); + let (width, height) = (unpack.u64()?, unpack.u64()?); + let bad = |what: &str| io::Error::new(io::ErrorKind::InvalidInput, what.to_string()); + let part = i32::try_from(part).map_err(|_| bad("bad part"))?; + let pixels = (u32::try_from(width), u32::try_from(height)); + let (Ok(width), Ok(height)) = pixels else { + return Err(bad("bad size")); + }; + with_doc(id, |doc| { + let info: serde_json::Value = + serde_json::from_str(&doc.presentation_info()?).map_err(io::Error::other)?; + let slide = usize::try_from(part).ok().and_then(|at| info["slides"].get(at)); + let hash = slide + .and_then(|slide| slide["hash"].as_str()) + .ok_or_else(|| bad("no such slide"))?; + let ((width, height), layers) = + doc.slide_layers((part, hash), (width, height), ipc::MAX_FRAME - 1024)?; + let mut body = Pack::new() + .u64(u64::from(width)) + .u64(u64::from(height)) + .u64(layers.len() as u64); + for layer in &layers { + body = body.str(&layer.json).u64(u64::from(layer.pixels.is_some())); + if let Some(pixels) = &layer.pixels { + body = body.blob(pixels); + } + } + Ok(body.bytes()) + })? +} diff --git a/crates/ely-lok/src/worker/teardown.rs b/crates/ely-lok/src/worker/teardown.rs new file mode 100644 index 0000000..3ae77c4 --- /dev/null +++ b/crates/ely-lok/src/worker/teardown.rs @@ -0,0 +1,89 @@ +use std::{io, path::Path}; + +use super::{LOK, docs, quit}; + +/// Frees documents, destroys the kit, and reports what failed (LOK-10). +pub(super) fn teardown_work() -> io::Result<()> { + let mut docs = docs().lock().expect("the docs lock"); + crate::events::stop_all(&docs); + docs.clear(); + let kit = LOK.swap(std::ptr::null_mut(), std::sync::atomic::Ordering::SeqCst); + if !kit.is_null() { + let kit = unsafe { Box::from_raw(kit) }; + let joined = kit.join_threads(); + log::info!("lok-worker: threads joined={joined}"); + drop(kit); + } + let Some(profile) = PROFILE.get() else { + return Ok(()); + }; + let lock = profile.join(".lock"); + match std::fs::remove_file(&lock) { + Ok(()) => log::info!("lok-worker: stale profile lock removed"), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(io::Error::new( + error.kind(), + format!("profile lock stays: {error}"), + )); + } + } + let temp = profile.join("tmp"); + let entries = match std::fs::read_dir(&temp) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(io::Error::new( + error.kind(), + format!("temp dir unreadable: {error}"), + )); + } + }; + for entry in entries { + let entry = entry?; + let name = entry.file_name(); + if !name.to_string_lossy().starts_with("lu") { + continue; + } + let path = entry.path(); + unprotect(&path); + let gone = if path.is_dir() { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + gone.map_err(|error| { + io::Error::new( + error.kind(), + format!("temp {} stays: {error}", path.display()), + ) + })?; + } + log::info!("lok-worker: torn down"); + Ok(()) +} + +/// Tears down and exits with the work's verdict. +pub(super) fn teardown(code: i32) -> ! { + let outcome = teardown_work(); + if let Err(error) = &outcome { + log::warn!("lok-worker: teardown failed: {error}"); + } + quit(if outcome.is_ok() { code } else { 1 }); +} + +/// Clears macOS's immutable flag on a kernel temp file. +#[cfg(target_os = "macos")] +fn unprotect(path: &Path) { + use std::os::unix::ffi::OsStrExt; + let Some(c) = std::ffi::CString::new(path.as_os_str().as_bytes()).ok() else { + return; + }; + unsafe { libc::chflags(c.as_ptr(), 0) }; +} + +#[cfg(not(target_os = "macos"))] +fn unprotect(_: &Path) {} + +/// The isolated profile directory, for the stale lock at exit. +pub(super) static PROFILE: std::sync::OnceLock = std::sync::OnceLock::new(); diff --git a/crates/ely-lok/src/worker/text.rs b/crates/ely-lok/src/worker/text.rs new file mode 100644 index 0000000..2eabfee --- /dev/null +++ b/crates/ely-lok/src/worker/text.rs @@ -0,0 +1,59 @@ +//! CSV in and out, from typed fields only (TABLE-12, 13). + +use std::io; + +use super::{close, load, with_doc}; +use crate::ffi::Lok; +use crate::ipc::Unpack; + +/// Loads a text file from typed CSV options. +pub(super) fn import(lok: &Lok, unpack: &mut Unpack) -> io::Result { + use crate::sheet::csv::{COLUMNS, ENCODINGS, Import}; + let bad = |what: &str| io::Error::new(io::ErrorKind::InvalidInput, format!("a bad {what}")); + let url = unpack.str()?; + let character = |code: u64| u32::try_from(code).ok().and_then(char::from_u32); + let separator = character(unpack.u64()?).ok_or_else(|| bad("separator"))?; + let quote = match unpack.u64()? { + 0 => None, + code => Some(character(code).ok_or_else(|| bad("quote"))?), + }; + let pick = |at: u64, len| usize::try_from(at).ok().filter(|at| *at < len); + let encoding = + ENCODINGS[pick(unpack.u64()?, ENCODINGS.len()).ok_or_else(|| bad("encoding"))?]; + let columns = (0..unpack.u64()?) + .map(|_| { + let at = pick(unpack.u64()?, COLUMNS.len()).ok_or_else(|| bad("column"))?; + Ok(COLUMNS[at]) + }) + .collect::>>()?; + let how = Import { + separator, + quote, + encoding, + columns, + }; + let filter = format!("FilterName=Text - txt - csv (StarCalc),{}", how.filter()); + let id = load(lok, &url, String::new(), &filter)?; + // A .txt name must not land in Writer. + let kind = with_doc(id, |doc| doc.kind())?; + if kind != 1 { + close(id); + return Err(io::Error::other(format!( + "the text loaded as kind {kind}, not a sheet" + ))); + } + Ok(id) +} + +/// Writes the shown sheet as text from a typed separator. +pub(super) fn export(unpack: &mut Unpack) -> io::Result<()> { + let (id, url) = (unpack.u64()?, unpack.str()?); + let separator = u32::try_from(unpack.u64()?) + .ok() + .and_then(char::from_u32) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "a bad separator"))?; + let options = crate::sheet::csv::export(separator); + with_doc(id, |doc| doc.save_as(&url, "csv", &options))??; + log::info!("lok-worker: exported text split by {separator:?}"); + Ok(()) +} diff --git a/crates/ely-office/Cargo.toml b/crates/ely-office/Cargo.toml new file mode 100644 index 0000000..0089804 --- /dev/null +++ b/crates/ely-office/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "ely-office" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +directories.workspace = true +ely-files.workspace = true +ely-gpui-component.workspace = true +ely-lok = { workspace = true, features = ["view"] } +gpui.workspace = true +log.workspace = true +quick-xml.workspace = true +ely-platform.workspace = true +jiff.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +ely-ui.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/ely-office/src/autosave.rs b/crates/ely-office/src/autosave.rs new file mode 100644 index 0000000..95e90aa --- /dev/null +++ b/crates/ely-office/src/autosave.rs @@ -0,0 +1,62 @@ +//! Ordinary files save themselves when chosen (§6.2). + +use std::time::Duration; + +use ely_files::SaveState; +use gpui::{AsyncApp, Context, WeakEntity}; + +use crate::{Engine, Host, Saving as _, in_window}; + +/// How long typing rests before a save. +const QUIET: Duration = Duration::from_secs(2); + +pub trait Autosaving: Host { + /// From the app's window, never its tests. + fn start_autosave(cx: &mut Context) { + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + loop { + cx.background_executor().timer(QUIET).await; + if in_window(&this, cx, |host, window, cx| host.autosave(window, cx)).is_err() { + break; + } + } + }) + .detach(); + } + + /// Saves each eligible tab whose edits rested a whole beat. + fn autosave(&mut self, window: &mut gpui::Window, cx: &mut Context) { + let on = ely_ui::settings::autosave::on(cx); + let core = self.core(); + if !on || core.stopped.is_some() || core.dialog.is_some() { + self.core_mut().quiet.clear(); + return; + } + let edits: Vec<(u64, u64)> = core + .tabs + .iter() + .filter(|tab| { + let ours = tab.session.path().and_then(|path| path.extension()).and_then(|ext| ext.to_str()).is_some_and( + |ext| Self::KIND.save_as.iter().any(|format| format.eq_ignore_ascii_case(ext)), + ); + ours && tab.risk.is_none() + && !tab.exporting + && tab.session.state() == SaveState::Dirty + && Self::Engine::settled(tab.view.read(cx)) + }) + .map(|tab| (tab.id, Self::Engine::edited(tab.view.read(cx)))) + .collect(); + let due: Vec = edits + .iter() + .filter(|(id, edited)| self.core().quiet.get(id) == Some(edited)) + .map(|(id, _)| *id) + .collect(); + self.core_mut().quiet = edits.into_iter().filter(|(id, _)| !due.contains(id)).collect(); + for id in due { + log::info!("office: autosaving tab {id}"); + self.save(id, None, window, cx); + } + } +} + +impl Autosaving for H {} diff --git a/crates/ely-office/src/chart.rs b/crates/ely-office/src/chart.rs new file mode 100644 index 0000000..95bf148 --- /dev/null +++ b/crates/ely-office/src/chart.rs @@ -0,0 +1,98 @@ +//! The look rows chart dialogs share: kind, titles, legend. + +use ely_gpui_component::{ + data_display::DescriptionList, + forms::{Choice, Input, Select, Switch, TextInput}, +}; +use ely_lok::sheet::{CHART_KINDS, Look}; +use gpui::{App, AppContext as _, Entity, ParentElement as _, Styled as _, Window, div}; + +fn text(cx: &App, key: &str) -> gpui::SharedString { + ely_ui::i18n::text(cx, key, &[]) +} + +/// A new chart's look: columns with a legend. +pub fn fresh() -> Look { + Look { + kind: "column".into(), + legend: true, + ..Look::default() + } +} + +/// The look's text fields. +pub struct LookFields { + pub title: Entity, + pub x_title: Entity, + pub y_title: Entity, +} + +/// A labelled text field holding `value`. +pub fn field(value: &str, key: &str, window: &mut Window, cx: &mut App) -> Entity { + let (value, name) = (value.to_string(), text(cx, key)); + cx.new(|cx| { + let mut input = TextInput::new(window, cx).label(name); + input.set_text(value, cx); + input + }) +} + +impl LookFields { + pub fn new(look: &Look, window: &mut Window, cx: &mut App) -> Self { + LookFields { + title: field(&look.title, "office.chart.heading", window, cx), + x_title: field(&look.x_title, "office.chart.x", window, cx), + y_title: field(&look.y_title, "office.chart.y", window, cx), + } + } + + /// `look` with the fields' text. + pub fn read(&self, look: &Look, cx: &App) -> Look { + let text = |field: &Entity| field.read(cx).text().trim().to_string(); + Look { + title: text(&self.title), + x_title: text(&self.x_title), + y_title: text(&self.y_title), + ..look.clone() + } + } +} + +/// `rows` with kind and titles added, and the legend switch. +pub fn look_rows( + id: &str, + rows: DescriptionList, + (look, fields): (&Look, &LookFields), + on_kind: impl Fn(String, &mut App) + 'static, + on_legend: impl Fn(bool, &mut App) + 'static, + cx: &App, +) -> (DescriptionList, Switch) { + let label = |key: &str| text(cx, key); + let mut kinds: Vec<&str> = CHART_KINDS.to_vec(); + if !kinds.contains(&look.kind.as_str()) { + kinds.push("other"); + } + let kind = Select::new( + format!("{id}-kind"), + kinds + .into_iter() + .map(|value| Choice::new(value, label(&format!("office.chart.{value}")))), + ) + .label(label("office.chart.kind")) + .selected(look.kind.clone()) + .on_change(move |value, _, cx| on_kind(value.to_string(), cx)); + let legend = Switch::new(format!("{id}-legend"), look.legend) + .label(label("office.chart.legend")) + .on_change(move |on, _, cx| on_legend(on, cx)); + let input = |field| div().w_full().max_w_48().child(Input::new(field)); + let mut rows = rows + .item(label("office.chart.kind"), div().w_full().max_w_48().child(kind)) + .item(label("office.chart.heading"), input(&fields.title)); + // A pie has no axes. + if look.kind != "pie" { + rows = rows + .item(label("office.chart.x"), input(&fields.x_title)) + .item(label("office.chart.y"), input(&fields.y_title)); + } + (rows, legend) +} diff --git a/crates/ely-office/src/chrome.rs b/crates/ely-office/src/chrome.rs new file mode 100644 index 0000000..0a3c2b2 --- /dev/null +++ b/crates/ely-office/src/chrome.rs @@ -0,0 +1,70 @@ +//! The tab strip and the save state (FILE-02). + +use ely_gpui_component::{ + feedback::Banner, + navigation::{EditorTab, EditorTabs}, + primitives::Severity, +}; +use gpui::{Context, SharedString}; + +use crate::{After, Closing, Host, Tabs, text}; + +pub trait Chrome: Host { + fn tab_strip(&self, cx: &mut Context) -> EditorTabs { + let this = cx.entity(); + let core = self.core(); + let open = core + .tabs + .iter() + .map(|tab| EditorTab::new(tab.id.to_string(), tab.name.clone()).dirty(tab.dirty(cx))); + let loading = core + .loading + .iter() + .map(|(id, name)| EditorTab::new(id.to_string(), name.clone()).preview(true)); + let tabs = EditorTabs::new("office-tabs", open.chain(loading).collect::>()) + .on_select({ + let this = this.clone(); + move |id, _, cx| { + let id = id.parse().expect("tab ids are numbers"); + this.update(cx, |host, cx| { + host.core_mut().active = Some(id); + cx.notify(); + }); + } + }) + .on_close(move |id, window, cx| { + let id = id.parse().expect("tab ids are numbers"); + this.update(cx, |host, cx| { + host.request_close(id, After::Tab, window, cx) + }); + }); + match core.active { + Some(id) => tabs.selected(id.to_string()), + None => tabs, + } + } + + /// The macro warning over a risky document (LOK-11). + fn risk_banner(&self, cx: &mut Context) -> Option { + let tab = self.active_tab()?; + let formats = Self::KIND.save_as.join("/").to_uppercase(); + let key = match tab.risk? { + crate::Risk::Macros => "office.risk", + crate::Risk::Signed => "office.signed", + crate::Risk::Repaired => "office.repaired", + }; + let warning = text(cx, key, &[("name", &tab.name), ("formats", &formats)]); + Some(Banner::new("office-risk", Severity::Warning, warning)) + } + + /// The active tab's save state, or the load. + fn save_state(&self, cx: &mut Context) -> SharedString { + match (self.active_tab(), self.core().loading.first()) { + (Some(tab), _) => text(cx, tab.state_key(cx), &[]), + (None, Some((_, name))) => text(cx, "office.loading", &[("name", name)]), + (None, None) => SharedString::default(), + } + } +} + +impl Chrome for H {} diff --git a/crates/ely-office/src/close.rs b/crates/ely-office/src/close.rs new file mode 100644 index 0000000..27ade2c --- /dev/null +++ b/crates/ely-office/src/close.rs @@ -0,0 +1,225 @@ +//! File actions, the system file panel, close and quit (FILE-03). + +use std::path::PathBuf; + +use ely_gpui_component::primitives::Severity; +use ely_ui::commands::{CloseTab, Open, Quit, Save, SaveAs}; +use gpui::{App, AppContext as _, Context, PathPromptOptions, TaskExt as _, Window}; + +use crate::{Dialog, Engine, Host, Opening, Saving, Tabs, in_window, text}; + +/// What a finished close or save goes on to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum After { + Tab, + Window, + Quit, +} + +pub trait Closing: Host { + fn on_open(&mut self, _: &Open, _: &mut Window, cx: &mut Context) { + self.pick(false, cx, |host, path, cx| host.open_file(path, cx)); + } + + fn on_save(&mut self, _: &Save, window: &mut Window, cx: &mut Context) { + match self.core().active { + Some(id) => self.save(id, None, window, cx), + None => self.no_document(cx), + } + } + + fn on_save_as(&mut self, _: &SaveAs, _: &mut Window, cx: &mut Context) { + match self.core().active { + Some(id) => self.save_as(id, None, cx), + None => self.no_document(cx), + } + } + + fn on_close_tab(&mut self, _: &CloseTab, window: &mut Window, cx: &mut Context) { + if let Some(id) = self.core().active { + self.request_close(id, After::Tab, window, cx); + } + } + + fn on_quit(&mut self, _: &Quit, window: &mut Window, cx: &mut Context) { + self.proceed(After::Quit, window, cx); + } + + fn no_document(&self, cx: &mut Context) { + let text = text(cx, "command.blocked.no_document", &[]); + self.toast(text, Severity::Warning, cx); + } + + /// A failed system panel is a failure, never a cancel. + fn panel_failed(&self, error: &str, cx: &mut App) { + let text = text(cx, "office.error.panel", &[("error", error)]); + self.toast(text, Severity::Danger, cx); + } + + /// Asks for one file, or one folder when `folder`. + fn pick( + &mut self, + folder: bool, + cx: &mut Context, + then: impl FnOnce(&mut Self, PathBuf, &mut Context) + 'static, + ) { + let picked = cx.prompt_for_paths(PathPromptOptions { + files: !folder, + directories: folder, + multiple: false, + prompt: None, + }); + cx.spawn(async move |this, cx| { + let picked = match picked.await { + Ok(Ok(paths)) => Ok(paths.and_then(|paths| paths.into_iter().next())), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + in_window(&this, cx, |host, _, cx| match picked { + Ok(Some(path)) => then(host, path, cx), + Ok(None) => log::info!("office: the file panel was cancelled"), + Err(error) => host.panel_failed(&error, cx), + }) + }) + .detach_and_log_err(cx); + } + + /// Closes `id`, asking first when it holds unsaved changes. + fn request_close( + &mut self, + id: u64, + after: After, + _window: &mut Window, + cx: &mut Context, + ) { + log::info!("office: closing tab {id} asked, then {after:?}"); + if !self.settled(id, cx) { + return self.when_settled(id, move |host, window, cx| { + host.request_close(id, after, window, cx) + }); + } + let Some(tab) = self.tab(id) else { + return; + }; + if tab.dirty(cx) { + return self.ask_unsaved(id, after, cx); + } + // The undo history decides; it is read in the background. + let edited = Self::Engine::edited(tab.view.read(cx)); + self.read_in( + id, + Self::Engine::history, + move |host, read, window, cx| { + let Some(tab) = host.tab(id) else { + return; + }; + if Self::Engine::edited(tab.view.read(cx)) != edited { + return host.request_close(id, after, window, cx); + } + let moved = match read { + Ok(history) => history != tab.mark, + Err(error) => { + log::error!("office: the undo history of tab {id} is unreadable: {error}"); + true + } + }; + if moved || tab.dirty(cx) { + return host.ask_unsaved(id, after, cx); + } + host.close_tab(id, cx); + host.proceed(after, window, cx); + }, + cx, + ); + } + + fn ask_unsaved(&mut self, id: u64, after: After, cx: &mut Context) { + self.core_mut().dialog = Some(Dialog::Unsaved { tab: id, after }); + cx.notify(); + } + + /// Window and quit close each tab, asking for dirty ones. + fn proceed(&mut self, after: After, window: &mut Window, cx: &mut Context) { + if after == After::Tab { + return; + } + if let Some(id) = self.core().tabs.first().map(|tab| tab.id) { + return self.request_close(id, after, window, cx); + } + match after { + After::Window => window.remove_window(), + After::Quit => cx.quit(), + After::Tab => {} + } + } + + /// The close button: tabs close in turn, off the window. + fn should_close(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if self.core().tabs.is_empty() { + return true; + } + // Each tab's history is read in the background. + self.proceed(After::Window, window, cx); + false + } + + /// Releases the kernel document, registry entry and scratch folder. + fn close_tab(&mut self, id: u64, cx: &mut Context) { + let Some(doc) = self.release_tab(id, cx) else { + return; + }; + // The kernel lets go in the background. + let worker = std::sync::Arc::clone(&self.core().worker); + cx.spawn(async move |this, cx| { + let closed = cx + .background_spawn(async move { + Self::Engine::close(&mut worker.lock().expect("the worker lock"), doc) + }) + .await; + if let Err(error) = closed + && this + .update(cx, |host, cx| host.engine_failed(&error.to_string(), cx)) + .is_err() + { + log::error!("office: closing a document failed after its window: {error}"); + } + }) + .detach(); + } + + /// Drops `id` from the window; its kernel document is returned. + fn release_tab(&mut self, id: u64, cx: &mut Context) -> Option> { + let core = self.core_mut(); + let at = core.tabs.iter().position(|tab| tab.id == id)?; + let tab = core.tabs.remove(at); + self.tab_closed(&tab, cx); + // Its images leave GPUI's atlas next frame (PERF-08). + let mut images = Self::Engine::retire(&tab.view, cx); + cx.spawn(async move |this, cx| { + let freed = crate::in_window(&this, cx, |_, window, _| { + window.on_next_frame(move |window, _| images.release_all(window)); + }); + if freed.is_err() { + log::info!("office: the window went, and its images with it"); + } + }) + .detach(); + crate::Recovering::forget_tab(self, id, cx); + let core = self.core_mut(); + core.waiting.retain(|(waiting, _)| *waiting != id); + if let Some(registry) = tab.registry { + core.sessions.close(registry); + } + if core.active == Some(id) { + core.active = core.tabs.last().map(|tab| tab.id); + } + if let Err(error) = std::fs::remove_dir_all(&tab.work) { + log::warn!("office: scratch {} stays: {error}", tab.work.display()); + } + log::info!("office: closed {}", tab.name); + cx.notify(); + Some(tab.doc) + } +} + +impl Closing for H {} diff --git a/crates/ely-office/src/dialogs.rs b/crates/ely-office/src/dialogs.rs new file mode 100644 index 0000000..592dd62 --- /dev/null +++ b/crates/ely-office/src/dialogs.rs @@ -0,0 +1,234 @@ +//! The shell's dialogs: unsaved, conflict, password (FILE-05). + +use std::{path::PathBuf, rc::Rc}; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + forms::TextInput, + overlays::{Close, Dialog as Card, PromptDialog}, +}; +use gpui::{AnyElement, App, AppContext as _, Context, Entity, IntoElement, SharedString, Window}; + +use crate::files::file_name; +use crate::{After, Closing, Host, Opening, Saving, Source, Tabs, text}; + +pub enum Dialog { + Unsaved { + tab: u64, + after: After, + }, + Conflict { + tab: u64, + then: Option, + }, + Password { + path: PathBuf, + wrong: bool, + /// An untitled copy, not the user's file. + copy: bool, + field: Entity, + }, +} + +pub trait Dialogs: Host { + /// Asks for `path`'s password; `wrong` when one just failed. + fn ask_password( + &mut self, + path: PathBuf, + wrong: bool, + copy: bool, + window: &mut Window, + cx: &mut Context, + ) { + log::info!( + "office: {} is locked, a password failed: {wrong}", + path.display() + ); + if self.core().dialog.is_some() { + return self.core_mut().locked.push_back((path, wrong, copy)); + } + let field = cx.new(|cx| TextInput::new(window, cx).masked()); + self.core_mut().dialog = Some(Dialog::Password { path, wrong, copy, field }); + cx.notify(); + } + + /// Clears the dialog; a waiting locked file asks next. + fn dialog_done(&mut self, window: &mut Window, cx: &mut Context) { + self.core_mut().dialog = None; + if let Some((path, wrong, copy)) = self.core_mut().locked.pop_front() { + self.ask_password(path, wrong, copy, window, cx); + } + cx.notify(); + } + + /// The shell's open dialog, if any. + fn core_dialog(&self, cx: &mut Context) -> Option { + let this = cx.entity(); + let dismiss = { + let this = this.clone(); + move |window: &mut Window, cx: &mut App| { + this.update(cx, |host, cx| host.dialog_done(window, cx)) + } + }; + let name = |tab| { + self.tab(tab) + .map(|tab| tab.name.clone()) + .unwrap_or_default() + }; + let card = match self.core().dialog.as_ref()? { + Dialog::Password { path, wrong, copy, field } => { + let prompt = password(this, (path, *wrong, *copy, field), dismiss, cx); + return Some(prompt.into_any_element()); + } + Dialog::Unsaved { tab, after } => { + let (tab, after) = (*tab, *after); + let title = text(cx, "office.unsaved.title", &[("name", &name(tab))]); + let owner = this.clone(); + // Cancel stops the whole close. + let cancelled = move |window: &mut Window, cx: &mut App| { + owner.update(cx, |host, cx| { + host.close_cancelled(cx); + host.dialog_done(window, cx) + }) + }; + Card::new("office-unsaved", title, cancelled) + .detail(text(cx, "office.unsaved.detail", &[])) + .action(cancel(cx)) + .action(choice( + &this, + "office-unsaved-discard", + text(cx, "office.unsaved.discard", &[]), + ButtonVariant::Danger, + move |host, window, cx| { + host.close_tab(tab, cx); + host.proceed(after, window, cx); + }, + )) + .action(choice( + &this, + "office-unsaved-save", + text(cx, "office.unsaved.save", &[]), + ButtonVariant::Primary, + move |host, window, cx| host.save(tab, Some(after), window, cx), + )) + } + Dialog::Conflict { tab, then } => { + let (tab, then) = (*tab, *then); + let title = text(cx, "office.conflict.title", &[("name", &name(tab))]); + let owner = this.clone(); + let cancelled = move |window: &mut Window, cx: &mut App| { + owner.update(cx, |host, cx| { + if then.is_some() { + host.close_cancelled(cx); + } + host.dialog_done(window, cx) + }) + }; + Card::new("office-conflict", title, cancelled) + .detail(text(cx, "office.conflict.detail", &[])) + .action(cancel(cx)) + .action(choice( + &this, + "office-conflict-reload", + text(cx, "office.conflict.reload", &[]), + ButtonVariant::Secondary, + move |host, _, cx| { + // Reloading ends the close that waited. + if then.is_some() { + host.close_cancelled(cx); + } + host.reload(tab, cx) + }, + )) + .action(choice( + &this, + "office-conflict-copy", + text(cx, "office.conflict.copy", &[]), + ButtonVariant::Secondary, + move |host, _, cx| host.save_as(tab, then, cx), + )) + .action(choice( + &this, + "office-conflict-keep", + text(cx, "office.conflict.keep", &[]), + ButtonVariant::Primary, + move |host, window, cx| host.keep_mine(tab, then, window, cx), + )) + } + }; + Some(card.into_any_element()) + } +} + +impl Dialogs for H {} + +/// A dialog's Cancel button. +pub fn cancel(cx: &App) -> impl FnOnce(Close) -> Button + 'static { + let label = text(cx, "office.cancel", &[]); + move |close| { + Button::new("office-dialog-cancel", label).on_click(move |_, window, cx| close(window, cx)) + } +} + +/// A dialog button that closes the dialog, then runs `run`. +fn choice( + this: &Entity, + id: &'static str, + label: SharedString, + variant: ButtonVariant, + run: impl Fn(&mut H, &mut Window, &mut Context) + 'static, +) -> impl FnOnce(Close) -> Button + 'static { + let this = this.clone(); + let run = Rc::new(run); + move |_| { + Button::new(id, label) + .variant(variant) + .on_click(move |_, window, cx| { + let run = run.clone(); + this.update(cx, |host, cx| { + host.core_mut().dialog = None; + run(host, window, cx); + if host.core().dialog.is_none() { + host.dialog_done(window, cx); + } + }) + }) + } +} + +/// The masked prompt for a locked file. +fn password( + this: Entity, + (path, wrong, copy, field): (&PathBuf, bool, bool, &Entity), + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> PromptDialog { + let name = file_name(path); + let title = text(cx, "office.locked.title", &[("name", &name)]); + let empty = text(cx, "office.locked.empty", &[]); + // Each ask its own dialog, so focus is its own. + let id = ("office-locked", field.entity_id().as_u64()); + let mut prompt = PromptDialog::new(id, title, field, dismiss) + .label(text(cx, "office.locked.label", &[])) + .submit(text(cx, "office.locked.open", &[])); + if wrong { + prompt = prompt.label(text(cx, "office.locked.wrong", &[])); + } + let path = path.clone(); + prompt + .check(move |text| { + if text.is_empty() { + Err(empty.clone()) + } else { + Ok(()) + } + }) + // The dialog closed first; the next ask may show. + .on_submit(move |text, _, cx| { + let (path, password) = (path.clone(), text.to_string()); + this.update(cx, |host, cx| match copy { + true => host.load(Source::Locked(path, password), cx), + false => host.open_with(path, password, cx), + }) + }) +} diff --git a/crates/ely-office/src/engine.rs b/crates/ely-office/src/engine.rs new file mode 100644 index 0000000..d824f84 --- /dev/null +++ b/crates/ely-office/src/engine.rs @@ -0,0 +1,89 @@ +//! What a document engine gives the shell (§6). + +use std::{ + io, + path::Path, + sync::{Arc, Mutex}, +}; + +use ely_platform::AppId; +use gpui::{App, Context, Entity, FocusHandle, Window}; + +use crate::Host; + +/// Where a load starts. +pub enum Load<'a, I> { + /// A file and its password, empty for none. + File(&'a Path, &'a str), + /// Text bytes already at the path, read as told. + Import(&'a Path, &'a I), +} + +/// A kernel behind the shell: loads, writes, history, views. +pub trait Engine: Sized + 'static { + type Worker: Send + 'static; + type Doc: Clone + Send + 'static; + type View: 'static; + /// What a load hands the window. + type Loaded: Send + 'static; + /// How text files are read. + type Import: Clone + Send + 'static; + + /// Loads off the window; a locked file fails PermissionDenied. + fn load(worker: &mut Self::Worker, from: Load) -> io::Result; + /// The view over a load, its document and history mark. + fn open( + worker: &Arc>, + loaded: Self::Loaded, + window: &mut Window, + cx: &mut App, + ) -> io::Result<(Self::Doc, Entity, String)>; + /// Checked before the file is overwritten; a risk stops it. + fn unsound(_worker: &mut Self::Worker, _doc: &Self::Doc) -> io::Result> { + Ok(None) + } + /// What saving in place would lose, macros aside. + fn risk(_loaded: &Self::Loaded) -> Option { + None + } + /// The undo history as a mark to compare. + fn history(worker: &mut Self::Worker, doc: &Self::Doc) -> io::Result; + /// Writes `doc` as `format` to `path`. + fn write( + worker: &mut Self::Worker, + doc: &Self::Doc, + path: &Path, + format: &str, + ) -> io::Result<()>; + /// A copy of `doc` at `path`; `doc` stays. + fn snapshot(_worker: &mut Self::Worker, _doc: &Self::Doc, _path: &Path, format: &str) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, format!("no snapshot as .{format}"))) + } + fn close(worker: &mut Self::Worker, doc: Self::Doc) -> io::Result<()>; + /// A closing view's images, for the shell to free. + fn retire(view: &Entity, cx: &mut App) -> ely_gpui_component::primitives::Retired; + /// Why the worker ended, if it did (§6.3). + fn ended(_worker: &mut Self::Worker) -> Option { + None + } + /// A fresh worker in place of an ended one. + fn restart(_app: AppId, _worker: &mut Self::Worker) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "this engine does not restart")) + } + /// Every input sent has reached the kernel. + fn settled(view: &Self::View) -> bool; + /// Edits landed so far. + fn edited(view: &Self::View) -> u64; + /// An edit is sent but not landed. + fn edit_pending(view: &Self::View) -> bool; + fn focus(view: &Self::View) -> FocusHandle; + /// The document was saved at history `mark`. + fn saved(view: &Entity, mark: &str, cx: &mut App); + /// Calls `reported` with the modified flag, `failed` with errors. + fn watch>( + view: &Entity, + cx: &mut Context, + reported: impl Fn(&mut H, &Entity, bool, &mut Context) + 'static, + failed: impl Fn(&mut H, &str, &mut Context) + 'static, + ); +} diff --git a/crates/ely-office/src/files.rs b/crates/ely-office/src/files.rs new file mode 100644 index 0000000..d63487a --- /dev/null +++ b/crates/ely-office/src/files.rs @@ -0,0 +1,264 @@ +//! Formats and the kernel half of a save (FILE-04). + +use std::{ + io, + path::{Path, PathBuf}, +}; + +use gpui::SharedString; +use quick_xml::{ + events::Event, + name::{Namespace, ResolveResult}, + reader::NsReader, +}; + +use crate::{Engine, Kind}; + +/// The lowercase extension of `path`. +fn extension(path: &Path) -> String { + path.extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_lowercase() +} + +/// The format a save of `path` writes: its own (FILE-04). +pub fn kept_format(kind: &Kind, path: &Path) -> io::Result<&'static str> { + let ext = extension(path); + kind.kept + .iter() + .find(|kept| **kept == ext) + .copied() + .ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, format!("cannot save .{ext}"))) +} + +/// The format a Save As to `path` writes, or None. +pub fn save_as_format(kind: &Kind, path: &Path) -> Option<&'static str> { + let ext = extension(path); + kind.save_as.iter().find(|format| **format == ext).copied() +} + +/// The kernel writes `format` into `work`; the bytes come back. +pub fn kernel_bytes( + worker: &mut G::Worker, + doc: &G::Doc, + work: &Path, + format: &str, +) -> io::Result> { + let copy = work.join(format!("save.{format}")); + G::write(worker, doc, ©, format)?; + let bytes = std::fs::read(©)?; + log::info!("office: the kernel wrote {} bytes of {format}", bytes.len()); + Ok(bytes) +} + +/// Whether `path` carries macros Ely will not round-trip (LOK-11). +pub fn macro_risk(kind: &Kind, path: &Path) -> io::Result { + let Some(macros) = kind.macros else { + return Ok(false); + }; + if macros.contains(&extension(path).as_str()) { + return Ok(true); + } + let bytes = std::fs::read(path)?; + if bytes.starts_with(b"PK\x03\x04") { + let names = zip_names(&bytes)?; + return Ok(names + .iter() + .any(|name| name.ends_with("vbaProject.bin") || name.starts_with("Basic/"))); + } + if bytes.starts_with(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) { + return Ok(ole_has_vba(&bytes)); + } + flat_odf_has_basic(&bytes) +} + +/// The office namespace that carries Basic libraries. +const OOO: &[u8] = b"http://openoffice.org/2004/office"; + +/// Whether flat ODF embeds a Basic library, namespaces in scope. +fn flat_odf_has_basic(bytes: &[u8]) -> io::Result { + let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); + if !text.trim_ascii_start().starts_with(b"<") { + return Ok(false); + } + let mut reader = NsReader::from_reader(text); + let mut buf = Vec::new(); + loop { + let (bound, event) = reader + .read_resolved_event_into(&mut buf) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + match (bound, event) { + (ResolveResult::Bound(Namespace(OOO)), Event::Start(tag) | Event::Empty(tag)) + if tag.local_name().as_ref() == b"library-embedded" => + { + return Ok(true); + } + (_, Event::Eof) => return Ok(false), + _ => {} + } + buf.clear(); + } +} + +/// The entry names in a zip's central directory. +fn zip_names(bytes: &[u8]) -> io::Result> { + let bad = || io::Error::new(io::ErrorKind::InvalidData, "a damaged zip directory"); + let u16_at = |at: usize| { + bytes + .get(at..at + 2) + .map(|b| u16::from_le_bytes([b[0], b[1]]) as usize) + }; + let u32_at = |at: usize| { + bytes + .get(at..at + 4) + .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize) + }; + let end = (0..bytes.len().saturating_sub(21)) + .rev() + .find(|&at| bytes[at..].starts_with(b"PK\x05\x06")) + .ok_or_else(bad)?; + let (count, mut at) = ( + u16_at(end + 10).ok_or_else(bad)?, + u32_at(end + 16).ok_or_else(bad)?, + ); + let mut names = Vec::with_capacity(count); + for _ in 0..count { + if !bytes + .get(at..) + .is_some_and(|rest| rest.starts_with(b"PK\x01\x02")) + { + return Err(bad()); + } + let name_len = u16_at(at + 28).ok_or_else(bad)?; + let skip = u16_at(at + 30).ok_or_else(bad)? + u16_at(at + 32).ok_or_else(bad)?; + let name = bytes.get(at + 46..at + 46 + name_len).ok_or_else(bad)?; + names.push(String::from_utf8_lossy(name).into_owned()); + at += 46 + name_len + skip; + } + Ok(names) +} + +/// Whether an OLE file has a `_VBA_PROJECT` directory entry. +fn ole_has_vba(bytes: &[u8]) -> bool { + let name: Vec = "_VBA_PROJECT" + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + // 128-byte entries: name, byte length, type 2. + (512..bytes.len().saturating_sub(128)) + .step_by(128) + .any(|at| { + let entry = &bytes[at..at + 128]; + entry.starts_with(&name) + && u16::from_le_bytes([entry[64], entry[65]]) as usize == name.len() + 2 + && entry[66] == 2 + }) +} + +/// A path's file name for tab titles. +pub fn file_name(path: &Path) -> SharedString { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() + .into() +} + +/// A fresh scratch folder for one document's kernel copies. +pub fn work_dir(root: &Path, id: u64) -> io::Result { + let dir = root.join(format!("doc-{id}")); + if dir.try_exists()? { + std::fs::remove_dir_all(&dir)?; + } + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KIND: Kind = Kind { + app: ely_platform::AppId::Doc, + kept: &["docx", "odt", "doc"], + save_as: &["docx", "odt"], + macros: Some(&["docm", "dotm"]), + }; + + #[test] + fn saves_keep_the_format_and_save_as_converts() { + let kept = |path: &str| kept_format(&KIND, Path::new(path)); + assert_eq!(kept("/a/Contract.DOC").expect("doc"), "doc"); + assert_eq!(kept("/a/b.odt").expect("odt"), "odt"); + assert!(kept("/a/b.pages").is_err(), "unknown formats fail"); + assert_eq!(save_as_format(&KIND, Path::new("/a/b.docx")), Some("docx")); + assert_eq!(save_as_format(&KIND, Path::new("/a/b.pdf")), None); + } + + #[test] + fn macros_are_found_by_structure_never_by_text() { + let dir = std::env::temp_dir().join(format!("ely-office-risk-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("a dir"); + let write = |name: &str, body: &[u8]| { + let path = dir.join(name); + std::fs::write(&path, body).expect("writes"); + path + }; + assert!(macro_risk(&KIND, &write("a.docm", b"PK")).expect("docm")); + let notes = write( + "notes.txt", + b"The archive path is Basic/Standard/Module1.xml and vbaProject.bin", + ); + assert!( + !macro_risk(&KIND, ¬es).expect("text"), + "words in text are not macros" + ); + let example = write( + "xml-example.txt", + b"Example: ", + ); + assert!( + !macro_risk(&KIND, &example).expect("text with a tag"), + "unbound tags are text" + ); + let root = |prefix: &str| { + format!( + "" + ) + }; + let basic = format!( + "{}", + root("ooo") + ); + assert!(macro_risk(&KIND, &write("f.fodt", basic.as_bytes())).expect("flat basic")); + let alias = format!( + "{}", + root("basic") + ); + assert!( + macro_risk(&KIND, &write("g.fodt", alias.as_bytes())).expect("aliased"), + "any bound prefix counts" + ); + let nested = format!( + "{}", + root("ooo") + ); + assert!( + macro_risk(&KIND, &write("n.fods", nested.as_bytes())).expect("nested"), + "a child may declare the namespace" + ); + let shadowed = format!( + "{}", + root("ooo") + ); + assert!( + !macro_risk(&KIND, &write("s.fodt", shadowed.as_bytes())).expect("shadowed"), + "a prefix rebound elsewhere is not Basic" + ); + assert!( + macro_risk(&KIND, &write("bad.fodt", b"")).is_err(), + "broken XML is an error" + ); + std::fs::remove_dir_all(&dir).expect("cleans"); + } +} diff --git a/crates/ely-office/src/find.rs b/crates/ely-office/src/find.rs new file mode 100644 index 0000000..7486013 --- /dev/null +++ b/crates/ely-office/src/find.rs @@ -0,0 +1,384 @@ +//! Find and replace over the active document (DOC-12, TABLE-08). + +use std::collections::VecDeque; + +use ely_gpui_component::{ + editor::{FindOptions, FindWidget}, + forms::{InputEvent, TextInput}, + primitives::{Severity, raise}, +}; +use ely_lok::{LokView, Rect}; +use gpui::{ + AnyElement, AppContext as _, Context, Entity, Focusable as _, InteractiveElement as _, + IntoElement, ParentElement as _, Styled as _, Subscription, Window, actions, div, +}; + +use crate::{Commands as _, Host, Tabs as _, text}; + +actions!(office, [FindReplace]); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Asked { + All, + Step, +} + +/// The open find box and what the kernel last found. +pub struct Finder { + /// The tab it searches. + tab: u64, + pub query: Entity, + pub replace: Option>, + pub options: FindOptions, + /// Every match from the last count, in order. + pub all: Vec, + pub current: Option, + /// The view's search count already taken. + seen: u64, + /// Requests still to answer; a newer query voids older ones. + pub pending: VecDeque<(Asked, bool)>, + /// Steps waiting for a count to land. + pub queued: VecDeque, + /// The first step marked the place for Back. + left: bool, + /// The view's edit count and undo history last seen. + edited: u64, + history: Option, + /// The edit count of the newest history check applied. + checked: u64, + /// The document changed since the count. + pub stale: bool, + _query: Subscription, +} + +pub trait Finding: Host { + fn on_find(&mut self, _: &FindReplace, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.active_tab() else { + let text = text(cx, "command.blocked.no_document", &[]); + return self.toast(text, Severity::Warning, cx); + }; + let open = self.core().finder.as_ref(); + if let Some(finder) = open.filter(|finder| finder.tab == tab.id) { + return window.focus(&finder.query.focus_handle(cx), cx); + } + let (id, seen) = (tab.id, tab.view.read(cx).search().0); + let edited = tab.view.read(cx).edited(); + let query = cx.new(|cx| TextInput::new(window, cx)); + let subscription = cx.subscribe(&query, |host, _, event, cx| match event { + InputEvent::Changed => host.count_matches(cx), + InputEvent::Submit => host.step(true, cx), + _ => {} + }); + window.focus(&query.focus_handle(cx), cx); + self.core_mut().finder = Some(Finder { + tab: id, + query, + replace: None, + options: FindOptions::default(), + all: Vec::new(), + current: None, + seen, + pending: VecDeque::new(), + queued: VecDeque::new(), + left: false, + edited, + history: None, + checked: edited, + stale: false, + _query: subscription, + }); + self.check_history(id, edited, false, cx); + cx.notify(); + } + + /// Checks the undo history in the background; moves mark stale. + fn check_history(&mut self, id: u64, edited: u64, counted: bool, cx: &mut Context) { + self.read_in( + id, + |worker, doc| worker.values(doc, ".uno:Undo"), + move |host, read, _, cx| { + let history = match read { + Ok(history) => history, + Err(error) => return host.find_failed(&error, cx), + }; + let slug = Self::KIND.app.slug(); + let open = host.core_mut().finder.as_mut(); + let Some(finder) = open.filter(|finder| finder.tab == id) else { + return; + }; + // A check for older edits never overrides a newer one. + if edited < finder.checked { + return; + } + let moved = finder.history.as_ref().is_some_and(|old| *old != history); + if counted { + finder.stale = false; + } else if moved && edited > finder.checked { + log::info!("{slug}: the text changed under the finder"); + finder.stale = true; + } + (finder.checked, finder.history) = (edited, Some(history)); + cx.notify(); + }, + cx, + ); + } + + /// The query and its options as `find.run` values. + fn search_values(&self, cx: &Context, command: &str, back: bool) -> Option> { + let finder = self.here()?; + let query = finder.query.read(cx).text().to_string(); + if query.is_empty() { + return None; + } + let options = finder.options; + let flag = |on: bool, yes: &str, no: &str| if on { yes } else { no }.to_string(); + Some(vec![ + query, + back.to_string(), + command.to_string(), + flag(options.case, "0", "256"), + flag(options.word, "16", "0"), + flag(options.regex, "2", "1"), + ]) + } + + fn run_search( + &mut self, + id: &str, + values: Vec, + asked: Option, + cx: &mut Context, + ) { + if let Some((finder, asked)) = self.core_mut().finder.as_mut().zip(asked) { + finder.pending.push_back((asked, true)); + } + let values: Vec<&str> = values.iter().map(String::as_str).collect(); + self.run_active(id, &values, cx); + } + + fn find_failed(&self, error: &std::io::Error, cx: &mut Context) { + let text = text(cx, "office.error.engine", &[("error", &error.to_string())]); + self.toast(text, Severity::Danger, cx); + } + + /// Counts every match; the kernel highlights them. + fn count_matches(&mut self, cx: &mut Context) { + if let Some(finder) = self.core_mut().finder.as_mut() { + finder + .pending + .iter_mut() + .for_each(|(_, live)| *live = false); + finder.queued.clear(); + } + let Some(values) = self.search_values(cx, "1", false) else { + if let Some(finder) = self.core_mut().finder.as_mut() { + finder.all.clear(); + finder.current = None; + } + return cx.notify(); + }; + self.run_search("find.run", values, Some(Asked::All), cx); + } + + fn step(&mut self, forward: bool, cx: &mut Context) { + let Some(finder) = self.here() else { + return; + }; + if finder.stale { + return self.count_matches(cx); + } + if counting(finder) { + let finder = self.core_mut().finder.as_mut().expect("the finder is open"); + finder.queued.push_back(forward); + return; + } + if let Some(values) = self.search_values(cx, "0", !forward) { + if !std::mem::replace( + &mut self.core_mut().finder.as_mut().expect("open").left, + true, + ) { + self.search_leaves(cx); + } + self.run_search("find.run", values, Some(Asked::Step), cx); + } + } + + /// Replaces the chosen match, or every one; then counts again. + fn replace(&mut self, every: bool, cx: &mut Context) { + let Some(finder) = self.here() else { + return; + }; + let Some(field) = &finder.replace else { + return; + }; + if finder.stale { + return self.count_matches(cx); + } + if !every && finder.current.is_none() { + return self.step(true, cx); + } + let with = field.read(cx).text().to_string(); + let Some(mut values) = self.search_values(cx, if every { "3" } else { "2" }, false) else { + return; + }; + values.push(with); + // A replace reports only when nothing is left. + self.run_search("find.replace", values, None, cx); + self.count_matches(cx); + } + + /// Takes a search outcome or an edit. + fn found(&mut self, id: u64, view: &Entity, cx: &mut Context) { + let Some(finder) = self + .core() + .finder + .as_ref() + .filter(|finder| finder.tab == id) + else { + return; + }; + let edited = view.read(cx).edited(); + let (count, found) = { + let (count, found) = view.read(cx).search(); + (count, found.to_vec()) + }; + let outcomes = usize::try_from(count - finder.seen).expect("a count of outcomes"); + let asked = (0..outcomes) + .fold(None, |last, _| { + let finder = self.core_mut().finder.as_mut().expect("the finder is open"); + let next = finder.pending.pop_front(); + next.map(|(asked, live)| live.then_some(asked)).or(last) + }) + .flatten(); + let finder = self.core().finder.as_ref().expect("the finder is open"); + let counted = asked == Some(Asked::All); + if edited != finder.edited || counted { + let finder = self.core_mut().finder.as_mut().expect("the finder is open"); + finder.edited = edited; + // A fresh count is current now; older checks drop. + if counted { + (finder.stale, finder.checked) = (false, edited); + } + self.check_history(id, edited, counted, cx); + } + let finder = self.core_mut().finder.as_mut().expect("the finder is open"); + if count != finder.seen { + finder.seen = count; + match (asked, found.first()) { + (Some(Asked::All), _) | (Some(Asked::Step), None) => { + finder.all = found.clone(); + finder.current = None; + } + (Some(Asked::Step), Some(first)) => { + finder.current = finder.all.iter().position(|rect| rect == first); + let first = *first; + view.update(cx, |view, cx| view.show(first, cx)); + } + (None, _) => log::info!( + "{}: a search outcome nobody asked for", + Self::KIND.app.slug() + ), + } + log::info!( + "{}: {} matches, at {:?}", + Self::KIND.app.slug(), + finder.all.len(), + finder.current + ); + } + let finder = self.core_mut().finder.as_mut().expect("the finder is open"); + let chosen = finder.current.map(|at| finder.all[at]); + if chosen.is_some() && view.read(cx).selection().first() != chosen.as_ref() { + finder.current = None; + } + if !counting(finder) { + for forward in std::mem::take(&mut finder.queued) { + self.step(forward, cx); + } + } + } + + fn close_find(&mut self, window: &mut Window, cx: &mut Context) { + self.core_mut().finder = None; + if let Some(tab) = self.active_tab() { + window.focus(&tab.view.read(cx).focus_handle().clone(), cx); + } + cx.notify(); + } + + /// The finder, when it searches the active tab. + fn here(&self) -> Option<&Finder> { + self.core() + .finder + .as_ref() + .filter(|finder| Some(finder.tab) == self.core().active) + } + + /// The find box over the page, while open. + fn find_box(&self, cx: &mut Context) -> Option { + let finder = self.here()?; + let this = cx.entity(); + let mut widget = FindWidget::new( + "office-find", + &finder.query, + finder.current, + finder.all.len(), + ) + .options(finder.options) + .on_options({ + let this = this.clone(); + move |options, _, cx| { + this.update(cx, |host, cx| { + if let Some(finder) = host.core_mut().finder.as_mut() { + finder.options = options; + } + host.count_matches(cx); + }) + } + }) + .on_step({ + let this = this.clone(); + move |forward, _, cx| this.update(cx, |host, cx| host.step(forward, cx)) + }) + .on_replace({ + let this = this.clone(); + move |every, _, cx| this.update(cx, |host, cx| host.replace(every, cx)) + }) + .on_toggle_replace({ + let this = this.clone(); + move |show, window, cx| { + this.update(cx, |host, cx| { + if let Some(finder) = host.core_mut().finder.as_mut() { + finder.replace = show.then(|| cx.new(|cx| TextInput::new(window, cx))); + } + cx.notify(); + }) + } + }) + .on_close(move |window, cx| this.update(cx, |host, cx| host.close_find(window, cx))); + if let Some(field) = &finder.replace { + widget = widget.replace(field); + } + if finder.stale { + widget = widget.error(text(cx, "office.find.stale", &[])); + } + let place = div() + .absolute() + .top_2() + .right_4() + .block_mouse_except_scroll() + .child(widget); + Some(raise("office-find-layer", place).into_any_element()) + } +} + +impl> Finding for H {} + +/// Whether a live count is still on its way. +fn counting(finder: &Finder) -> bool { + finder + .pending + .iter() + .any(|(asked, live)| *live && *asked == Asked::All) +} diff --git a/crates/ely-office/src/lib.rs b/crates/ely-office/src/lib.rs new file mode 100644 index 0000000..bb04d66 --- /dev/null +++ b/crates/ely-office/src/lib.rs @@ -0,0 +1,380 @@ +//! The shell document apps share (§6), over any engine. + +pub mod chart; +mod autosave; +mod chrome; +mod close; +mod dialogs; +pub mod engine; +pub mod files; +mod find; +pub mod lok; +mod open; +mod recover; +pub mod panel; +mod save; +mod stopped; +mod zoom; + +use std::{ + io, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use ely_files::{SaveState, Session, Sessions}; +use ely_gpui_component::primitives::Severity; +use ely_platform::AppId; +use ely_ui::Root; +use gpui::{ + App, AppContext as _, Context, Entity, FocusHandle, SharedString, TaskExt as _, WeakEntity, + Window, +}; + +pub use autosave::Autosaving; +pub use chrome::Chrome; +pub use close::{After, Closing}; +pub use dialogs::{Dialog, Dialogs, cancel}; +pub use engine::{Engine, Load}; +pub use find::{FindReplace, Finder, Finding}; +pub use lok::{Commands, Lok, start}; +pub use open::{Opening, Source}; +pub use recover::{Keeping, Recovering}; +pub use stopped::{Stopped, Stopping}; +pub use save::{Saving, folder}; +pub use zoom::{ActualSize, ZoomIn, ZoomOut, Zooming, bind_zoom_keys, zoom_items}; + +/// Every shared trait, for `use ely_office::prelude::*`. +pub mod prelude { + pub use crate::{Chrome, Closing, Commands, Dialogs, Finding, Host, Opening, Saving, Stopping, Tabs, Zooming}; +} + +/// What one app opens and writes. +pub struct Kind { + pub app: AppId, + /// Formats a save keeps, as the engine names them. + pub kept: &'static [&'static str], + /// Formats Save As converts to; the first is the default. + pub save_as: &'static [&'static str], + /// Extensions that always carry macros; None for macro-free formats. + pub macros: Option<&'static [&'static str]>, +} + +/// An app built on the shared shell. +pub trait Host: Sized + 'static { + /// What the app keeps per tab. + type Extra: Default; + type Engine: Engine; + const KIND: Kind; + fn core(&self) -> &Core; + fn core_mut(&mut self) -> &mut Core; + /// Runs when the kernel reports on tab `id`. + fn observed(&mut self, id: u64, view: &Entity>, cx: &mut Context); + /// Runs before a search first moves the cursor. + fn search_leaves(&mut self, _: &mut Context) {} + /// Runs when a close stops: question or save cancelled. + fn close_cancelled(&mut self, _: &mut Context) {} + /// An outside change met a save; true if shown. + fn conflicted(&mut self, _id: u64, _: &mut Context) -> bool { + false + } + /// Runs as tab `tab` leaves, saved, clean or discarded. + fn tab_closed(&mut self, _tab: &Tab, _: &mut Context) {} + + /// Tab `tab`'s document as last seen; reads and numbered commands. + fn read_at(&self, _tab: u64, _cx: &App) -> Option<::Doc> { + None + } +} + +/// The view type of host `H`'s engine. +pub type View = <::Engine as Engine>::View; +/// The worker type of host `H`'s engine. +pub type WorkerOf = <::Engine as Engine>::Worker; +/// The document type of host `H`'s engine. +pub type DocOf = <::Engine as Engine>::Doc; + +/// Why saving in place would lose something. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Risk { + /// Macros Ely will not round-trip (LOK-11). + Macros, + /// Signatures an edit breaks (§11.4). + Signed, + /// A damaged file opened through a repaired copy (PDF-14). + Repaired, +} + +/// One open document. +pub struct Tab { + pub id: u64, + pub view: Entity, + pub doc: G::Doc, + pub session: Session, + /// Its id in the same-file registry; untitled tabs have none. + pub registry: Option, + pub name: SharedString, + /// The kernel says it is modified. + pub modified: bool, + /// Why Save writes a copy, if it does. + pub risk: Option, + /// The kernel's undo history at the last load or save. + pub mark: String, + pub work: PathBuf, + /// A save's export runs in the background. + pub exporting: bool, + /// Reopen from the file once memory matches it. + pub reopen: bool, + pub extra: E, +} + +impl Tab { + pub fn dirty(&self, cx: &App) -> bool { + self.modified + || self.session.state() != SaveState::Saved + || G::edit_pending(self.view.read(cx)) + } + + /// The status line for this tab (FILE-02). + pub fn state_key(&self, cx: &App) -> &'static str { + match self.session.state() { + _ if self.risk == Some(Risk::Macros) => "office.state.risk", + _ if self.risk == Some(Risk::Signed) => "office.state.signed", + _ if self.risk == Some(Risk::Repaired) => "office.state.repaired", + _ if self.exporting => "office.state.saving", + SaveState::Saving => "office.state.saving", + SaveState::ExternalModified => "office.state.external", + SaveState::SaveFailed => "office.state.failed", + _ if self.dirty(cx) => "office.state.dirty", + _ => "office.state.saved", + } + } +} + +/// A continuation run once a tab's input lands. +pub type Waiting = Box)>; + +/// The shared state of one window. +pub struct Core { + pub worker: Arc>>, + pub sessions: Sessions, + pub tabs: Vec>, + pub active: Option, + /// Loads in flight: tab id and name. + pub loading: Vec<(u64, SharedString)>, + pub focus: FocusHandle, + pub root: WeakEntity, + pub work: PathBuf, + pub next: u64, + pub untitled: u32, + pub dialog: Option, + /// Locked files waiting: path, a password failed, a copy. + pub locked: std::collections::VecDeque<(PathBuf, bool, bool)>, + /// The window that async results update. + pub window: Option, + /// Work that waits for a tab's queued input. + pub waiting: Vec<(u64, Waiting)>, + /// Saves that wait for a tab's running export. + pub queued_saves: Vec<(u64, Waiting)>, + /// The open find box. + pub finder: Option, + /// Recovery points and crash return; none when off or failed. + pub keeping: Option, + /// Why recovery could not start, shown once. + pub recovery_off: Option, + /// The engine ended under the open tabs (§6.3). + pub stopped: Option, + /// Edit counts at the last autosave look, per tab. + pub quiet: std::collections::HashMap, +} + +impl Core { + pub fn new(worker: Arc>>, work: PathBuf, cx: &mut Context) -> Self { + Core { + worker, + sessions: Sessions::default(), + tabs: Vec::new(), + active: None, + loading: Vec::new(), + focus: cx.focus_handle(), + root: WeakEntity::new_invalid(), + work, + next: 0, + untitled: 0, + dialog: None, + locked: Default::default(), + window: None, + waiting: Vec::new(), + queued_saves: Vec::new(), + finder: None, + keeping: None, + recovery_off: None, + stopped: None, + quiet: Default::default(), + } + } +} + +/// An emptied scratch root under an app's data folder. +pub fn fresh_work(data: &std::path::Path) -> io::Result { + let work = data.join("work"); + if work.try_exists()? { + std::fs::remove_dir_all(&work)?; + } + std::fs::create_dir_all(&work)?; + Ok(work) +} + +/// Runs `f` on the host inside its window. +pub fn in_window( + this: &WeakEntity, + cx: &mut gpui::AsyncApp, + f: impl FnOnce(&mut H, &mut Window, &mut Context) -> R, +) -> anyhow::Result { + let handle = this + .read_with(cx, |host, _| host.core().window)? + .ok_or_else(|| anyhow::anyhow!("the documents have no window"))?; + cx.update_window(handle, |_, window, cx| { + this.update(cx, |host, cx| f(host, window, cx)) + })? +} + +/// Opens the print panel after GPUI releases the app. +pub fn print_later( + pdf: std::path::PathBuf, + cx: &mut Context, + failed: impl FnOnce(&mut H, String, &mut Context) + 'static, +) { + // The panel's modal loop calls back into GPUI. + cx.spawn(async move |this, cx| { + let printed = ely_platform::print::print_pdf(&pdf); + // The copy may hold what a password guarded. + if let Err(error) = std::fs::remove_file(&pdf) { + log::error!("office: the print copy {} stays: {error}", pdf.display()); + } + match printed { + Ok(printed) => log::info!("office: print done, printed {printed}"), + Err(error) => { + if this + .update(cx, |host, cx| failed(host, error.to_string(), cx)) + .is_err() + { + log::error!("office: printing failed after its window closed: {error}"); + } + } + } + }) + .detach(); +} + +/// A localized message. +pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString { + ely_ui::i18n::text(cx, key, args) +} + +/// Tabs, toasts and queued input. +pub trait Tabs: Host { + fn tab(&self, id: u64) -> Option<&Tab> { + self.core().tabs.iter().find(|tab| tab.id == id) + } + + fn tab_mut(&mut self, id: u64) -> Option<&mut Tab> { + self.core_mut().tabs.iter_mut().find(|tab| tab.id == id) + } + + fn active_tab(&self) -> Option<&Tab> { + self.core().active.and_then(|id| self.tab(id)) + } + + fn toast(&self, text: SharedString, severity: Severity, cx: &mut App) { + log::warn!("{}: {text}", Self::KIND.app.slug()); + self.toast_unlogged(text, severity, cx); + } + + /// A success toast with a button that opens `path`. + fn toast_output(&self, text: SharedString, path: std::path::PathBuf, cx: &mut App) { + log::info!("{}: {text}", Self::KIND.app.slug()); + if let Some(root) = self.core().root.upgrade() { + root.update(cx, |root, cx| root.toast_output(text, Severity::Success, path, cx)); + } + } + + /// A toast quoting document text; the caller logs a summary. + fn toast_unlogged(&self, text: SharedString, severity: Severity, cx: &mut App) { + if let Some(root) = self.core().root.upgrade() { + root.update(cx, |root, cx| root.toast(text, severity, cx)); + } + } + + /// A kernel failure as a toast. + fn engine_failed(&self, error: &str, cx: &mut App) { + let text = text(cx, "office.error.engine", &[("error", error)]); + self.toast(text, Severity::Danger, cx); + } + + /// Whether every input sent to `id` reached the kernel. + fn settled(&self, id: u64, cx: &App) -> bool { + self.tab(id) + .is_none_or(|tab| Self::Engine::settled(tab.view.read(cx))) + } + + /// Runs `then` once the queued input of `id` lands. + fn when_settled( + &mut self, + id: u64, + then: impl FnOnce(&mut Self, &mut Window, &mut Context) + 'static, + ) { + log::info!("{}: tab {id} waits for queued input", Self::KIND.app.slug()); + self.core_mut().waiting.push((id, Box::new(then))); + } + + /// Reads tab `id` in the background; `then` gets the window. + fn read_in( + &self, + id: u64, + read: impl FnOnce(&mut WorkerOf, &DocOf) -> io::Result + Send + 'static, + then: impl FnOnce(&mut Self, io::Result, &mut Window, &mut Context) + 'static, + cx: &mut Context, + ) { + let Some(tab) = self.tab(id) else { + return; + }; + let doc = self.read_at(id, cx).unwrap_or_else(|| tab.doc.clone()); + let worker = Arc::clone(&self.core().worker); + cx.spawn(async move |this, cx| { + let read = cx + .background_executor() + .spawn(async move { read(&mut worker.lock().expect("the worker lock"), &doc) }) + .await; + in_window(&this, cx, |host, window, cx| then(host, read, window, cx)) + }) + .detach_and_log_err(cx); + } + + /// A dialog's read; dropped once tab `id` is hidden. + fn ask_in( + &self, + id: u64, + read: impl FnOnce(&mut WorkerOf, &DocOf) -> io::Result + Send + 'static, + then: impl FnOnce(&mut Self, io::Result, &mut Window, &mut Context) + 'static, + cx: &mut Context, + ) { + self.read_in( + id, + read, + move |host, read, window, cx| { + if host.core().active != Some(id) { + log::info!( + "{}: tab {id} left; its dialog is dropped", + Self::KIND.app.slug() + ); + return; + } + then(host, read, window, cx) + }, + cx, + ); + } +} + +impl Tabs for H {} diff --git a/crates/ely-office/src/lok.rs b/crates/ely-office/src/lok.rs new file mode 100644 index 0000000..bfea7b8 --- /dev/null +++ b/crates/ely-office/src/lok.rs @@ -0,0 +1,227 @@ +//! LibreOfficeKit as the shell's engine (§7). + +use std::{ + io, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use ely_lok::{Doc, LokError, LokView, Reported, Worker, sheet::csv::Import}; +use ely_platform::AppId; +use gpui::{App, Context, Entity, FocusHandle, Window}; + +use crate::engine::{Engine, Load}; +use crate::{Host, Tabs, text}; + +/// The LOK engine. +pub struct Lok; + +/// A load: the document, its layout, its history. +pub struct Loaded { + doc: Doc, + laid: ely_lok::Layout, + mark: String, +} + +impl Engine for Lok { + type Worker = Worker; + type Doc = Doc; + type View = LokView; + type Loaded = Loaded; + type Import = Import; + + fn load(worker: &mut Worker, from: Load) -> io::Result { + let doc = match from { + Load::File(path, password) => worker.unlock(&ely_lok::file_url(path), password)?, + Load::Import(path, how) => worker.import(&ely_lok::file_url(path), how)?, + }; + // Layout and history too: the window never waits. + let laid = ely_lok::layout(worker, &doc)?; + let mark = worker.values(&doc, ".uno:Undo")?; + Ok(Loaded { doc, laid, mark }) + } + + fn open( + worker: &Arc>, + loaded: Loaded, + _: &mut Window, + cx: &mut App, + ) -> io::Result<(Doc, Entity, String)> { + let Loaded { doc, laid, mark } = loaded; + let view = LokView::open(Arc::clone(worker), doc.clone(), laid, cx); + Ok((doc, view, mark)) + } + + fn history(worker: &mut Worker, doc: &Doc) -> io::Result { + worker.values(doc, ".uno:Undo") + } + + fn write(worker: &mut Worker, doc: &Doc, path: &Path, format: &str) -> io::Result<()> { + doc.save_owned(worker, &ely_lok::file_url(path), format) + } + + fn snapshot(worker: &mut Worker, doc: &Doc, path: &Path, format: &str) -> io::Result<()> { + doc.save_as(worker, &ely_lok::file_url(path), format) + } + + fn close(worker: &mut Worker, doc: Doc) -> io::Result<()> { + worker.close_doc(doc) + } + + fn retire(view: &Entity, cx: &mut App) -> ely_gpui_component::primitives::Retired { + view.update(cx, |view, _| view.retire_all()) + } + + fn ended(worker: &mut Worker) -> Option { + worker.ended() + } + + fn restart(app: AppId, worker: &mut Worker) -> io::Result<()> { + *worker = spawn(app)?; + worker.ready()?; + worker.set_author(&ely_platform::user::name()?) + } + + fn settled(view: &LokView) -> bool { + view.settled() + } + + fn edited(view: &LokView) -> u64 { + view.edited() + } + + fn edit_pending(view: &LokView) -> bool { + view.edit_pending() + } + + fn focus(view: &LokView) -> FocusHandle { + view.focus_handle().clone() + } + + /// The kernel reports its own modified status. + fn saved(_: &Entity, _: &str, _: &mut App) {} + + fn watch>( + view: &Entity, + cx: &mut Context, + reported: impl Fn(&mut H, &Entity, bool, &mut Context) + 'static, + failed: impl Fn(&mut H, &str, &mut Context) + 'static, + ) { + cx.subscribe(view, move |host, _, error: &LokError, cx| match error.0.starts_with(ely_lok::STALE) { + true => failed(host, &text(cx, "office.error.changed", &[]), cx), + false => failed(host, &error.0, cx), + }) + .detach(); + // The status shows the zoom. + cx.subscribe(view, |_, _, _: &ely_lok::Zoomed, cx| cx.notify()).detach(); + cx.subscribe(view, move |host, view, _: &Reported, cx| { + let modified = view.read(cx).state(".uno:ModifiedStatus") == Some("true"); + reported(host, &view, modified, cx) + }) + .detach(); + } +} + +/// The worker and the scratch root of `app`. +pub fn start(app: AppId, cx: &mut App) -> io::Result<(Arc>, PathBuf)> { + let data = ely_platform::data_dir(app).map_err(io::Error::other)?; + let work = crate::fresh_work(&data)?; + let worker = Arc::new(Mutex::new(spawn(app)?)); + // The window opens now; the kernel finishes starting behind it. + let starting = Arc::clone(&worker); + let started = cx.background_executor().spawn(async move { + let mut worker = starting.lock().expect("the worker lock"); + worker.ready()?; + worker.set_author(&ely_platform::user::name()?) + }); + cx.spawn(async move |_| { + if let Err(error) = started.await { + ely_platform::startup::fail(app, &format!("the document engine did not start: {error}")); + } + }) + .detach(); + follow_author(&worker, cx); + Ok((worker, work)) +} + +/// A worker process on the app's profile, not yet checked. +fn spawn(app: AppId) -> io::Result { + let data = ely_platform::data_dir(app).map_err(io::Error::other)?; + let install = ely_lok::install_dir()?; + let worker = Worker::launch(&install, &data.join("lok-profile"))?; + log::info!("{}: worker from {}", app.slug(), install.display()); + Ok(worker) +} + +/// A new author name signs from the next comment on. +fn follow_author(worker: &Arc>, cx: &mut App) { + let worker = Arc::clone(worker); + let mut signed = ely_ui::settings::current(cx).author.clone(); + cx.observe_global::(move |cx| { + let author = ely_ui::settings::current(cx).author.clone(); + if author == signed { + return; + } + signed = author; + let worker = Arc::clone(&worker); + cx.background_executor() + .spawn(async move { + let set = ely_platform::user::name().and_then(|name| worker.lock().expect("the worker lock").set_author(&name)); + if let Err(error) = set { + log::error!("lok: the new author name is not set: {error}"); + } + }) + .detach(); + }) + .detach(); +} + +/// Mapped UNO commands on LOK tabs. +pub trait Commands: Host { + /// Runs a mapped command on the active tab. + fn run_active(&mut self, id: &str, values: &[&str], cx: &mut Context) { + self.run_in(self.core().active, id, values, cx); + } + + /// Runs a mapped command on tab `tab`. + fn run_in(&mut self, tab: Option, id: &str, values: &[&str], cx: &mut Context) { + let Some(tab) = tab.and_then(|tab| self.tab(tab)) else { + let text = text(cx, "command.blocked.no_document", &[]); + return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx); + }; + log::info!("{}: runs {id}", Self::KIND.app.slug()); + // Numbers hold only in the state they were read. + let addressed = ely_lok::commands::find(id).is_ok_and(|command| command.addressed(values)); + let sent = match (addressed, self.read_at(tab.id, cx)) { + (true, Some(at)) => tab.view.update(cx, |view, _| view.run_at(&at, id, values)), + _ => tab.view.update(cx, |view, _| view.run(id, values)), + }; + if let Err(error) = sent { + self.engine_failed(&error.to_string(), cx); + } + } + + /// Runs `commands` made from tab `tab`'s last read, as one. + fn run_all_in(&mut self, tab: u64, commands: &[(&str, &[&str])], cx: &mut Context) { + let (Some(open), Some(at)) = (self.tab(tab), self.read_at(tab, cx)) else { + return log::warn!("{}: tab {tab} has no read to run from", Self::KIND.app.slug()); + }; + log::info!("{}: runs {} commands from one read", Self::KIND.app.slug(), commands.len()); + if let Err(error) = open.view.update(cx, |view, _| view.run_all_at(&at, commands)) { + self.engine_failed(&error.to_string(), cx); + } + } + + /// Runs a numbered command against the read state `at`. + fn run_on(&mut self, tab: u64, at: &Doc, id: &str, values: &[&str], cx: &mut Context) { + let Some(tab) = self.tab(tab) else { + return; + }; + log::info!("{}: runs {id} at a read state", Self::KIND.app.slug()); + if let Err(error) = tab.view.update(cx, |view, _| view.run_at(at, id, values)) { + self.engine_failed(&error.to_string(), cx); + } + } +} + +impl> Commands for H {} diff --git a/crates/ely-office/src/open.rs b/crates/ely-office/src/open.rs new file mode 100644 index 0000000..887e16e --- /dev/null +++ b/crates/ely-office/src/open.rs @@ -0,0 +1,280 @@ +//! Opening user files and copies over one worker (FILE-03). + +use std::{ + io, + path::{Path, PathBuf}, + sync::Arc, +}; + +use ely_files::{Open, Session}; +use gpui::{AppContext as _, Context, Entity, TaskExt as _, Window}; + +use crate::engine::{Engine, Load}; +use crate::files::{self, file_name}; +use crate::{Host, Tab, Tabs, View, in_window, text}; + +/// How host `H`'s engine reads text files. +type ImportOf = <::Engine as Engine>::Import; + +/// What a load starts from. +pub enum Source { + /// A user file under its registry id, with its password. + File(PathBuf, usize, String), + /// A template copy; the result is untitled. + Copy(PathBuf), + /// A copy that needed a password, with the one given. + Locked(PathBuf, String), + /// Checked text bytes, where they go, how to read. + Import(PathBuf, Arc>, I), +} + +pub trait Opening: Host { + /// An untitled copy of a local template. + fn new_from_file(&mut self, template: &Path, cx: &mut Context) { + let name = file_name(template); + let copy = files::work_dir(&self.core().work, self.core().next).and_then(|dir| { + let copy = dir.join(name.as_ref()); + std::fs::copy(template, ©)?; + Ok(copy) + }); + match copy { + Ok(copy) => self.load(Source::Copy(copy), cx), + Err(error) => self.failed_open(&name, &error, cx), + } + } + + /// A new document from checked text bytes named `name`. + fn import_text( + &mut self, + name: &str, + bytes: Arc>, + how: ImportOf, + cx: &mut Context, + ) { + match files::work_dir(&self.core().work, self.core().next) { + Ok(dir) => self.load(Source::Import(dir.join(name), bytes, how), cx), + Err(error) => self.failed_open(name, &error, cx), + } + } + + /// An untitled document from `contents` named `name`. + fn new_from_text(&mut self, name: &str, contents: &str, cx: &mut Context) { + let copy = files::work_dir(&self.core().work, self.core().next).and_then(|dir| { + let copy = dir.join(name); + std::fs::write(©, contents)?; + Ok(copy) + }); + match copy { + Ok(copy) => self.load(Source::Copy(copy), cx), + Err(error) => self.failed_open(name, &error, cx), + } + } + + /// Opens a user file; an open one gets focus (FILE-03). + fn open_file(&mut self, path: PathBuf, cx: &mut Context) { + self.open_with(path, String::new(), cx); + } + + /// Opens a user file with its password. + fn open_with(&mut self, path: PathBuf, password: String, cx: &mut Context) { + match self.core_mut().sessions.open(&path, false) { + Ok(Open::Focus(registry)) => { + let core = self.core_mut(); + core.active = core + .tabs + .iter() + .find(|tab| tab.registry == Some(registry)) + .map(|tab| tab.id); + cx.notify(); + } + Ok(Open::Fresh(registry) | Open::View(registry)) => { + self.load(Source::File(path, registry, password), cx) + } + Err(error) => self.failed_open(&path.display().to_string(), &error, cx), + } + } + + fn failed_open(&self, name: &str, error: &io::Error, cx: &mut gpui::App) { + log::error!("{}: {name} did not open: {error}", Self::KIND.app.slug()); + let error = error.to_string(); + let text = text( + cx, + "office.error.open", + &[("name", name), ("error", &error)], + ); + self.toast(text, ely_gpui_component::primitives::Severity::Danger, cx); + } + + /// Loads off the main thread; the tab takes focus. + fn load(&mut self, source: Source>, cx: &mut Context) { + let id = self.core().next; + self.core_mut().next += 1; + self.load_into(id, source, cx); + } + + /// Loads into tab id `id`, taken earlier from `next`. + fn load_into(&mut self, id: u64, source: Source>, cx: &mut Context) { + let (path, password, text) = match &source { + Source::File(path, _, password) => (path.clone(), password.clone(), None), + Source::Copy(path) => (path.clone(), String::new(), None), + Source::Locked(path, password) => (path.clone(), password.clone(), None), + Source::Import(path, bytes, how) => ( + path.clone(), + String::new(), + Some((Arc::clone(bytes), how.clone())), + ), + }; + let written = path.clone(); + self.core_mut().loading.push((id, file_name(&path))); + cx.notify(); + let worker = Arc::clone(&self.core().worker); + cx.spawn(async move |this, cx| { + let loaded = cx + .background_spawn(async move { + if let Some((bytes, _)) = &text { + std::fs::write(&written, bytes.as_slice())?; + } + let mut worker = worker.lock().expect("the worker lock"); + let from = match &text { + None => Load::File(&written, &password), + Some((_, how)) => Load::Import(&written, how), + }; + Self::Engine::load(&mut worker, from) + }) + .await; + in_window(&this, cx, |host, window, cx| { + host.loaded(id, source, loaded, window, cx) + }) + }) + .detach_and_log_err(cx); + } + + fn loaded( + &mut self, + id: u64, + source: Source>, + loaded: io::Result<::Loaded>, + window: &mut Window, + cx: &mut Context, + ) { + self.core_mut() + .loading + .retain(|(loading, _)| *loading != id); + let imported = matches!(source, Source::Import(..)); + let (path, registry, tried) = match source { + Source::File(path, registry, password) => (path, Some(registry), !password.is_empty()), + Source::Copy(path) | Source::Import(path, ..) => (path, None, false), + Source::Locked(path, _) => (path, None, true), + }; + let opened = loaded.and_then(|loaded| { + let risk = match files::macro_risk(&Self::KIND, &path)? { + true => Some(crate::Risk::Macros), + false => Self::Engine::risk(&loaded), + }; + let (work, session) = match registry { + Some(_) => ( + files::work_dir(&self.core().work, id)?, + Session::load(path.clone())?, + ), + None => { + let dir = path.parent().expect("a copy sits in its work dir"); + (dir.to_path_buf(), Session::new_file()) + } + }; + let (doc, view, mark) = Self::Engine::open(&self.core().worker, loaded, window, cx)?; + Ok((doc, work, session, risk, view, mark)) + }); + let (doc, work, session, risk, view, mark) = match opened { + Ok(opened) => opened, + Err(error) => { + if let Some(registry) = registry { + self.core_mut().sessions.close(registry); + } + cx.notify(); + // A locked copy asks too, and stays a copy. + if error.kind() == io::ErrorKind::PermissionDenied && !imported { + return crate::Dialogs::ask_password(self, path, tried, registry.is_none(), window, cx); + } + return self.failed_open(&path.display().to_string(), &error, cx); + } + }; + let name = match registry { + Some(_) => file_name(&path), + None if imported => file_name(&path), + None => { + self.core_mut().untitled += 1; + let n = self.core().untitled.to_string(); + text(cx, "office.untitled", &[("n", &n)]) + } + }; + log::info!( + "{}: opened {name} as tab {id}, risk {risk:?}", + Self::KIND.app.slug() + ); + self.watch(id, &view, cx); + window.focus(&Self::Engine::focus(view.read(cx)), cx); + self.core_mut().tabs.push(Tab { + id, + view, + doc, + session, + registry, + name, + modified: false, + risk, + mark, + work, + exporting: false, + reopen: false, + extra: Default::default(), + }); + self.core_mut().active = Some(id); + // Points from now on; a crash copy comes back unsaved. + let named = self.tab(id).expect("just opened").name.clone(); + crate::Recovering::keep_tab(self, id, &named, tried, cx); + crate::Recovering::reopened(self, id, &path, cx); + cx.notify(); + } + + /// Engine errors become toasts; kernel edits mark the session. + fn watch(&mut self, id: u64, view: &Entity>, cx: &mut Context) { + let reported = move |host: &mut Self, + view: &Entity>, + modified: bool, + cx: &mut Context| { + if let Some(tab) = host.tab_mut(id) { + match (modified, tab.modified) { + (true, false) => tab.session.edit(), + (false, true) => tab.session.reverted(), + _ => {} + } + tab.modified = modified; + } + host.observed(id, view, cx); + if Self::Engine::settled(view.read(cx)) { + let (ready, rest): (Vec<_>, Vec<_>) = host + .core_mut() + .waiting + .drain(..) + .partition(|(tab, _)| *tab == id); + host.core_mut().waiting = rest; + for (_, then) in ready { + cx.spawn(async move |this, cx| in_window(&this, cx, then)) + .detach_and_log_err(cx); + } + } + cx.notify(); + }; + Self::Engine::watch(view, cx, reported, move |host, error, cx| { + // A released tab's view may finish its queue late. + if host.tab(id).is_none() { + log::info!("office: tab {id} failed after it closed"); + return; + } + host.engine_failed(error, cx); + crate::Stopping::check_engine(host, cx); + }); + } +} + +impl Opening for H {} diff --git a/crates/ely-office/src/panel.rs b/crates/ely-office/src/panel.rs new file mode 100644 index 0000000..78a2450 --- /dev/null +++ b/crates/ely-office/src/panel.rs @@ -0,0 +1,285 @@ +//! Kernel-bound controls the property panels share. + +use ely_gpui_component::{ + buttons::{Button, ToggleButton, ToggleItem}, + forms::{Choice, ColorPalette, ColorSwatch, Combobox, Select, TextInput}, + overlays::Popover, + primitives::{IconName, Tooltip}, + theme::{ActiveTheme as _, ControlSize}, +}; +use ely_lok::show::Paint; +use gpui::{ + AnyElement, App, Context, Entity, Hsla, InteractiveElement as _, IntoElement, + ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, + rgb, +}; + +/// Sizes the size menu offers, in points. +const SIZES: [&str; 17] = [ + "8", "9", "10", "10.5", "11", "12", "14", "16", "18", "20", "22", "24", "26", "28", "36", "48", + "72", +]; + +/// Palette colors, 0xRRGGBB. +pub const COLORS: [u32; 10] = [ + 0x000000, 0x808080, 0xFF0000, 0xFF8000, 0xFFFF00, 0x00A933, 0x00FFFF, 0x2A6099, 0x800080, + 0xFFFFFF, +]; + +/// A panel that edits one document through table commands. +pub trait Panel: Sized + 'static { + /// The kernel's state for `uno`. + fn state(&self, uno: &str, cx: &App) -> Option; + /// Runs table command `id` on the document. + fn run(&mut self, id: &str, values: &[&str], cx: &mut Context); +} + +fn text(cx: &App, key: &str) -> SharedString { + ely_ui::i18n::text(cx, key, &[]) +} + +fn uno_of(command: &str) -> &'static str { + ely_lok::commands::find(command) + .expect("a table command") + .uno +} + +/// Runs `command` with `values` through `panel`. +pub fn send(panel: &Entity

, command: &'static str, values: &[&str], cx: &mut App) { + panel.update(cx, |panel, cx| panel.run(command, values, cx)); +} + +/// A kernel-lit toggle; `why` explains it when disabled. +pub fn toggle( + panel: &P, + command: &'static str, + (icon, label): (Option, &str), + (tip, why): (&str, &str), + cx: &Context

, +) -> AnyElement { + let this = cx.entity(); + let state = panel.state(uno_of(command), cx); + let mut item = ToggleItem::new(command); + if let Some(icon) = icon { + item = item.icon(icon); + } + let shown = match state.as_deref() == Some(ely_lok::MIXED) { + true => format!("{label} {}", text(cx, "office.props.mixed")), + false => label.to_string(), + }; + if !shown.trim().is_empty() { + item = item.label(shown.trim().to_string()); + } + let disabled = state.as_deref() == Some("disabled"); + if disabled { + item = item.tooltip(text(cx, why)); + } else if !tip.is_empty() { + item = item.tooltip(text(cx, tip)); + } + ToggleButton::new( + SharedString::from(command), + item, + state.as_deref() == Some("true"), + ) + .disabled(disabled) + .size(ControlSize::Sm) + .on_toggle(move |_, _, cx| send(&this, command, &[], cx)) + .into_any_element() +} + +/// A button for `command`; `why` explains it when disabled. +pub fn button( + panel: &P, + command: &'static str, + key: &str, + why: &str, + cx: &Context

, +) -> AnyElement { + let this = cx.entity(); + let off = panel.state(uno_of(command), cx).as_deref() == Some("disabled"); + let button = Button::new(SharedString::from(command), text(cx, key)) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| send(&this, command, &[], cx)); + guarded(button, command, off.then(|| text(cx, why))) +} + +/// A clear button: label key, command, value. +pub type Clear = (&'static str, &'static str, &'static str); + +/// A color swatch; `empty` names no color. +pub fn palette( + panel: &Entity

, + command: &'static str, + (current, empty): (Paint, &str), + clear: Option, + cx: &App, +) -> AnyElement { + let (shown, tip) = match current { + Paint::Color(color) => (Hsla::from(rgb(color)), format!("#{color:06X}").into()), + Paint::Mixed => (gpui::transparent_black(), text(cx, "office.props.mixed")), + Paint::None => (gpui::transparent_black(), text(cx, empty)), + }; + let label = (current == Paint::Mixed).then(|| muted(tip.clone(), cx)); + let clear = clear.map(|(key, run, value)| (text(cx, key), run, value)); + let this = panel.clone(); + let popover = Popover::with_opener( + SharedString::from(format!("{command}-popover")), + move |toggle| { + ColorSwatch::new(SharedString::from(format!("{command}-swatch")), shown) + .tooltip(tip) + .on_click(move |window, cx| toggle(window, cx)) + }, + move |close, _, _| { + let swatches = COLORS.map(|color| (format!("#{color:06X}"), Hsla::from(rgb(color)))); + let picked = this.clone(); + let mut palette = ColorPalette::new(SharedString::from(command), swatches.clone()) + .columns(5) + .on_change({ + let close = close.clone(); + move |color, window, cx| { + let at = swatches + .iter() + .position(|(_, swatch)| *swatch == color) + .expect("the palette hands back its own colors"); + close(window, cx); + send(&picked, command, &[&COLORS[at].to_string()], cx) + } + }); + if let Paint::Color(color) = current + && COLORS.contains(&color) + { + palette = palette.selected(rgb(color)); + } + let this = this.clone(); + let clear = clear.clone().map(|(label, run, value)| { + Button::new(SharedString::from(format!("{command}-clear")), label) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + close(window, cx); + send(&this, run, &[value], cx) + }) + }); + div() + .flex() + .flex_col() + .gap_2() + .p_2() + .child(palette) + .children(clear) + }, + ); + row([popover.into_any_element()].into_iter().chain(label)) +} + +/// The kernel's state for `uno` as a color. +pub fn color_state(panel: &P, uno: &str, cx: &App) -> Paint { + let state = panel.state(uno, cx); + if state.as_deref() == Some(ely_lok::MIXED) { + return Paint::Mixed; + } + state + .and_then(|color| color.parse::().ok()) + .map_or(Paint::None, Paint::Color) +} + +/// The font box; `id` keys its text field. +pub fn font_box( + panel: &P, + id: &'static str, + fonts: &[SharedString], + window: &mut Window, + cx: &mut Context

, +) -> AnyElement { + let this = cx.entity(); + let input = window.use_keyed_state( + SharedString::from(format!("{id}-input")), + cx, + TextInput::new, + ); + let choices = fonts + .iter() + .map(|font| Choice::new(font.clone(), font.clone())); + let mut font = Combobox::new(id, &input, choices) + .size(ControlSize::Sm) + .on_change(move |value, _, cx| send(&this, "format.font", &[value.as_ref()], cx)); + let name = panel.state(".uno:CharFontName", cx); + let hint = match name.as_deref() == Some(ely_lok::MIXED) { + true => text(cx, "office.props.mixed"), + false => SharedString::default(), + }; + if input.read(cx).placeholder_text() != &hint { + input.update(cx, |input, cx| input.set_placeholder(hint, cx)); + } + if let Some(name) = name.filter(|name| known(name)) { + font = font.selected(name); + } + font.into_any_element() +} + +/// The size menu, in points. +pub fn size_menu(panel: &P, id: &'static str, cx: &Context

) -> AnyElement { + let this = cx.entity(); + let reported = panel.state(".uno:FontHeight", cx); + let size = reported.clone().filter(|size| known(size)); + let mut sizes: Vec = SIZES.iter().map(|size| (*size).into()).collect(); + if let Some(size) = size.clone().filter(|size| !sizes.contains(size)) { + sizes.push(size); + } + sizes.sort_by(|a, b| points(a).total_cmp(&points(b))); + let mut menu = Select::new( + id, + sizes + .into_iter() + .map(|size| Choice::new(size.clone(), size)), + ) + .size(ControlSize::Sm) + .on_change(move |value, _, cx| send(&this, "format.size", &[value.as_ref()], cx)); + if reported.as_deref() == Some(ely_lok::MIXED) { + menu = menu.placeholder(text(cx, "office.props.mixed")); + } + if let Some(size) = size { + menu = menu.selected(size); + } + menu.into_any_element() +} + +/// Controls side by side, wrapping when narrow. +pub fn row(items: impl IntoIterator) -> AnyElement { + div() + .flex() + .flex_wrap() + .items_center() + .gap_1() + .children(items) + .into_any_element() +} + +/// `button`, or it disabled with `why` on hover. +pub fn guarded(button: Button, id: &str, why: Option) -> AnyElement { + match why { + None => button.into_any_element(), + Some(why) => div() + .id(SharedString::from(format!("{id}-why"))) + .child(button.disabled(true)) + .tooltip(Tooltip::text(why)) + .into_any_element(), + } +} + +/// Quiet text in place of a control. +pub fn muted(text: SharedString, cx: &App) -> AnyElement { + div() + .text_color(cx.theme().colors.fg_muted) + .child(text) + .into_any_element() +} + +/// A real kernel value: neither empty nor mixed. +pub fn known(state: &str) -> bool { + !state.is_empty() && state != ely_lok::MIXED +} + +/// A size label's points, for ordering. +fn points(size: &str) -> f64 { + size.parse().expect("sizes are numbers") +} diff --git a/crates/ely-office/src/recover.rs b/crates/ely-office/src/recover.rs new file mode 100644 index 0000000..7dd4e09 --- /dev/null +++ b/crates/ely-office/src/recover.rs @@ -0,0 +1,430 @@ +//! Recovery points of open documents, back after crashes (§6.3). + +use std::{ + collections::HashMap, + io, + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; + +use ely_files::{Crash, Marker, Recovery, RecoveryKey}; +use ely_gpui_component::primitives::Severity; +use gpui::{AsyncApp, Context, WeakEntity}; +use serde::{Deserialize, Serialize}; + +use crate::{Engine, Host, Opening as _, Source, Tabs as _, text}; + +/// A tab's points, and the edit the last holds. +struct Slot { + kept: Kept, + written: Option, + writing: bool, +} + +/// What reopening a slot needs, kept beside its points. +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Kept { + slot: String, + name: String, + format: String, + sealed: bool, + /// Not reopened yet; a clean end keeps it. + #[serde(default)] + held: bool, +} + +/// The app's recovery: marker, slots, what a crash left. +pub struct Keeping { + dir: PathBuf, + crash: Option, + slots: HashMap, + crashed: Vec, + /// Copies opening from points: path, point time, old slot. + reopening: Vec<(PathBuf, SystemTime, Kept)>, + /// Slots that could not reopen; kept for a later start. + held: Vec, + /// When the last point was written. + pub(crate) last: Option, + /// The index on disk is behind; the failure shown. + behind: Option, +} + +impl Keeping { + /// Takes the marker; a crash leaves its slots to reopen. + pub fn start(app: ely_platform::AppId) -> io::Result { + Self::start_in(ely_platform::data_dir(app).map_err(io::Error::other)?) + } + + fn start_in(dir: PathBuf) -> io::Result { + let marker = Crash::check(&dir, ely_files::alive)?; + log::info!("recovery: the last run ended {marker:?}"); + let listed = read_index(&dir)?; + let crashed = match marker { + Marker::Crashed => listed, + // A clean end keeps only what never reopened. + Marker::Clean | Marker::Discarded => { + let held: Vec = listed.into_iter().filter(|kept| kept.held).collect(); + prune(&dir.join("recovery"), &held)?; + held + } + }; + let crash = Crash::open(&dir, std::process::id())?; + let (reopening, held) = (Vec::new(), Vec::new()); + Ok(Self { dir, crash: Some(crash), slots: HashMap::new(), crashed, reopening, held, last: None, behind: None }) + } + + /// A clean quit: the next start finds nothing to recover. + pub fn end(&mut self) { + if let Some(crash) = self.crash.take() + && let Err(error) = crash.close() + { + log::error!("recovery: the clean-quit mark failed: {error}"); + } + } + + fn folder(&self, slot: &str) -> PathBuf { + self.dir.join("recovery").join(slot) + } + + fn save_index(&self) -> io::Result<()> { + let waiting = self.reopening.iter().map(|(_, _, kept)| kept).chain(&self.held); + let waiting = waiting.map(|kept| Kept { held: true, ..kept.clone() }); + let slots = self.slots.values().map(|slot| slot.kept.clone()); + let kept: Vec = slots.chain(self.crashed.iter().cloned()).chain(waiting).collect(); + let path = self.dir.join("recovery").join("index.json"); + std::fs::create_dir_all(self.dir.join("recovery"))?; + // Staged then renamed: a failed write keeps the last index. + ely_files::save_atomic(&path, &serde_json::to_vec(&kept).map_err(io::Error::other)?, 0).map(|_| ()) + } +} + +fn read_index(dir: &Path) -> io::Result> { + match std::fs::read(dir.join("recovery").join("index.json")) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(error), + } +} + +/// Removes every slot folder but those `held`. +fn prune(recovery: &Path, held: &[Kept]) -> io::Result<()> { + if held.is_empty() { + return remove(recovery); + } + for entry in std::fs::read_dir(recovery)? { + let entry = entry?; + let name = entry.file_name(); + if entry.file_type()?.is_dir() && !held.iter().any(|kept| *kept.slot == *name) { + remove(&entry.path())?; + } + } + Ok(()) +} + +fn remove(path: &Path) -> io::Result<()> { + match std::fs::remove_dir_all(path) { + Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + } +} + +/// When recovery points are written and crashes come back. +pub trait Recovering: Host { + /// From the app's window, never its tests. + fn start_recovery(&mut self, cx: &mut Context) { + match Keeping::start(Self::KIND.app) { + Ok(keeping) => self.core_mut().keeping = Some(keeping), + Err(error) => { + log::error!("recovery: off for this run: {error}"); + self.core_mut().recovery_off = Some(error.to_string()); + } + } + Self::keep_points_every(cx); + cx.on_app_quit(|host, _| { + if let Some(keeping) = host.core_mut().keeping.as_mut() { + keeping.end(); + } + async {} + }) + .detach(); + } + + /// Ticks every ten seconds for the window's life. + fn keep_points_every(cx: &mut Context) { + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + loop { + cx.background_executor().timer(Duration::from_secs(2)).await; + if this.update(cx, |host, cx| host.keep_points(cx)).is_err() { + break; + } + cx.background_executor().timer(Recovery::INTERVAL - Duration::from_secs(2)).await; + } + }) + .detach(); + } + + /// Reopens what a crash left, then writes due points. + fn keep_points(&mut self, cx: &mut Context) { + // An idle engine reports no error when it ends. + crate::Stopping::check_engine(self, cx); + if let Some(error) = self.core_mut().recovery_off.take() { + self.toast_unlogged(text(cx, "office.recovery.off", &[("error", &error)]), Severity::Danger, cx); + } + if self.core().stopped.is_some() { + return; + } + if self.core().keeping.as_ref().is_some_and(|keeping| keeping.behind.is_some()) { + self.flush_index(cx); + } + if self.core().window.is_some() { + self.reopen_crashed(cx); + } + let app = Self::KIND.app; + let due: Vec<(u64, u64)> = self + .core() + .tabs + .iter() + .filter(|tab| tab.dirty(cx) && !tab.exporting && Self::Engine::settled(tab.view.read(cx))) + .map(|tab| (tab.id, Self::Engine::edited(tab.view.read(cx)))) + .filter(|(id, edited)| { + let slot = self.core().keeping.as_ref().and_then(|keeping| keeping.slots.get(id)); + slot.is_some_and(|slot| !slot.writing && slot.written != Some(*edited)) + }) + .collect(); + for (id, edited) in due { + let Some(keeping) = self.core_mut().keeping.as_mut() else { return }; + let slot = keeping.slots.get_mut(&id).expect("a due tab has a slot"); + slot.writing = true; + let kept = slot.kept.clone(); + let folder = keeping.folder(&kept.slot); + let work = self.tab(id).expect("a due tab").work.join(format!("recovery.{}", kept.format)); + self.read_in( + id, + move |worker, doc| { + Self::Engine::snapshot(worker, doc, &work, &kept.format)?; + let bytes = std::fs::read(&work)?; + std::fs::remove_file(&work)?; + let limits = ely_platform::storage::limits(app)?; + let recovery = match kept.sealed { + true => Recovery::sensitive(folder, RecoveryKey::load().key), + false => Recovery::new(folder), + }; + let mut recovery = recovery.with_limits(limits.keep, limits.megabytes * 1024 * 1024); + recovery.write_once(&bytes, None, SystemTime::now()).map(|_| ()) + }, + move |host, written, _, cx| { + let Some(keeping) = host.core_mut().keeping.as_mut() else { return }; + let Some(slot) = keeping.slots.get_mut(&id) else { return }; + slot.writing = false; + match written { + Ok(()) => { + slot.written = Some(edited); + keeping.last = Some(SystemTime::now()); + if keeping.behind.is_some() { + host.flush_index(cx); + } + } + Err(error) => { + log::error!("recovery: tab {id} point failed ({:?})", error.kind()); + let shown = text(cx, "office.recovery.failed", &[("error", &error.to_string())]); + host.toast_unlogged(shown, Severity::Warning, cx); + } + } + }, + cx, + ); + } + } + + /// Writes the index; a failure shows once and retries. + fn flush_index(&mut self, cx: &mut Context) { + let Some(keeping) = self.core_mut().keeping.as_mut() else { return }; + let error = match keeping.save_index() { + Ok(()) => return keeping.behind = None, + Err(error) => error.to_string(), + }; + if keeping.behind.as_ref() == Some(&error) { + return; + } + log::error!("recovery: the index is not saved: {error}"); + keeping.behind = Some(error.clone()); + self.toast_unlogged(text(cx, "office.recovery.index", &[("error", &error)]), Severity::Danger, cx); + } + + /// Each tab gets its own folder of points once open. + fn keep_tab(&mut self, id: u64, name: &str, sealed: bool, cx: &mut Context) { + let format = name + .rsplit_once('.') + .map(|(_, ext)| ext.to_ascii_lowercase()) + .filter(|ext| Self::KIND.save_as.contains(&ext.as_str()) || Self::KIND.kept.contains(&ext.as_str())) + .unwrap_or_else(|| Self::KIND.save_as[0].to_string()); + let Some(keeping) = self.core_mut().keeping.as_mut() else { return }; + let slot = format!("{}-{id}", std::process::id()); + let kept = Kept { slot, name: name.to_string(), format, sealed, held: false }; + keeping.slots.insert(id, Slot { kept, written: None, writing: false }); + self.flush_index(cx); + } + + /// A closed tab's points go: closing decided its fate. + fn forget_tab(&mut self, id: u64, cx: &mut Context) { + let Some(keeping) = self.core_mut().keeping.as_mut() else { return }; + let Some(slot) = keeping.slots.remove(&id) else { return }; + if let Err(error) = remove(&keeping.folder(&slot.kept.slot)) { + log::error!("recovery: tab {id} points stay: {error}"); + } + self.flush_index(cx); + } + + /// Opens each crashed slot's newest point as unsaved copy. + fn reopen_crashed(&mut self, cx: &mut Context) { + let Some(keeping) = self.core_mut().keeping.as_mut() else { return }; + let crashed = std::mem::take(&mut keeping.crashed); + if crashed.is_empty() { + return; + } + for kept in crashed { + let folder = self.core().keeping.as_ref().expect("keeping").folder(&kept.slot); + match restore(&kept, &folder, &self.core().work, self.core().next, false) { + Ok(Some((copy, at))) => { + log::info!("recovery: reopening a crashed {}", kept.format); + self.core_mut().keeping.as_mut().expect("keeping").reopening.push((copy.clone(), at, kept)); + self.load(Source::Copy(copy), cx); + } + Ok(None) => { + if let Err(error) = remove(&folder) { + log::error!("recovery: an empty slot stays: {error}"); + } + } + Err(error) => { + log::error!("recovery: a crashed copy cannot open ({:?})", error.kind()); + let shown = text(cx, "office.recovery.lost", &[("name", &kept.name), ("error", &error.to_string())]); + self.toast_unlogged(shown, Severity::Danger, cx); + // Its points stay for a later start. + self.core_mut().keeping.as_mut().expect("keeping").held.push(kept); + } + } + } + self.flush_index(cx); + } + + /// Opens `id`'s newest point as a copy; false if none. + fn reopen_point(&mut self, id: u64, cx: &mut Context) -> io::Result { + let (work, next) = (self.core().work.clone(), self.core().next); + let Some(keeping) = self.core_mut().keeping.as_mut() else { return Ok(false) }; + // Out of the tab's hands: releasing it keeps these points. + let Some(slot) = keeping.slots.remove(&id) else { return Ok(false) }; + let restored = restore(&slot.kept, &keeping.folder(&slot.kept.slot), &work, next, true); + match restored { + Ok(Some((copy, at))) => { + keeping.reopening.push((copy.clone(), at, slot.kept)); + self.load(Source::Copy(copy), cx); + Ok(true) + } + Ok(None) => { + keeping.slots.insert(id, slot); + Ok(false) + } + Err(error) => { + keeping.held.push(slot.kept); + Err(error) + } + } + } + + /// A reopened copy takes over its old points, unsaved. + fn reopened(&mut self, id: u64, path: &Path, cx: &mut Context) { + let Some(keeping) = self.core_mut().keeping.as_mut() else { return }; + let Some(n) = keeping.reopening.iter().position(|(copy, ..)| copy == path) else { + return; + }; + let (_, at, kept) = keeping.reopening.remove(n); + let name = kept.name.clone(); + if let Some(slot) = keeping.slots.get_mut(&id) { + let sealed = slot.kept.sealed || kept.sealed; + slot.kept = Kept { sealed, held: false, ..kept }; + } + // The point it took over is the latest kept. + keeping.last = Some(keeping.last.map_or(at, |last| last.max(at))); + self.flush_index(cx); + let Some(tab) = self.tab_mut(id) else { return }; + tab.session.edit(); + let shown = text(cx, "office.recovery.back", &[("name", &name), ("at", &clock(at))]); + self.toast_unlogged(shown, Severity::Warning, cx); + } +} + +impl Recovering for H {} + +/// The newest point copied to the work folder, and when. +fn restore(kept: &Kept, folder: &Path, work: &Path, next: u64, this_run: bool) -> io::Result> { + let recovery = match kept.sealed { + true => { + let key = RecoveryKey::load(); + // This run's points open with this run's key. + if !key.lasting && !this_run { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "the recovery key is not in the key store")); + } + Recovery::sensitive(folder.to_path_buf(), key.key) + } + false => Recovery::new(folder.to_path_buf()), + }; + let Some(point) = recovery.points()?.into_iter().next() else { + return Ok(None); + }; + let dir = crate::files::work_dir(work, next)?; + let copy = dir.join(&kept.name).with_extension(&kept.format); + recovery.restore(&point, ©)?; + Ok(Some((copy, point.at))) +} + +/// A recovery time as this machine's clock reads it. +pub(crate) fn clock(at: SystemTime) -> String { + match jiff::Timestamp::try_from(at) { + Ok(at) => at.to_zoned(jiff::tz::TimeZone::system()).strftime("%Y-%m-%d %H:%M").to_string(), + Err(error) => error.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::{Keeping, Kept}; + + fn kept(slot: &str, held: bool) -> Kept { + Kept { slot: slot.into(), name: "a.docx".into(), format: "docx".into(), sealed: false, held } + } + + #[cfg(unix)] + #[test] + fn a_failed_index_write_keeps_the_last_one() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().expect("a temp dir"); + let mut keeping = Keeping::start_in(dir.path().to_path_buf()).expect("starts"); + keeping.crashed.push(kept("1-0", false)); + keeping.save_index().expect("the first index"); + let recovery = dir.path().join("recovery"); + std::fs::set_permissions(&recovery, std::fs::Permissions::from_mode(0o555)).expect("locks"); + keeping.crashed.push(kept("1-1", false)); + let failed = keeping.save_index(); + std::fs::set_permissions(&recovery, std::fs::Permissions::from_mode(0o755)).expect("unlocks"); + assert!(failed.is_err(), "the folder refused the write"); + let read = super::read_index(dir.path()).expect("the old index still reads"); + assert_eq!(read.len(), 1); + } + + #[test] + fn a_clean_end_keeps_only_what_never_reopened() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut keeping = Keeping::start_in(dir.path().to_path_buf()).expect("starts"); + for slot in ["1-0", "1-1"] { + std::fs::create_dir_all(keeping.folder(slot).join("points")).expect("a slot"); + } + keeping.crashed.push(kept("1-0", false)); + keeping.held.push(kept("1-1", false)); + keeping.save_index().expect("the index"); + keeping.end(); + let again = Keeping::start_in(dir.path().to_path_buf()).expect("starts again"); + let slots: Vec<&str> = again.crashed.iter().map(|kept| kept.slot.as_str()).collect(); + assert_eq!(slots, ["1-1"], "only the held slot comes back"); + assert!(again.folder("1-1").exists() && !again.folder("1-0").exists()); + } +} diff --git a/crates/ely-office/src/save.rs b/crates/ely-office/src/save.rs new file mode 100644 index 0000000..80b694e --- /dev/null +++ b/crates/ely-office/src/save.rs @@ -0,0 +1,465 @@ +//! Save, save as, and conflict outcomes (FILE-04, FILE-05). + +use std::{ + io, + path::{Path, PathBuf}, +}; + +use ely_files::{Open, SaveError, Saved}; +use ely_gpui_component::primitives::Severity; +use gpui::{Context, TaskExt as _, Window}; + +use crate::files::{self, file_name}; +use crate::{After, Closing, Dialog, Engine, Host, Opening, Tab, Tabs, in_window, text}; + +/// The file a path names, links resolved. +fn identity(path: &Path) -> io::Result { + match std::fs::canonicalize(path) { + Ok(real) => Ok(real), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let parent = path.parent().unwrap_or(Path::new(".")); + let name = path + .file_name() + .ok_or_else(|| io::Error::other("no file name"))?; + Ok(std::fs::canonicalize(parent)?.join(name)) + } + Err(error) => Err(error), + } +} + +pub trait Saving: Host { + /// The tab whose source file is `target`. + fn holder(&self, target: &Path) -> io::Result> { + for tab in &self.core().tabs { + if let Some(open) = tab.session.path() + && ely_files::same_file(open, target)? + { + return Ok(Some(tab.id)); + } + } + Ok(None) + } + + /// Saves in place in its own format. + fn save(&mut self, id: u64, then: Option, window: &mut Window, cx: &mut Context) { + if !self.settled(id, cx) { + return self.when_settled(id, move |host, window, cx| host.save(id, then, window, cx)); + } + if self.exporting(id) { + return self.after_export(id, move |host, window, cx| host.save(id, then, window, cx)); + } + let Some(tab) = self.tab(id) else { + return log::info!("office: tab {id} closed before its save"); + }; + let Some(path) = tab + .session + .path() + .filter(|_| tab.risk.is_none()) + .map(PathBuf::from) + else { + return self.save_as(id, then, cx); + }; + let format = match files::kept_format(&Self::KIND, &path) { + Ok(format) => format, + Err(error) => { + return self.finish(id, Err(SaveError::Io(error)), None, then, window, cx); + } + }; + self.exported(id, format, then, true, cx, move |host, bytes, _, _| { + ely_files::save(&mut host.tab_mut(id).expect("open").session, &bytes) + }); + } + + /// Whether tab `id` has an export running. + fn exporting(&self, id: u64) -> bool { + self.tab(id).is_some_and(|tab| tab.exporting) + } + + /// Runs `then` once tab `id`'s running export ends. + fn after_export( + &mut self, + id: u64, + then: impl FnOnce(&mut Self, &mut Window, &mut Context) + 'static, + ) { + log::info!("office: a save of tab {id} waits for the running one"); + self.core_mut().queued_saves.push((id, Box::new(then))); + } + + /// Exports `format` off the UI thread; `write` stores the bytes. + fn exported( + &mut self, + id: u64, + format: &'static str, + then: Option, + overwrite: bool, + cx: &mut Context, + write: impl FnOnce( + &mut Self, + Vec, + &mut Window, + &mut Context, + ) -> Result + + 'static, + ) { + let Some(tab) = self.tab_mut(id) else { + return; + }; + tab.exporting = true; + let (work, view) = (tab.work.clone(), tab.view.clone()); + let edited = Self::Engine::edited(view.read(cx)); + cx.notify(); + self.read_in( + id, + move |worker, doc| { + if overwrite && let Some(risk) = Self::Engine::unsound(worker, doc)? { + return Ok(Err(risk)); + } + let bytes = files::kernel_bytes::(worker, doc, &work, format)?; + Ok(Ok((bytes, Self::Engine::history(worker, doc)?))) + }, + move |host, read, window, cx| { + let (queued, rest): (Vec<_>, Vec<_>) = host + .core_mut() + .queued_saves + .drain(..) + .partition(|(tab, _)| *tab == id); + host.core_mut().queued_saves = rest; + let Some(tab) = host.tab_mut(id) else { + return log::info!("office: tab {id} closed during its save"); + }; + tab.exporting = false; + let (saved, history) = match read { + Ok(Ok((bytes, history))) => (write(host, bytes, window, cx), Some(history)), + Ok(Err(risk)) => { + // Found at the last moment: write elsewhere instead. + log::warn!("office: tab {id} is {risk:?}; its save becomes Save As"); + tab.risk = Some(risk); + cx.notify(); + host.save_as(id, then, cx); + for (_, save) in queued { + save(host, window, cx); + } + return; + } + Err(error) => (Err(SaveError::Io(error)), None), + }; + // Edits after the export stay unsaved; a close asks again. + let changed = Self::Engine::edited(view.read(cx)) != edited + || Self::Engine::edit_pending(view.read(cx)); + match then { + Some(after) if changed && saved.is_ok() => { + host.finish(id, saved, history, None, window, cx); + host.request_close(id, after, window, cx); + } + then => host.finish(id, saved, history, then, window, cx), + } + for (_, save) in queued { + save(host, window, cx); + } + }, + cx, + ); + } + + /// Asks where, then writes the default format there. + fn save_as(&mut self, id: u64, then: Option, cx: &mut Context) { + let Some(tab) = self.tab(id) else { + return; + }; + let Some(dir) = folder(tab) else { + return self.panel_failed("no documents folder", cx); + }; + let stem = tab + .name + .rsplit_once('.') + .map_or(tab.name.as_ref(), |(stem, _)| stem); + log::info!("office: asks where to save tab {id}"); + let chosen = + cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", Self::KIND.save_as[0]))); + cx.spawn(async move |this, cx| { + let chosen = match chosen.await { + Ok(Ok(path)) => Ok(path), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + in_window(&this, cx, |host, window, cx| { + match chosen { + Ok(Some(path)) => return host.write_as(id, path, then, window, cx), + Ok(None) => log::info!("office: the save panel was cancelled"), + Err(error) => host.panel_failed(&error, cx), + } + // No save: a close that waited on it stops. + if then.is_some() { + host.close_cancelled(cx); + } + }) + }) + .detach_and_log_err(cx); + } + + /// Writes `id` to `path`; conversions need new files. + fn write_as( + &mut self, + id: u64, + path: PathBuf, + then: Option, + _window: &mut Window, + cx: &mut Context, + ) { + if !self.settled(id, cx) { + return self.when_settled(id, move |host, window, cx| { + host.write_as(id, path, then, window, cx) + }); + } + if self.exporting(id) { + return self.after_export(id, move |host, window, cx| { + host.write_as(id, path, then, window, cx) + }); + } + let target = match identity(&path) { + Ok(target) => target, + Err(error) => return self.report(id, Err(SaveError::Io(error)), cx), + }; + let Some(format) = files::save_as_format(&Self::KIND, &target) else { + let formats = Self::KIND.save_as.join(", ."); + let text = text(cx, "office.error.format", &[("formats", &formats)]); + return self.toast(text, Severity::Danger, cx); + }; + let Some(tab) = self.tab(id) else { + return log::info!("office: tab {id} closed before its save"); + }; + let holder = match self.holder(&target) { + Ok(holder) => holder, + Err(error) => return self.report(id, Err(SaveError::Io(error)), cx), + }; + if holder.is_some_and(|other| other != id) { + let text = text(cx, "office.error.taken", &[("name", &file_name(&target))]); + return self.toast(text, Severity::Danger, cx); + } + let own_format = tab + .session + .path() + .and_then(|open| files::kept_format(&Self::KIND, open).ok()); + if holder == Some(id) && (tab.risk.is_some() || own_format != Some(format)) { + let text = text(cx, "office.error.convert", &[]); + return self.toast(text, Severity::Danger, cx); + } + // The written file differs from the open one; reopen it. + let reopen = + matches!(tab.risk, Some(crate::Risk::Signed | crate::Risk::Repaired)) && then.is_none(); + self.exported(id, format, then, false, cx, move |host, bytes, _, cx| { + // Another tab may have taken the target meanwhile. + match host.holder(&target) { + Ok(Some(other)) if other != id => { + let message = format!("{} is open in another tab", target.display()); + return Err(SaveError::Io(io::Error::other(message))); + } + Err(error) => return Err(SaveError::Io(error)), + _ => {} + } + let session = &mut host.tab_mut(id).expect("open").session; + let saved = ely_files::save_as(session, &target, &bytes); + if let Ok(done) = &saved { + host.moved(id, done, cx); + host.tab_mut(id).expect("open").reopen = reopen; + } + saved + }); + } + + /// The tab now names the saved file: registry, title, risk. + fn moved(&mut self, id: u64, done: &Saved, cx: &mut Context) { + let tab = self.tab(id).expect("the tab is open"); + if let Some(old) = tab.registry { + self.core_mut().sessions.close(old); + } + let registry = match self.core_mut().sessions.open(&done.path, false) { + Ok(Open::Fresh(registry) | Open::View(registry) | Open::Focus(registry)) => { + Some(registry) + } + Err(error) => { + self.report(id, Err(SaveError::Io(error)), cx); + None + } + }; + // Ely's writes carry no signatures; macros may stay. + let risk = match files::macro_risk(&Self::KIND, &done.path) { + Ok(macros) => macros.then_some(crate::Risk::Macros), + Err(error) => { + self.report(id, Err(SaveError::Io(error)), cx); + Some(crate::Risk::Macros) + } + }; + let tab = self.tab_mut(id).expect("the tab is open"); + tab.registry = registry; + tab.name = file_name(&done.path); + tab.risk = risk; + } + + /// Keep Mine over the disk now; later changes conflict. + fn keep_mine( + &mut self, + id: u64, + then: Option, + window: &mut Window, + cx: &mut Context, + ) { + if !self.settled(id, cx) { + return self.when_settled(id, move |host, window, cx| { + host.keep_mine(id, then, window, cx) + }); + } + if self.exporting(id) { + return self.after_export(id, move |host, window, cx| { + host.keep_mine(id, then, window, cx) + }); + } + let Some(tab) = self.tab(id) else { + return log::info!("office: tab {id} closed before its save"); + }; + let format = tab + .session + .path() + .ok_or_else(|| io::Error::other("no path")) + .and_then(|path| files::kept_format(&Self::KIND, path)); + let format = match format { + Ok(format) => format, + Err(error) => { + return self.finish(id, Err(SaveError::Io(error)), None, then, window, cx); + } + }; + let seen = tab.session.path().map(ely_files::Fingerprint::of); + match seen { + Some(Ok(seen)) => { + self.tab_mut(id).expect("open").session.rebase(seen); + self.exported(id, format, then, true, cx, move |host, bytes, _, _| { + ely_files::save(&mut host.tab_mut(id).expect("open").session, &bytes) + }); + } + // Gone from disk: nothing there to lose. + _ => self.exported(id, format, then, true, cx, move |host, bytes, _, _| { + ely_files::save_overwrite(&mut host.tab_mut(id).expect("open").session, &bytes) + }), + } + } + + /// Reopens the tab from its file; the disk version wins. + fn reload(&mut self, id: u64, cx: &mut Context) { + let Some(path) = self + .tab(id) + .and_then(|tab| tab.session.path().map(PathBuf::from)) + else { + return; + }; + self.close_tab(id, cx); + self.open_file(path, cx); + } + + /// Reopens a flagged tab once nothing in memory is unsaved. + fn reopen_if_saved(&mut self, id: u64, cx: &mut Context) { + cx.spawn(async move |this, cx| { + in_window(&this, cx, |host, _, cx| { + let Some(tab) = host.tab(id).filter(|tab| tab.reopen) else { + return; + }; + match !tab.dirty(cx) && !tab.exporting { + true => { + log::info!("office: tab {id} reopens the file it wrote"); + host.reload(id, cx); + } + false => log::info!( + "office: tab {id} has edits since; it reopens after its next save" + ), + } + }) + }) + .detach_and_log_err(cx); + } + + /// `history` is the kernel's undo history read with the export. + fn finish( + &mut self, + id: u64, + saved: Result, + history: Option, + then: Option, + window: &mut Window, + cx: &mut Context, + ) { + match saved { + Ok(done) => { + log::info!("office: saved {}", done.path.display()); + // A save may replace the file; the registry follows it. + let registry = self.tab(id).and_then(|tab| tab.registry); + if let Some(registry) = registry + && let Err(error) = self.core_mut().sessions.track(registry, &done.path) + { + log::error!("office: the saved file did not register: {error}"); + self.engine_failed(&error.to_string(), cx); + } + if let (Some(history), Some(tab)) = (history, self.tab_mut(id)) { + Self::Engine::saved(&tab.view, &history, cx); + tab.mark = history; + } + self.reopen_if_saved(id, cx); + cx.notify(); + if let Some(after) = then { + self.close_tab(id, cx); + self.proceed(after, window, cx); + } + } + Err(SaveError::Conflict) => { + // Saves no close waits on may go to the app. + if then.is_none() && self.conflicted(id, cx) { + return; + } + self.core_mut().dialog = Some(Dialog::Conflict { tab: id, then }); + cx.notify(); + } + failed => { + self.report(id, failed, cx); + if then.is_some() { + self.close_cancelled(cx); + } + } + } + } + + /// A failed save as a toast; edits stay. + fn report(&mut self, id: u64, saved: Result, cx: &mut Context) { + let name = self.tab(id).map(|tab| tab.name.clone()).unwrap_or_default(); + let error = match saved { + Ok(_) => return, + Err(SaveError::Refused(state)) => state.to_string(), + Err(SaveError::Io(error)) => error.to_string(), + Err(SaveError::Conflict) => { + let text = text(cx, "office.error.conflict", &[("name", &name)]); + return self.toast(text, Severity::Danger, cx); + } + }; + if let Some(tab) = self.tab_mut(id) { + tab.session.save_failed(); + } + let text = text( + cx, + "office.error.save", + &[("name", &name), ("error", &error)], + ); + self.toast(text, Severity::Danger, cx); + cx.notify(); + } +} + +impl Saving for H {} + +/// Where a panel for `tab` opens: beside it, else Documents. +pub fn folder(tab: &Tab) -> Option { + tab.session + .path() + .and_then(|path| path.parent().map(PathBuf::from)) + .or_else(|| { + directories::UserDirs::new()? + .document_dir() + .map(PathBuf::from) + }) +} diff --git a/crates/ely-office/src/stopped.rs b/crates/ely-office/src/stopped.rs new file mode 100644 index 0000000..d22e6ef --- /dev/null +++ b/crates/ely-office/src/stopped.rs @@ -0,0 +1,127 @@ +//! The engine ended: say so, then reopen (§6.3). + +use std::{path::PathBuf, sync::Arc}; + +use ely_gpui_component::{feedback::Banner, primitives::Severity}; +use gpui::{AppContext as _, Context}; + +use crate::{Closing as _, Dialog, Engine, Host, Opening as _, Recovering as _, Tabs as _, recover::clock, text}; + +/// Why the engine ended, and whether it is coming back. +pub struct Stopped { + why: String, + restarting: bool, +} + +pub trait Stopping: Host { + /// After an engine error: asks if it ended. + fn check_engine(&mut self, cx: &mut Context) { + if self.core().stopped.is_some() { + return; + } + let worker = Arc::clone(&self.core().worker); + cx.spawn(async move |this, cx| { + let ended = cx + .background_spawn(async move { Self::Engine::ended(&mut worker.lock().expect("the worker lock")) }) + .await; + let Some(why) = ended else { return }; + let noted = this.update(cx, |host, cx| { + if host.core().stopped.is_none() { + log::error!("{}: the engine ended ({why})", Self::KIND.app.slug()); + host.core_mut().stopped = Some(Stopped { why, restarting: false }); + cx.notify(); + } + }); + if noted.is_err() { + log::info!("office: the engine ended after its window"); + } + }) + .detach(); + } + + /// What ended, the last point, and the way back. + fn stopped_banner(&self, cx: &mut Context) -> Option { + let stopped = self.core().stopped.as_ref()?; + let at = match self.core().keeping.as_ref().and_then(|keeping| keeping.last) { + Some(at) => clock(at), + None => text(cx, "office.stopped.never", &[]).to_string(), + }; + let said = text(cx, "office.stopped", &[("why", &stopped.why), ("at", &at)]); + let banner = Banner::new("office-stopped", Severity::Danger, said); + if stopped.restarting { + return Some(banner); + } + let this = cx.entity(); + Some(banner.action(text(cx, "office.stopped.reopen", &[]), move |_, cx| { + this.update(cx, |host, cx| host.restart_engine(cx)) + })) + } + + /// A new worker, then each tab from point or file. + fn restart_engine(&mut self, cx: &mut Context) { + let Some(stopped) = self.core_mut().stopped.as_mut() else { return }; + stopped.restarting = true; + cx.notify(); + let worker = Arc::clone(&self.core().worker); + cx.spawn(async move |this, cx| { + let restarted = cx + .background_spawn(async move { + Self::Engine::restart(Self::KIND.app, &mut worker.lock().expect("the worker lock")) + }) + .await; + let reopened = this.update(cx, |host, cx| match restarted { + Ok(()) => host.reopen_all(cx), + Err(error) => { + log::error!("{}: the engine does not restart: {error}", Self::KIND.app.slug()); + host.core_mut().stopped = Some(Stopped { why: error.to_string(), restarting: false }); + cx.notify(); + } + }); + if reopened.is_err() { + log::info!("office: the engine restarted after its window"); + } + }) + .detach(); + } + + /// Unsaved tabs from their newest point, clean ones from file. + fn reopen_all(&mut self, cx: &mut Context) { + self.core_mut().stopped = None; + let tabs: Vec<(u64, String, Option, bool)> = self + .core() + .tabs + .iter() + .map(|tab| (tab.id, tab.name.to_string(), tab.session.path().map(PathBuf::from), tab.dirty(cx))) + .collect(); + let mut lost = Vec::new(); + for (id, name, path, dirty) in tabs { + if !dirty { + self.release_tab(id, cx); + if let Some(path) = path { + self.open_file(path, cx); + } + continue; + } + match self.reopen_point(id, cx) { + Ok(true) => {} + Ok(false) => lost.push(name), + Err(error) => { + log::error!("office: tab {id} point does not open ({:?})", error.kind()); + lost.push(format!("{name} ({error})")); + } + } + self.release_tab(id, cx); + } + // Questions about the released tabs have no answer now. + if matches!(self.core().dialog, Some(Dialog::Unsaved { .. } | Dialog::Conflict { .. })) { + self.core_mut().dialog = None; + } + log::info!("{}: the engine is back, {} tab(s) lost", Self::KIND.app.slug(), lost.len()); + if !lost.is_empty() { + let shown = text(cx, "office.stopped.lost", &[("names", &lost.join(", "))]); + self.toast_unlogged(shown, Severity::Danger, cx); + } + } +} + +impl Stopping for H {} diff --git a/crates/ely-office/src/zoom.rs b/crates/ely-office/src/zoom.rs new file mode 100644 index 0000000..61a99df --- /dev/null +++ b/crates/ely-office/src/zoom.rs @@ -0,0 +1,66 @@ +//! Zooming a document: keys, View menu items, the status control. + +use ely_gpui_component::shell::{ZoomControl, ZoomStep}; +use ely_ui::commands; +use gpui::{App, Context, Div, InteractiveElement as _, KeyBinding, MenuItem, actions}; + +use crate::{Host, Tabs as _, lok::Lok, text}; + +actions!(office, [ZoomIn, ZoomOut, ActualSize]); + +/// Command with =, - and 0, as Mac apps do. +pub fn bind_zoom_keys(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("secondary-=", ZoomIn, Some(commands::CONTEXT)), + KeyBinding::new("secondary--", ZoomOut, Some(commands::CONTEXT)), + KeyBinding::new("secondary-0", ActualSize, Some(commands::CONTEXT)), + ]); +} + +/// Zoom In, Zoom Out, and Actual Size (or Fit Slide). +pub fn zoom_items(fits: bool, cx: &App) -> [MenuItem; 3] { + let actual = if fits { "office.zoom.fit" } else { "office.zoom.actual" }; + [ + MenuItem::action(text(cx, "office.zoom.in", &[]), ZoomIn), + MenuItem::action(text(cx, "office.zoom.out", &[]), ZoomOut), + MenuItem::action(text(cx, actual, &[]), ActualSize), + ] +} + +/// The active document's zoom, by key, menu or status control. +pub trait Zooming: Host { + /// Actual size fits a whole page instead (ElyShow). + const FITS: bool = false; + + fn zoom(&mut self, step: ZoomStep, cx: &mut Context) { + let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else { + return; + }; + view.update(cx, |view, cx| match step { + ZoomStep::In => view.zoom_step(true, cx), + ZoomStep::Out => view.zoom_step(false, cx), + ZoomStep::Reset if Self::FITS => view.fit_page(cx), + ZoomStep::Reset => view.set_zoom(1.0, cx), + }); + cx.notify(); + } + + /// The window root, answering the zoom actions. + fn bind_zoom(root: Div, cx: &mut Context) -> Div { + root.on_action(cx.listener(|host: &mut Self, _: &ZoomIn, _, cx| host.zoom(ZoomStep::In, cx))) + .on_action(cx.listener(|host: &mut Self, _: &ZoomOut, _, cx| host.zoom(ZoomStep::Out, cx))) + .on_action(cx.listener(|host: &mut Self, _: &ActualSize, _, cx| host.zoom(ZoomStep::Reset, cx))) + } + + /// Minus, the percent, plus; none without a document. + fn zoom_control(&self, cx: &mut Context) -> Option { + let view = self.active_tab()?.view.clone(); + let percent = (view.read(cx).zoom() * 100.0).round() as u32; + let this = cx.entity().downgrade(); + Some(ZoomControl::new("office-zoom").owned(percent, move |step, _, cx| { + if let Err(error) = this.update(cx, |host, cx| host.zoom(step, cx)) { + log::warn!("office: zoom without a window: {error}"); + } + })) + } +} diff --git a/crates/ely-pdf-engine/Cargo.toml b/crates/ely-pdf-engine/Cargo.toml new file mode 100644 index 0000000..60f4329 --- /dev/null +++ b/crates/ely-pdf-engine/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "ely-pdf-engine" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +ely-ipc.workspace = true +log.workspace = true +env_logger.workspace = true +serde_json.workspace = true + +[features] +test-support = [] + +[build-dependencies] +cc = "1" + +[dev-dependencies] +ely-pdf-engine = { path = ".", features = ["test-support"] } +tempfile.workspace = true + +[lints] +workspace = true + +[target.'cfg(target_os = "macos")'.dev-dependencies] +objc2.workspace = true +objc2-foundation = { workspace = true, features = ["NSArray"] } +objc2-pdf-kit = { workspace = true, features = ["PDFPage", "PDFAnnotation", "PDFAnnotationUtilities"] } diff --git a/crates/ely-pdf-engine/build.rs b/crates/ely-pdf-engine/build.rs new file mode 100644 index 0000000..4305f90 --- /dev/null +++ b/crates/ely-pdf-engine/build.rs @@ -0,0 +1,110 @@ +//! Builds the MuPDF shim into the worker binary only. + +use std::path::PathBuf; + +fn main() { + let root = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("set by cargo")).join("../.."); + let mupdf = std::env::var_os("ELY_MUPDF_DIR").map_or_else(|| root.join("engines/mupdf-build"), PathBuf::from); + // One stamp for client and worker; stale workers refused. + let mut stamp: u64 = 0xcbf2_9ce4_8422_2325; + for source in [ + "shim/ely_mupdf.c", + "shim/ely_pages.c", + "shim/ely_insert.c", + "shim/ely_text.c", + "shim/ely_write.c", + "shim/ely_place.c", + "shim/ely_annot.c", + "shim/ely_form.c", + "shim/ely_ocr.c", + "shim/ely_outline.c", + "shim/ely_meta.c", + "shim/ely_redact.c", + "shim/ely_alt.c", + "shim/ely_clean.c", + "shim/ely_secure.c", + "shim/ely_sign.c", + "shim/ely_output.c", + "shim/ely_shim.h", + "src/proto.rs", + "src/bin/ely-pdf-worker/main.rs", + "src/bin/ely-pdf-worker/ffi.rs", + "src/bin/ely-pdf-worker/edit.rs", + "src/bin/ely-pdf-worker/content.rs", + "src/edit.rs", + "src/content.rs", + "src/marks.rs", + "src/bin/ely-pdf-worker/marks.rs", + "src/bin/ely-pdf-worker/ocr.rs", + "src/bin/ely-pdf-worker/structure.rs", + "src/bin/ely-pdf-worker/redact.rs", + "src/redact.rs", + "src/structure.rs", + "src/ocr.rs", + "src/bin/ely-pdf-worker/secure.rs", + "src/secure.rs", + "src/bin/ely-pdf-worker/sign.rs", + "src/sign.rs", + "src/bin/ely-pdf-worker/output.rs", + "src/output.rs", + "../ely-ipc/src/lib.rs", + ] { + println!("cargo:rerun-if-changed={source}"); + for byte in std::fs::read(source).expect("a source file") { + stamp = (stamp ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3); + } + } + println!("cargo:rustc-env=ELY_PDF_BUILD={stamp:016x}"); + println!("cargo:rerun-if-env-changed=ELY_MUPDF_DIR"); + for lib in ["libmupdf.a", "libmupdf-third.a", "libmupdf-pkcs7.a"] { + println!("cargo:rerun-if-changed={}", mupdf.join(lib).display()); + if !mupdf.join(lib).is_file() { + panic!("no {lib} in {} (run scripts/build-mupdf.sh or set ELY_MUPDF_DIR)", mupdf.display()); + } + } + cc::Build::new() + .file("shim/ely_mupdf.c") + .file("shim/ely_pages.c") + .file("shim/ely_insert.c") + .file("shim/ely_text.c") + .file("shim/ely_write.c") + .file("shim/ely_place.c") + .file("shim/ely_annot.c") + .file("shim/ely_form.c") + .file("shim/ely_ocr.c") + .file("shim/ely_outline.c") + .file("shim/ely_meta.c") + .file("shim/ely_redact.c") + .file("shim/ely_alt.c") + .file("shim/ely_clean.c") + .file("shim/ely_secure.c") + .file("shim/ely_sign.c") + .file("shim/ely_output.c") + .include(root.join("reference/mupdf/include")) + .include(openssl("includedir")) + .warnings(true) + .cargo_metadata(false) + .compile("ely_mupdf"); + let out = PathBuf::from(std::env::var("OUT_DIR").expect("set by cargo")); + let bin = "ely-pdf-worker"; + println!("cargo:rustc-link-arg-bin={bin}={}", out.join("libely_mupdf.a").display()); + println!("cargo:rustc-link-arg-bin={bin}={}", mupdf.join("libmupdf-pkcs7.a").display()); + println!("cargo:rustc-link-arg-bin={bin}={}", mupdf.join("libmupdf.a").display()); + println!("cargo:rustc-link-arg-bin={bin}={}", mupdf.join("libmupdf-third.a").display()); + // Signing and checking need OpenSSL, linked in whole. + println!("cargo:rustc-link-arg-bin={bin}={}", PathBuf::from(openssl("libdir")).join("libcrypto.a").display()); + println!("cargo:rustc-link-arg-bin={bin}=-lm"); + // Tesseract inside MuPDF is C++. + let cxx = if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { "-lc++" } else { "-lstdc++" }; + println!("cargo:rustc-link-arg-bin={bin}={cxx}"); +} + +/// An OpenSSL directory, from pkg-config. +fn openssl(variable: &str) -> String { + let out = std::process::Command::new("pkg-config") + .args([&format!("--variable={variable}"), "libcrypto"]) + .output() + .expect("pkg-config, to find OpenSSL"); + assert!(out.status.success(), "pkg-config knows no libcrypto"); + String::from_utf8(out.stdout).expect("a UTF-8 path").trim().to_string() +} diff --git a/crates/ely-pdf-engine/fonts/GreatVibes-Regular.ttf b/crates/ely-pdf-engine/fonts/GreatVibes-Regular.ttf new file mode 100644 index 0000000..a1327ff Binary files /dev/null and b/crates/ely-pdf-engine/fonts/GreatVibes-Regular.ttf differ diff --git a/crates/ely-pdf-engine/fonts/OFL.txt b/crates/ely-pdf-engine/fonts/OFL.txt new file mode 100644 index 0000000..7002592 --- /dev/null +++ b/crates/ely-pdf-engine/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2015 The Great Vibes Pro Project Authors (https://github.com/googlefonts/great-vibes) + +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: +https://openfontlicense.org + + +----------------------------------------------------------- +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. \ No newline at end of file diff --git a/crates/ely-pdf-engine/shim/ely_alt.c b/crates/ely-pdf-engine/shim/ely_alt.c new file mode 100644 index 0000000..4ce0954 --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_alt.c @@ -0,0 +1,86 @@ +/* Alternate text dropped from redacted pages (PDF-09). */ + +#include "ely_shim.h" + +typedef void (bdc_fn)(fz_context *ctx, pdf_processor *proc, const char *tag, pdf_obj *raw, pdf_obj *cooked); + +static void drop_said(fz_context *ctx, pdf_obj *dict) +{ + if (!pdf_is_dict(ctx, dict)) + return; + pdf_dict_del(ctx, dict, PDF_NAME(ActualText)); + pdf_dict_del(ctx, dict, PDF_NAME(Alt)); + pdf_dict_del(ctx, dict, PDF_NAME(E)); +} + +static void quiet_bdc(fz_context *ctx, pdf_processor *proc, const char *tag, pdf_obj *raw, pdf_obj *cooked) +{ + bdc_fn **out = fz_user_context(ctx); + drop_said(ctx, raw); + drop_said(ctx, cooked); + (*out)(ctx, proc, tag, raw, cooked); +} + +/* Each output stream writes marked content through `quiet_bdc`. */ +static pdf_processor *quiet_factory(fz_context *ctx, pdf_document *doc, pdf_processor *chain, int struct_parents, + fz_matrix transform, pdf_filter_options *options, void *sopts) +{ + bdc_fn **out = fz_user_context(ctx); + if (chain->op_BDC != quiet_bdc) + { + *out = chain->op_BDC; + chain->op_BDC = quiet_bdc; + } + return pdf_new_sanitize_filter(ctx, doc, chain, struct_parents, transform, options, sopts); +} + +/* Strips elements on `page`, by own, inherited or kids' `Pg`. */ +static int quiet_elem(fz_context *ctx, pdf_obj *elem, int page, int inherited) +{ + pdf_obj *kids; + int pg, i, on = 0; + if (!pdf_is_dict(ctx, elem) || pdf_mark_obj(ctx, elem)) + return 0; + fz_try(ctx) + { + pg = pdf_dict_gets(ctx, elem, "Pg") ? pdf_to_num(ctx, pdf_dict_gets(ctx, elem, "Pg")) : inherited; + on = pg == page; + kids = pdf_dict_get(ctx, elem, PDF_NAME(K)); + if (pdf_is_array(ctx, kids)) + for (i = 0; i < pdf_array_len(ctx, kids); i++) + on |= quiet_elem(ctx, pdf_array_get(ctx, kids, i), page, pg); + else + on |= quiet_elem(ctx, kids, page, pg); + if (on) + { + drop_said(ctx, elem); + pdf_dict_del(ctx, elem, PDF_NAME(T)); + } + } + fz_always(ctx) + pdf_unmark_obj(ctx, elem); + fz_catch(ctx) + fz_rethrow(ctx); + return on; +} + +/* Drops what marked content says on `page`, forms too. */ +void ely_drop_alt(fz_context *ctx, pdf_document *pdf, pdf_page *page) +{ + pdf_sanitize_filter_options sopts = { 0 }; + pdf_filter_factory list[2] = { { quiet_factory, NULL }, { NULL, NULL } }; + pdf_filter_options opts = { 0 }; + void *before = fz_user_context(ctx); + bdc_fn *out = NULL; + list[0].options = &sopts; + opts.recurse = 1; + opts.filters = list; + fz_set_user_context(ctx, &out); + fz_try(ctx) + pdf_filter_page_contents(ctx, pdf, page, &opts); + fz_always(ctx) + fz_set_user_context(ctx, before); + fz_catch(ctx) + fz_rethrow(ctx); + quiet_elem(ctx, pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/StructTreeRoot"), pdf_to_num(ctx, page->obj), 0); +} diff --git a/crates/ely-pdf-engine/shim/ely_annot.c b/crates/ely-pdf-engine/shim/ely_annot.c new file mode 100644 index 0000000..ea2c08c --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_annot.c @@ -0,0 +1,461 @@ +/* Annotations: list, add, change, delete (PDF-05). */ + +#include +#include + +#include "ely_shim.h" + +static const char *stamps[] = { "Approved", "AsIs", "Confidential", "Departmental", "Draft", "Experimental", + "Expired", "Final", "ForComment", "ForPublicRelease", "NotApproved", "NotForPublicRelease", "Sold", + "TopSecret", NULL }; + +void ely_json_string(fz_context *ctx, fz_output *out, const char *s) +{ + fz_write_byte(ctx, out, '"'); + for (; *s; s++) + { + unsigned char c = (unsigned char)*s; + if (c == '"' || c == '\\') + fz_write_printf(ctx, out, "\\%c", c); + else if (c < 32) + fz_write_printf(ctx, out, "\\u%04x", c); + else + fz_write_byte(ctx, out, c); + } + fz_write_byte(ctx, out, '"'); +} + +fz_json *ely_member(fz_context *ctx, fz_json *obj, const char *key, int type) +{ + fz_json *value = fz_json_object_get(ctx, obj, key); + if (value && value->type != type) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad %s", key); + return value; +} + +float ely_number(fz_context *ctx, fz_json *array, int at) +{ + fz_json *value = fz_json_array_get(ctx, array, at); + if (!value || value->type != FZ_JSON_NUMBER) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad number list"); + return (float)value->u.number; +} + +/* Four numbers, at least `least` wide and high. */ +fz_rect ely_rect(fz_context *ctx, fz_json *array, float least) +{ + fz_rect r; + if (!array || array->type != FZ_JSON_ARRAY || fz_json_array_length(ctx, array) != 4) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad rectangle"); + r = fz_make_rect(ely_number(ctx, array, 0), ely_number(ctx, array, 1), ely_number(ctx, array, 2), + ely_number(ctx, array, 3)); + if (r.x1 - r.x0 < least || r.y1 - r.y0 < least) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a rectangle too small"); + return r; +} + +static enum pdf_annot_type kind_of(fz_context *ctx, const char *kind) +{ + if (!strcmp(kind, "highlight")) return PDF_ANNOT_HIGHLIGHT; + if (!strcmp(kind, "underline")) return PDF_ANNOT_UNDERLINE; + if (!strcmp(kind, "strikeout")) return PDF_ANNOT_STRIKE_OUT; + if (!strcmp(kind, "note")) return PDF_ANNOT_TEXT; + if (!strcmp(kind, "square")) return PDF_ANNOT_SQUARE; + if (!strcmp(kind, "circle")) return PDF_ANNOT_CIRCLE; + if (!strcmp(kind, "line")) return PDF_ANNOT_LINE; + if (!strcmp(kind, "ink")) return PDF_ANNOT_INK; + if (!strcmp(kind, "stamp")) return PDF_ANNOT_STAMP; + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no annotation kind %s", kind); +} + +static int rgb_of(fz_context *ctx, pdf_annot *annot) +{ + float c[4], k; + int n; + pdf_annot_color(ctx, annot, &n, c); + if (n == 1) + c[1] = c[2] = c[0]; + else if (n == 4) + { + k = 1 - c[3]; + c[0] = (1 - c[0]) * k, c[1] = (1 - c[1]) * k, c[2] = (1 - c[2]) * k; + } + else if (n != 3) + return -1; + return ((int)(c[0] * 255 + 0.5f) << 16) | ((int)(c[1] * 255 + 0.5f) << 8) | (int)(c[2] * 255 + 0.5f); +} + +static int color_of(fz_context *ctx, fz_json *spec) +{ + fz_json *color = ely_member(ctx, spec, "color", FZ_JSON_NUMBER); + if (!color) + return -1; + if (color->u.number < 0 || color->u.number > 0xffffff) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad color"); + return (int)color->u.number; +} + +static const char *text_of(fz_context *ctx, fz_json *spec, const char *key) +{ + fz_json *text = ely_member(ctx, spec, key, FZ_JSON_STRING); + return text ? text->u.string : NULL; +} + +static int is_stamp(const char *name) +{ + int i; + for (i = 0; name && stamps[i]; i++) + if (!strcmp(stamps[i], name)) + return 1; + return 0; +} + +/* Throws unless `spec` makes a whole annotation. */ +static enum pdf_annot_type check(fz_context *ctx, fz_json *spec, int pages) +{ + fz_json *page = ely_member(ctx, spec, "page", FZ_JSON_NUMBER); + fz_json *kind = ely_member(ctx, spec, "kind", FZ_JSON_STRING); + fz_json *list; + enum pdf_annot_type type; + int i, n; + if (!page || page->u.number < 0 || page->u.number >= pages || !kind) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "an annotation with no page or kind"); + type = kind_of(ctx, kind->u.string); + color_of(ctx, spec); + text_of(ctx, spec, "contents"); + text_of(ctx, spec, "author"); + switch (type) + { + case PDF_ANNOT_HIGHLIGHT: + case PDF_ANNOT_UNDERLINE: + case PDF_ANNOT_STRIKE_OUT: + list = ely_member(ctx, spec, "rects", FZ_JSON_ARRAY); + n = list ? fz_json_array_length(ctx, list) : 0; + if (n == 0) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a mark over nothing"); + for (i = 0; i < n; i++) + ely_rect(ctx, fz_json_array_get(ctx, list, i), 0.1f); + break; + case PDF_ANNOT_TEXT: + list = ely_member(ctx, spec, "at", FZ_JSON_ARRAY); + if (!list || fz_json_array_length(ctx, list) != 2) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a note with no place"); + ely_number(ctx, list, 0), ely_number(ctx, list, 1); + break; + case PDF_ANNOT_LINE: + list = ely_member(ctx, spec, "line", FZ_JSON_ARRAY); + if (!list || fz_json_array_length(ctx, list) != 4) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a line with no ends"); + if (fabsf(ely_number(ctx, list, 2) - ely_number(ctx, list, 0)) + + fabsf(ely_number(ctx, list, 3) - ely_number(ctx, list, 1)) < 2) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a line too short"); + break; + case PDF_ANNOT_INK: + list = ely_member(ctx, spec, "strokes", FZ_JSON_ARRAY); + n = list ? fz_json_array_length(ctx, list) : 0; + if (n == 0) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "ink with no strokes"); + for (i = 0; i < n; i++) + { + fz_json *stroke = fz_json_array_get(ctx, list, i); + int k, m = stroke->type == FZ_JSON_ARRAY ? fz_json_array_length(ctx, stroke) : 0; + if (m < 4 || m % 2) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad stroke"); + for (k = 0; k < m; k++) + ely_number(ctx, stroke, k); + } + break; + case PDF_ANNOT_STAMP: + if (!is_stamp(text_of(ctx, spec, "icon"))) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no such stamp"); + ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 2); + break; + default: + ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 2); + } + return type; +} + +static fz_point point_at(fz_context *ctx, fz_json *list, int at) +{ + return fz_make_point(ely_number(ctx, list, at), ely_number(ctx, list, at + 1)); +} + +/* Makes a checked `spec` inside the open step. */ +static void make(fz_context *ctx, pdf_document *pdf, fz_json *spec, enum pdf_annot_type type) +{ + pdf_page *page = NULL; + pdf_annot *annot = NULL; + fz_point *points = NULL; + fz_var(page); + fz_var(annot); + fz_var(points); + fz_try(ctx) + { + fz_json *list; + int i, k, n, color = color_of(ctx, spec); + page = pdf_load_page(ctx, pdf, (int)ely_member(ctx, spec, "page", FZ_JSON_NUMBER)->u.number); + annot = pdf_create_annot(ctx, page, type); + switch (type) + { + case PDF_ANNOT_HIGHLIGHT: + case PDF_ANNOT_UNDERLINE: + case PDF_ANNOT_STRIKE_OUT: + list = ely_member(ctx, spec, "rects", FZ_JSON_ARRAY); + n = fz_json_array_length(ctx, list); + for (i = 0; i < n; i++) + pdf_add_annot_quad_point(ctx, annot, fz_quad_from_rect(ely_rect(ctx, fz_json_array_get(ctx, list, i), 0.1f))); + break; + case PDF_ANNOT_TEXT: + { + fz_point at = point_at(ctx, ely_member(ctx, spec, "at", FZ_JSON_ARRAY), 0); + pdf_set_annot_rect(ctx, annot, fz_make_rect(at.x, at.y, at.x + 16, at.y + 16)); + pdf_set_annot_popup(ctx, annot, fz_make_rect(at.x + 20, at.y, at.x + 220, at.y + 100)); + break; + } + case PDF_ANNOT_LINE: + list = ely_member(ctx, spec, "line", FZ_JSON_ARRAY); + pdf_set_annot_line(ctx, annot, point_at(ctx, list, 0), point_at(ctx, list, 2)); + break; + case PDF_ANNOT_INK: + list = ely_member(ctx, spec, "strokes", FZ_JSON_ARRAY); + n = fz_json_array_length(ctx, list); + for (i = 0; i < n; i++) + { + fz_json *stroke = fz_json_array_get(ctx, list, i); + int m = fz_json_array_length(ctx, stroke) / 2; + fz_free(ctx, points); + points = NULL; + points = fz_malloc_array(ctx, m, fz_point); + for (k = 0; k < m; k++) + points[k] = point_at(ctx, stroke, k * 2); + pdf_add_annot_ink_list(ctx, annot, m, points); + } + break; + case PDF_ANNOT_STAMP: + pdf_set_annot_icon_name(ctx, annot, text_of(ctx, spec, "icon")); + pdf_set_annot_rect(ctx, annot, ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 2)); + break; + default: + pdf_set_annot_rect(ctx, annot, ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 2)); + } + if (color >= 0) + { + float rgb[3] = { (color >> 16) / 255.0f, ((color >> 8) & 255) / 255.0f, (color & 255) / 255.0f }; + pdf_set_annot_color(ctx, annot, 3, rgb); + } + if (text_of(ctx, spec, "contents")) + pdf_set_annot_contents(ctx, annot, text_of(ctx, spec, "contents")); + if (text_of(ctx, spec, "author")) + pdf_set_annot_author(ctx, annot, text_of(ctx, spec, "author")); + pdf_update_annot(ctx, annot); + } + fz_always(ctx) + { + fz_free(ctx, points); + pdf_drop_annot(ctx, annot); + fz_drop_page(ctx, (fz_page *)page); + } + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* A JSON list of annotations, added in one step. */ +int ely_annotate(fz_context *ctx, fz_document *doc, const char *json) +{ + pdf_document *pdf = NULL; + fz_pool *pool = NULL; + enum pdf_annot_type *types = NULL; + int begun = 0; + fz_var(pdf); + fz_var(pool); + fz_var(types); + fz_var(begun); + fz_try(ctx) + { + fz_json *specs; + int i, n, pages; + pdf = ely_pdf_of(ctx, doc); + pages = pdf_count_pages(ctx, pdf); + pool = fz_new_pool(ctx); + specs = fz_parse_json(ctx, pool, json); + n = specs->type == FZ_JSON_ARRAY ? fz_json_array_length(ctx, specs) : 0; + if (n == 0) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no annotations to add"); + types = fz_malloc_array(ctx, n, enum pdf_annot_type); + for (i = 0; i < n; i++) + types[i] = check(ctx, fz_json_array_get(ctx, specs, i), pages); + pdf_begin_operation(ctx, pdf, "Annotate"); + begun = 1; + for (i = 0; i < n; i++) + make(ctx, pdf, fz_json_array_get(ctx, specs, i), types[i]); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + fz_free(ctx, types); + fz_drop_pool(ctx, pool); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* The annotation or widget object `id` on `page`. */ +pdf_annot *ely_find_annot(fz_context *ctx, pdf_page *page, int id, int widget) +{ + pdf_annot *annot = widget ? pdf_first_widget(ctx, page) : pdf_first_annot(ctx, page); + for (; annot; annot = widget ? pdf_next_widget(ctx, annot) : pdf_next_annot(ctx, annot)) + if (pdf_to_num(ctx, pdf_annot_obj(ctx, annot)) == id) + return annot; + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no %s %d on the page", widget ? "field" : "annotation", id); +} + +/* Sets an annotation's colour or contents, in one step. */ +int ely_change_annot(fz_context *ctx, fz_document *doc, int number, int id, const char *json) +{ + pdf_document *pdf = NULL; + pdf_page *page = NULL; + fz_pool *pool = NULL; + int begun = 0; + fz_var(pdf); + fz_var(page); + fz_var(pool); + fz_var(begun); + fz_try(ctx) + { + pdf_annot *annot; + fz_json *spec; + const char *contents; + int color; + pdf = ely_pdf_of(ctx, doc); + page = pdf_load_page(ctx, pdf, number); + annot = ely_find_annot(ctx, page, id, 0); + pool = fz_new_pool(ctx); + spec = fz_parse_json(ctx, pool, json); + color = color_of(ctx, spec); + contents = text_of(ctx, spec, "contents"); + if (color < 0 && !contents) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no change"); + pdf_begin_operation(ctx, pdf, "Change an annotation"); + begun = 1; + if (color >= 0) + { + float rgb[3] = { (color >> 16) / 255.0f, ((color >> 8) & 255) / 255.0f, (color & 255) / 255.0f }; + pdf_set_annot_color(ctx, annot, 3, rgb); + } + if (contents) + pdf_set_annot_contents(ctx, annot, contents); + pdf_update_annot(ctx, annot); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + fz_drop_pool(ctx, pool); + fz_drop_page(ctx, (fz_page *)page); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* Unlinks `field`; an emptied parent goes too. */ +void ely_unlink_field(fz_context *ctx, pdf_document *pdf, pdf_obj *field) +{ + pdf_obj *parent = pdf_dict_get(ctx, field, PDF_NAME(Parent)); + pdf_obj *kids = parent ? pdf_dict_get(ctx, parent, PDF_NAME(Kids)) : pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields"); + int at = pdf_array_find(ctx, kids, field); + if (at >= 0) + pdf_array_delete(ctx, kids, at); + if (parent && pdf_array_len(ctx, kids) == 0) + ely_unlink_field(ctx, pdf, parent); +} + +/* Deletes annotation or field `id`, in one step. */ +int ely_delete_annot(fz_context *ctx, fz_document *doc, int number, int id, int widget) +{ + pdf_document *pdf = NULL; + pdf_page *page = NULL; + pdf_obj *field = NULL; + int begun = 0; + fz_var(pdf); + fz_var(page); + fz_var(field); + fz_var(begun); + fz_try(ctx) + { + pdf_annot *annot; + pdf = ely_pdf_of(ctx, doc); + page = pdf_load_page(ctx, pdf, number); + annot = ely_find_annot(ctx, page, id, widget); + field = pdf_keep_obj(ctx, pdf_annot_obj(ctx, annot)); + pdf_begin_operation(ctx, pdf, widget ? "Delete a field" : "Delete an annotation"); + begun = 1; + pdf_delete_annot(ctx, page, annot); + if (widget) + ely_unlink_field(ctx, pdf, field); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + pdf_drop_obj(ctx, field); + fz_drop_page(ctx, (fz_page *)page); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +static void write_annot(fz_context *ctx, fz_output *out, pdf_annot *annot) +{ + fz_rect r = pdf_bound_annot(ctx, annot); + fz_write_printf(ctx, out, "{\"id\":%d,\"kind\":", pdf_to_num(ctx, pdf_annot_obj(ctx, annot))); + ely_json_string(ctx, out, pdf_string_from_annot_type(ctx, pdf_annot_type(ctx, annot))); + fz_write_printf(ctx, out, ",\"rect\":[%g,%g,%g,%g],\"color\":%d,\"contents\":", r.x0, r.y0, r.x1, r.y1, + rgb_of(ctx, annot)); + ely_json_string(ctx, out, pdf_annot_contents(ctx, annot)); + fz_write_string(ctx, out, ",\"author\":"); + ely_json_string(ctx, out, pdf_annot_has_author(ctx, annot) ? pdf_annot_author(ctx, annot) : ""); + fz_write_string(ctx, out, ",\"icon\":"); + ely_json_string(ctx, out, pdf_annot_has_icon_name(ctx, annot) ? pdf_annot_icon_name(ctx, annot) : ""); + fz_write_byte(ctx, out, '}'); +} + +/* JSON of a page's annotations and form fields. */ +int ely_annots(fz_context *ctx, fz_document *doc, int number, unsigned char **data, size_t *len) +{ + pdf_page *page = NULL; + fz_buffer *buf = NULL; + fz_output *out = NULL; + fz_var(page); + fz_var(buf); + fz_var(out); + fz_try(ctx) + { + pdf_annot *annot; + page = pdf_load_page(ctx, ely_pdf_of(ctx, doc), number); + buf = fz_new_buffer(ctx, 1024); + out = fz_new_output_with_buffer(ctx, buf); + fz_write_string(ctx, out, "{\"annots\":["); + for (annot = pdf_first_annot(ctx, page); annot; annot = pdf_next_annot(ctx, annot)) + { + if (annot != pdf_first_annot(ctx, page)) + fz_write_byte(ctx, out, ','); + write_annot(ctx, out, annot); + } + fz_write_string(ctx, out, "],\"fields\":["); + ely_write_fields(ctx, out, page); + fz_write_string(ctx, out, "]}"); + fz_close_output(ctx, out); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_drop_output(ctx, out); + fz_drop_buffer(ctx, buf); + fz_drop_page(ctx, (fz_page *)page); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} diff --git a/crates/ely-pdf-engine/shim/ely_clean.c b/crates/ely-pdf-engine/shim/ely_clean.c new file mode 100644 index 0000000..ea47cdb --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_clean.c @@ -0,0 +1,395 @@ +/* Cleaning into a new file, counted before and after (PDF-10). */ + +#include + +#include "ely_shim.h" + +enum { CLEAN_META = 1, CLEAN_ANNOTS = 2, CLEAN_FILES = 4, CLEAN_HIDDEN = 8, CLEAN_ALL = 15 }; + +/* What cleaning reaches, counted. */ +typedef struct +{ + int meta, annots, files, hidden_pages, scripts, hidden_annots; +} reach; + +/* The filter running a page, and what it drops. */ +typedef struct +{ + pdf_processor *top; + int dropped; +} hiding; + +static pdf_processor *hide_factory(fz_context *ctx, pdf_document *doc, pdf_processor *chain, int struct_parents, + fz_matrix transform, pdf_filter_options *options, void *sopts) +{ + hiding *h = ((pdf_sanitize_filter_options *)sopts)->opaque; + h->top = pdf_new_sanitize_filter(ctx, doc, chain, struct_parents, transform, options, sopts); + /* Layers count as hidden only for a viewer's usage. */ + h->top->usage = "View"; + return h->top; +} + +/* Unseen text: modes 3 and 7, or zero opacity. */ +static int hide_text(fz_context *ctx, void *opaque, int *ucs, int len, fz_matrix trm, fz_matrix ctm, fz_rect bbox, + int tr, float ca, float CA) +{ + hiding *h = opaque; + int fills = tr == 0 || tr == 2 || tr == 4 || tr == 6, strokes = tr == 1 || tr == 2 || tr == 5 || tr == 6; + int drop = h->top->hidden > 0 || tr == 3 || tr == 7 || ((!fills || ca == 0) && (!strokes || CA == 0)); + (void)ctx, (void)ucs, (void)len, (void)trm, (void)ctm, (void)bbox; + h->dropped += drop; + return drop; +} + +/* Drawing inside a layer that is off. */ +static int hide_cull(fz_context *ctx, void *opaque, fz_rect bbox, fz_cull_type type) +{ + hiding *h = opaque; + (void)ctx, (void)bbox, (void)type; + h->dropped += h->top->hidden > 0; + return h->top->hidden > 0; +} + +/* Drops hidden text and hidden layers from `page`; how many. */ +static int drop_hidden(fz_context *ctx, pdf_document *pdf, pdf_page *page) +{ + hiding h = { NULL, 0 }; + pdf_sanitize_filter_options sopts = { 0 }; + pdf_filter_factory list[2] = { { hide_factory, NULL }, { NULL, NULL } }; + pdf_filter_options opts = { 0 }; + sopts.opaque = &h; + sopts.text_filter = hide_text; + sopts.culler = hide_cull; + list[0].options = &sopts; + opts.recurse = 1; + opts.filters = list; + pdf_filter_page_contents(ctx, pdf, page, &opts); + if (h.dropped) + ely_drop_alt(ctx, pdf, page); + return h.dropped; +} + +static int is_script(fz_context *ctx, pdf_obj *action) +{ + return pdf_is_dict(ctx, action) && + (pdf_name_eq(ctx, pdf_dict_get(ctx, action, PDF_NAME(S)), PDF_NAME(JavaScript)) || pdf_dict_get(ctx, action, PDF_NAME(JS))); +} + +/* `action` without its scripts, their Next chains kept. */ +static pdf_obj *spliced(fz_context *ctx, pdf_document *pdf, pdf_obj *action, int *n) +{ + pdf_obj *out = NULL, *each = NULL; + int i, k; + fz_var(each); + if (is_script(ctx, action)) + { + if (pdf_mark_obj(ctx, action)) + return NULL; + (*n)++; + fz_try(ctx) + out = spliced(ctx, pdf, pdf_dict_get(ctx, action, PDF_NAME(Next)), n); + fz_always(ctx) + pdf_unmark_obj(ctx, action); + fz_catch(ctx) + fz_rethrow(ctx); + return out; + } + if (!pdf_is_array(ctx, action)) + return pdf_keep_obj(ctx, action); + out = pdf_new_array(ctx, pdf, pdf_array_len(ctx, action)); + fz_try(ctx) + for (i = 0; i < pdf_array_len(ctx, action); i++) + { + each = spliced(ctx, pdf, pdf_array_get(ctx, action, i), n); + if (pdf_is_array(ctx, each)) + for (k = 0; k < pdf_array_len(ctx, each); k++) + pdf_array_push(ctx, out, pdf_array_get(ctx, each, k)); + else if (each) + pdf_array_push(ctx, out, each); + pdf_drop_obj(ctx, each); + each = NULL; + } + fz_always(ctx) + pdf_drop_obj(ctx, each); + fz_catch(ctx) + { + pdf_drop_obj(ctx, out); + fz_rethrow(ctx); + } + return out; +} + +/* Splices scripts out of `dict[key]`; how many. */ +static int splice_key(fz_context *ctx, pdf_document *pdf, pdf_obj *dict, const char *key) +{ + pdf_obj *now = pdf_dict_gets(ctx, dict, key), *kept; + int n = 0; + if (!now) + return 0; + kept = spliced(ctx, pdf, now, &n); + if (n == 0) + pdf_drop_obj(ctx, kept); + else if (kept && !(pdf_is_array(ctx, kept) && pdf_array_len(ctx, kept) == 0)) + pdf_dict_puts_drop(ctx, dict, key, kept); + else + { + pdf_drop_obj(ctx, kept); + pdf_dict_dels(ctx, dict, key); + } + return n; +} + +static int drop_key(fz_context *ctx, pdf_obj *dict, const char *key) +{ + int had = pdf_dict_gets(ctx, dict, key) != NULL; + pdf_dict_dels(ctx, dict, key); + return had; +} + +/* Cleans `obj` and the direct values inside it. */ +static void clean_obj(fz_context *ctx, pdf_document *pdf, pdf_obj *obj, int mask, reach *r) +{ + pdf_obj *aa; + int i; + if (pdf_is_dict(ctx, obj)) + { + if (mask & CLEAN_META) + r->meta += drop_key(ctx, obj, "Metadata") + drop_key(ctx, obj, "PieceInfo"); + if (mask & CLEAN_FILES) + { + r->files += drop_key(ctx, obj, "EF"); + drop_key(ctx, obj, "RF"); + drop_key(ctx, obj, "AF"); + } + if (mask & CLEAN_HIDDEN) + { + r->scripts += splice_key(ctx, pdf, obj, "A") + splice_key(ctx, pdf, obj, "OpenAction") + + splice_key(ctx, pdf, obj, "Next"); + aa = pdf_dict_get(ctx, obj, PDF_NAME(AA)); + for (i = pdf_dict_len(ctx, aa) - 1; i >= 0; i--) + r->scripts += splice_key(ctx, pdf, aa, pdf_to_name(ctx, pdf_dict_get_key(ctx, aa, i))); + if (pdf_is_dict(ctx, aa) && pdf_dict_len(ctx, aa) == 0) + pdf_dict_del(ctx, obj, PDF_NAME(AA)); + } + for (i = 0; i < pdf_dict_len(ctx, obj); i++) + if (!pdf_is_indirect(ctx, pdf_dict_get_val(ctx, obj, i))) + clean_obj(ctx, pdf, pdf_dict_get_val(ctx, obj, i), mask, r); + } + else if (pdf_is_array(ctx, obj)) + for (i = 0; i < pdf_array_len(ctx, obj); i++) + if (!pdf_is_indirect(ctx, pdf_array_get(ctx, obj, i))) + clean_obj(ctx, pdf, pdf_array_get(ctx, obj, i), mask, r); +} + +/* Every object: metadata, embedded bytes, scripts. */ +static void clean_all(fz_context *ctx, pdf_document *pdf, int mask, reach *r) +{ + int i; + for (i = 1; i < pdf_xref_len(ctx, pdf); i++) + { + pdf_obj *obj = pdf_new_indirect(ctx, pdf, i, 0); + fz_try(ctx) + clean_obj(ctx, pdf, pdf_resolve_indirect(ctx, obj), mask, r); + fz_always(ctx) + pdf_drop_obj(ctx, obj); + fz_catch(ctx) + fz_rethrow(ctx); + } +} + +static int unseen(fz_context *ctx, pdf_obj *annot) +{ + return (pdf_dict_get_int(ctx, annot, PDF_NAME(F)) & (PDF_ANNOT_IS_HIDDEN | PDF_ANNOT_IS_INVISIBLE)) != 0; +} + +/* Hidden fields go with their values. */ +static int drop_hidden_widgets(fz_context *ctx, pdf_page *page) +{ + pdf_annot *widget; + int n = 0; +restart: + for (widget = pdf_first_widget(ctx, page); widget; widget = pdf_next_widget(ctx, widget)) + if (unseen(ctx, pdf_annot_obj(ctx, widget))) + { + pdf_obj *field = pdf_keep_obj(ctx, pdf_annot_obj(ctx, widget)); + fz_try(ctx) + { + pdf_delete_annot(ctx, page, widget); + ely_unlink_field(ctx, page->doc, field); + } + fz_always(ctx) + pdf_drop_obj(ctx, field); + fz_catch(ctx) + fz_rethrow(ctx); + n++; + goto restart; + } + return n; +} + +/* Links are not in MuPDF's annotation list. */ +static int drop_hidden_links(fz_context *ctx, pdf_page *page) +{ + pdf_obj *annots = pdf_dict_get(ctx, page->obj, PDF_NAME(Annots)); + int i, n = 0; + for (i = pdf_array_len(ctx, annots) - 1; i >= 0; i--) + { + pdf_obj *annot = pdf_array_get(ctx, annots, i); + if (pdf_name_eq(ctx, pdf_dict_get(ctx, annot, PDF_NAME(Subtype)), PDF_NAME(Link)) && unseen(ctx, annot)) + { + pdf_array_delete(ctx, annots, i); + n++; + } + } + return n; +} + +static void sweep_page(fz_context *ctx, pdf_document *pdf, pdf_page *page, int mask, reach *r) +{ + pdf_annot *annot; + if (mask & CLEAN_HIDDEN) + r->hidden_pages += drop_hidden(ctx, pdf, page) > 0; +restart: + for (annot = pdf_first_annot(ctx, page); annot; annot = pdf_next_annot(ctx, annot)) + { + int file = pdf_annot_type(ctx, annot) == PDF_ANNOT_FILE_ATTACHMENT; + int hidden = (pdf_annot_flags(ctx, annot) & (PDF_ANNOT_IS_HIDDEN | PDF_ANNOT_IS_INVISIBLE)) != 0; + if ((file && (mask & CLEAN_FILES)) || (!file && (mask & CLEAN_ANNOTS)) || (hidden && (mask & CLEAN_HIDDEN))) + { + r->hidden_annots += hidden && !file; + r->annots += !file && !hidden; + pdf_delete_annot(ctx, page, annot); + goto restart; + } + } + if (mask & CLEAN_HIDDEN) + r->hidden_annots += drop_hidden_widgets(ctx, page) + drop_hidden_links(ctx, page); +} + +/* Drops what `mask` names from `doc`, counting what was there. */ +static reach sweep(fz_context *ctx, fz_document *doc, int mask) +{ + pdf_document *pdf = ely_pdf_of(ctx, doc); + pdf_obj *trailer = pdf_trailer(ctx, pdf), *root = pdf_dict_get(ctx, trailer, PDF_NAME(Root)); + pdf_obj *names = pdf_dict_get(ctx, root, PDF_NAME(Names)), *tree; + pdf_page *page = NULL; + reach r = { 0 }; + int i; + fz_var(page); + if (mask & CLEAN_META) + { + r.meta = pdf_dict_len(ctx, pdf_dict_get(ctx, trailer, PDF_NAME(Info))); + pdf_dict_del(ctx, trailer, PDF_NAME(Info)); + } + if ((mask & CLEAN_FILES) && names) + pdf_dict_del(ctx, names, PDF_NAME(EmbeddedFiles)); + if (mask & CLEAN_HIDDEN) + { + tree = pdf_load_name_tree(ctx, pdf, PDF_NAME(JavaScript)); + r.scripts = pdf_dict_len(ctx, tree); + pdf_drop_obj(ctx, tree); + if (names) + pdf_dict_del(ctx, names, PDF_NAME(JavaScript)); + } + clean_all(ctx, pdf, mask, &r); + fz_try(ctx) + { + for (i = 0; i < pdf_count_pages(ctx, pdf); i++) + { + page = pdf_load_page(ctx, pdf, i); + sweep_page(ctx, pdf, page, mask, &r); + fz_drop_page(ctx, (fz_page *)page); + page = NULL; + } + } + fz_always(ctx) + fz_drop_page(ctx, (fz_page *)page); + fz_catch(ctx) + fz_rethrow(ctx); + return r; +} + +static void write_reach(fz_context *ctx, fz_buffer *buf, reach r) +{ + fz_append_printf(ctx, buf, "{\"meta\":%d,\"annots\":%d,\"files\":%d,\"hidden\":%d,\"scripts\":%d,\"hidden_annots\":%d}", + r.meta, r.annots, r.files, r.hidden_pages, r.scripts, r.hidden_annots); +} + +/* Counts by sweeping a throwaway copy of `pdf`. */ +static reach count_copy(fz_context *ctx, pdf_document *pdf, const char *raw, const char *password) +{ + fz_document *copy = NULL; + reach r; + fz_var(copy); + fz_try(ctx) + { + pdf_save_snapshot(ctx, pdf, raw); + copy = fz_open_document(ctx, raw); + if (fz_needs_password(ctx, copy) && !fz_authenticate_password(ctx, copy, password)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the copy refused its password"); + r = sweep(ctx, copy, CLEAN_ALL); + } + fz_always(ctx) + fz_drop_document(ctx, copy); + fz_catch(ctx) + fz_rethrow(ctx); + return r; +} + +/* What cleaning would reach, as JSON. */ +int ely_clean_scan(fz_context *ctx, fz_document *doc, const char *raw, const char *password, unsigned char **data, size_t *len) +{ + fz_buffer *buf = NULL; + fz_var(buf); + fz_try(ctx) + { + buf = fz_new_buffer(ctx, 128); + write_reach(ctx, buf, count_copy(ctx, ely_pdf_of(ctx, doc), raw, password)); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + fz_drop_buffer(ctx, buf); + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +static void apply_clean(fz_context *ctx, fz_document *copy, void *arg) +{ + sweep(ctx, copy, *(int *)arg); +} + +/* Cleans `mask` into `out`, then counts what is left. */ +int ely_clean(fz_context *ctx, fz_document *doc, int mask, const char *raw, const char *out, const char *password, + unsigned char **data, size_t *len) +{ + fz_document *written = NULL; + fz_buffer *buf = NULL; + fz_var(written); + fz_var(buf); + fz_try(ctx) + { + reach left; + if (mask <= 0 || mask > CLEAN_ALL) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "nothing chosen to clean"); + ely_save_copy(ctx, ely_pdf_of(ctx, doc), raw, out, password, NULL, apply_clean, &mask); + written = fz_open_document(ctx, out); + if (fz_needs_password(ctx, written) && !fz_authenticate_password(ctx, written, password)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the written file refused its password"); + left = count_copy(ctx, ely_pdf_of(ctx, written), raw, password); + buf = fz_new_buffer(ctx, 128); + write_reach(ctx, buf, left); + if (((mask & CLEAN_META) && left.meta) || ((mask & CLEAN_ANNOTS) && left.annots) || ((mask & CLEAN_FILES) && left.files) || + ((mask & CLEAN_HIDDEN) && (left.hidden_pages || left.scripts || left.hidden_annots))) + fz_throw(ctx, FZ_ERROR_FORMAT, "the written file still holds what was cleaned"); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, buf); + fz_drop_document(ctx, written); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} diff --git a/crates/ely-pdf-engine/shim/ely_form.c b/crates/ely-pdf-engine/shim/ely_form.c new file mode 100644 index 0000000..8bdd09c --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_form.c @@ -0,0 +1,489 @@ +/* Form fields: list, add, fill, change (PDF-06). */ + +#include +#include + +#include "ely_shim.h" + +static const char *kind_name(enum pdf_widget_type type) +{ + switch (type) + { + case PDF_WIDGET_TYPE_TEXT: return "text"; + case PDF_WIDGET_TYPE_CHECKBOX: return "checkbox"; + case PDF_WIDGET_TYPE_RADIOBUTTON: return "radio"; + case PDF_WIDGET_TYPE_COMBOBOX: return "choice"; + case PDF_WIDGET_TYPE_LISTBOX: return "list"; + case PDF_WIDGET_TYPE_SIGNATURE: return "signature"; + default: return "button"; + } +} + +/* The field that names `obj`. */ +static pdf_obj *head_of(fz_context *ctx, pdf_obj *obj) +{ + while (!pdf_dict_get(ctx, obj, PDF_NAME(T)) && pdf_dict_get(ctx, obj, PDF_NAME(Parent))) + obj = pdf_dict_get(ctx, obj, PDF_NAME(Parent)); + return obj; +} + +static pdf_obj *fields_of(fz_context *ctx, pdf_document *pdf) +{ + pdf_obj *root = pdf_dict_get(ctx, pdf_trailer(ctx, pdf), PDF_NAME(Root)); + pdf_obj *form = pdf_dict_get(ctx, root, PDF_NAME(AcroForm)); + if (!form) + form = pdf_dict_put_dict(ctx, root, PDF_NAME(AcroForm), 2); + if (!pdf_is_array(ctx, pdf_dict_get(ctx, form, PDF_NAME(Fields)))) + pdf_dict_put_array(ctx, form, PDF_NAME(Fields), 4); + return pdf_dict_get(ctx, form, PDF_NAME(Fields)); +} + +/* A value as text; names and streams too. */ +static void write_value(fz_context *ctx, fz_output *out, pdf_obj *v) +{ + char *text = NULL; + fz_var(text); + fz_try(ctx) + { + if (pdf_is_name(ctx, v)) + ely_json_string(ctx, out, pdf_to_name(ctx, v)); + else if (pdf_is_stream(ctx, v)) + { + text = pdf_new_utf8_from_pdf_stream_obj(ctx, v); + ely_json_string(ctx, out, text); + } + else + ely_json_string(ctx, out, pdf_to_text_string(ctx, v)); + } + fz_always(ctx) + fz_free(ctx, text); + fz_catch(ctx) + fz_rethrow(ctx); +} + +static void write_options(fz_context *ctx, fz_output *out, pdf_obj *obj, int export) +{ + int i, n = pdf_choice_field_option_count(ctx, obj); + fz_write_byte(ctx, out, '['); + for (i = 0; i < n; i++) + { + if (i) + fz_write_byte(ctx, out, ','); + ely_json_string(ctx, out, pdf_choice_field_option(ctx, obj, export, i)); + } + fz_write_byte(ctx, out, ']'); +} + +void ely_write_fields(fz_context *ctx, fz_output *out, pdf_page *page) +{ + pdf_annot *widget; + char *name = NULL; + fz_var(name); + fz_try(ctx) + { + for (widget = pdf_first_widget(ctx, page); widget; widget = pdf_next_widget(ctx, widget)) + { + pdf_obj *obj = pdf_annot_obj(ctx, widget); + enum pdf_widget_type type = pdf_widget_type(ctx, widget); + fz_rect r = pdf_bound_widget(ctx, widget); + int button = type == PDF_WIDGET_TYPE_CHECKBOX || type == PDF_WIDGET_TYPE_RADIOBUTTON; + pdf_obj *as = pdf_dict_get(ctx, obj, PDF_NAME(AS)); + fz_free(ctx, name); + name = NULL; + name = pdf_load_field_name(ctx, obj); + fz_write_printf(ctx, out, "%s{\"id\":%d,\"kind\":\"%s\",\"name\":", widget == pdf_first_widget(ctx, page) ? "" : ",", + pdf_to_num(ctx, obj), kind_name(type)); + ely_json_string(ctx, out, name); + fz_write_printf(ctx, out, ",\"rect\":[%g,%g,%g,%g],\"value\":", r.x0, r.y0, r.x1, r.y1); + write_value(ctx, out, pdf_dict_get_inheritable(ctx, obj, PDF_NAME(V))); + fz_write_string(ctx, out, ",\"on\":"); + ely_json_string(ctx, out, button ? pdf_to_name(ctx, pdf_button_field_on_state(ctx, obj)) : ""); + fz_write_printf(ctx, out, ",\"checked\":%s,\"readonly\":%s,\"options\":", + button && pdf_is_name(ctx, as) && !pdf_name_eq(ctx, as, PDF_NAME(Off)) ? "true" : "false", + pdf_widget_is_readonly(ctx, widget) ? "true" : "false"); + write_options(ctx, out, obj, 1); + fz_write_string(ctx, out, ",\"labels\":"); + write_options(ctx, out, obj, 0); + fz_write_byte(ctx, out, '}'); + } + } + fz_always(ctx) + fz_free(ctx, name); + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* Shows `state` on each button under `node`, else Off. */ +static void show_state(fz_context *ctx, pdf_obj *node, pdf_obj *state) +{ + pdf_obj *kids = pdf_dict_get(ctx, node, PDF_NAME(Kids)); + int i; + if (kids) + { + for (i = 0; i < pdf_array_len(ctx, kids); i++) + show_state(ctx, pdf_array_get(ctx, kids, i), state); + return; + } + pdf_dict_put(ctx, node, PDF_NAME(AS), pdf_dict_get(ctx, pdf_dict_getp(ctx, node, "AP/N"), state) ? state : PDF_NAME(Off)); +} + +/* Fills field widget `id` with `value`, in one step. */ +int ely_fill(fz_context *ctx, fz_document *doc, int number, int id, const char *value) +{ + pdf_document *pdf = NULL; + pdf_page *page = NULL; + pdf_obj *state = NULL; + int begun = 0; + fz_var(pdf); + fz_var(page); + fz_var(state); + fz_var(begun); + fz_try(ctx) + { + pdf_annot *widget; + pdf_obj *obj; + enum pdf_widget_type type; + pdf = ely_pdf_of(ctx, doc); + page = pdf_load_page(ctx, pdf, number); + widget = ely_find_annot(ctx, page, id, 1); + obj = pdf_annot_obj(ctx, widget); + type = pdf_widget_type(ctx, widget); + if (pdf_widget_is_readonly(ctx, widget)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the field is read only"); + if (type == PDF_WIDGET_TYPE_CHECKBOX || type == PDF_WIDGET_TYPE_RADIOBUTTON) + { + state = pdf_new_name(ctx, value); + if (!pdf_name_eq(ctx, state, PDF_NAME(Off)) && !pdf_name_eq(ctx, state, pdf_button_field_on_state(ctx, obj))) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the button has no state %s", value); + } + else if (type != PDF_WIDGET_TYPE_TEXT && type != PDF_WIDGET_TYPE_COMBOBOX && type != PDF_WIDGET_TYPE_LISTBOX) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a %s field cannot be filled", kind_name(type)); + pdf_begin_operation(ctx, pdf, "Fill a field"); + begun = 1; + if (state) + { + pdf_dict_put(ctx, head_of(ctx, obj), PDF_NAME(V), state); + show_state(ctx, head_of(ctx, obj), state); + } + else if (!pdf_set_field_value(ctx, pdf, obj, value, 1)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the field refused the value"); + pdf_update_annot(ctx, widget); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + pdf_drop_obj(ctx, state); + fz_drop_page(ctx, (fz_page *)page); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* Throws unless `prefix` plus `name` is free for `kind`. */ +static pdf_obj *check_name(fz_context *ctx, pdf_obj *fields, const char *prefix, const char *name, const char *kind, + pdf_obj *self) +{ + pdf_obj *found = NULL; + char *full = NULL; + if (!name[0] || strchr(name, '.')) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad field name"); + fz_var(full); + fz_try(ctx) + { + full = fz_asprintf(ctx, "%s%s", prefix, name); + found = pdf_lookup_field(ctx, fields, full); + } + fz_always(ctx) + fz_free(ctx, full); + fz_catch(ctx) + fz_rethrow(ctx); + if (!found || (self && !pdf_objcmp(ctx, found, self))) + return NULL; + if (!strcmp(kind, "radio") && pdf_field_type(ctx, found) == PDF_WIDGET_TYPE_RADIOBUTTON && + pdf_dict_get(ctx, found, PDF_NAME(Kids))) + return found; + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a field named %s%s exists", prefix, name); +} + +/* The full name of `field`'s parent, with a dot. */ +static char *parent_path(fz_context *ctx, pdf_obj *field) +{ + pdf_obj *parent = pdf_dict_get(ctx, field, PDF_NAME(Parent)); + char *name = parent ? pdf_load_field_name(ctx, parent) : NULL, *path = NULL; + fz_try(ctx) + path = fz_asprintf(ctx, "%s%s", name ? name : "", name && name[0] ? "." : ""); + fz_always(ctx) + fz_free(ctx, name); + fz_catch(ctx) + fz_rethrow(ctx); + return path; +} + +static void put_options(fz_context *ctx, pdf_obj *field, fz_json *options) +{ + pdf_obj *opt = pdf_dict_put_array(ctx, field, PDF_NAME(Opt), 4); + int i, n = fz_json_array_length(ctx, options); + for (i = 0; i < n; i++) + pdf_array_push_text_string(ctx, opt, fz_json_array_get(ctx, options, i)->u.string); +} + +static void check_options(fz_context *ctx, fz_json *options) +{ + int i, n = options ? fz_json_array_length(ctx, options) : 0; + if (n == 0) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a choice with no options"); + for (i = 0; i < n; i++) + if (fz_json_array_get(ctx, options, i)->type != FZ_JSON_STRING || !fz_json_array_get(ctx, options, i)->u.string[0]) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad option"); +} + +/* An on state no button in `group` has. */ +static pdf_obj *new_state(fz_context *ctx, pdf_obj *group) +{ + pdf_obj *kids = pdf_dict_get(ctx, group, PDF_NAME(Kids)); + char name[32]; + int n, i, taken; + for (n = pdf_array_len(ctx, kids) + 1;; n++) + { + snprintf(name, sizeof name, "Choice%d", n); + for (i = 0, taken = 0; i < pdf_array_len(ctx, kids); i++) + taken |= !strcmp(pdf_to_name(ctx, pdf_button_field_on_state(ctx, pdf_array_get(ctx, kids, i))), name); + if (!taken) + return pdf_new_name(ctx, name); + } +} + +/* Adds a field of four kinds, in one step. */ +int ely_add_field(fz_context *ctx, fz_document *doc, int number, const char *json) +{ + pdf_document *pdf = NULL; + pdf_page *page = NULL; + pdf_annot *widget = NULL; + pdf_obj *state = NULL; + fz_pool *pool = NULL; + int begun = 0; + fz_var(pdf); + fz_var(page); + fz_var(widget); + fz_var(state); + fz_var(pool); + fz_var(begun); + fz_try(ctx) + { + fz_json *spec, *options, *kindj, *namej; + const char *kind, *name; + pdf_obj *fields, *group, *obj, *mk; + fz_rect rect; + pdf = ely_pdf_of(ctx, doc); + page = pdf_load_page(ctx, pdf, number); + pool = fz_new_pool(ctx); + spec = fz_parse_json(ctx, pool, json); + kindj = ely_member(ctx, spec, "kind", FZ_JSON_STRING); + namej = ely_member(ctx, spec, "name", FZ_JSON_STRING); + options = ely_member(ctx, spec, "options", FZ_JSON_ARRAY); + if (!kindj || !namej) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a field with no kind or name"); + kind = kindj->u.string, name = namej->u.string; + if (strcmp(kind, "text") && strcmp(kind, "checkbox") && strcmp(kind, "radio") && strcmp(kind, "choice")) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no field kind %s", kind); + if (!strcmp(kind, "choice")) + check_options(ctx, options); + rect = ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 4); + group = check_name(ctx, pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields"), "", name, kind, NULL); + pdf_begin_operation(ctx, pdf, "Add a field"); + begun = 1; + fields = fields_of(ctx, pdf); + widget = pdf_create_annot_raw(ctx, page, PDF_ANNOT_WIDGET); + obj = pdf_annot_obj(ctx, widget); + pdf_dict_put(ctx, obj, PDF_NAME(P), page->obj); + pdf_dict_put_int(ctx, obj, PDF_NAME(F), PDF_ANNOT_IS_PRINT); + pdf_dict_put_rect(ctx, obj, PDF_NAME(Rect), fz_transform_rect(rect, ely_to_user(ctx, page))); + pdf_dict_put_text_string(ctx, obj, PDF_NAME(DA), "/Helv 0 Tf 0 g"); + mk = pdf_dict_put_dict(ctx, obj, PDF_NAME(MK), 1); + pdf_array_push_real(ctx, pdf_dict_put_array(ctx, mk, PDF_NAME(BC), 1), 0); + if (!strcmp(kind, "radio")) + { + if (!group) + { + group = pdf_add_new_dict(ctx, pdf, 4); + pdf_dict_put(ctx, group, PDF_NAME(FT), PDF_NAME(Btn)); + pdf_dict_put_int(ctx, group, PDF_NAME(Ff), PDF_BTN_FIELD_IS_RADIO | PDF_BTN_FIELD_IS_NO_TOGGLE_TO_OFF); + pdf_dict_put_text_string(ctx, group, PDF_NAME(T), name); + pdf_dict_put(ctx, group, PDF_NAME(V), PDF_NAME(Off)); + pdf_dict_put_array(ctx, group, PDF_NAME(Kids), 2); + pdf_array_push_drop(ctx, fields, group); + } + state = new_state(ctx, group); + pdf_dict_put(ctx, obj, PDF_NAME(Parent), group); + pdf_array_push(ctx, pdf_dict_get(ctx, group, PDF_NAME(Kids)), obj); + } + else + { + pdf_dict_put_text_string(ctx, obj, PDF_NAME(T), name); + pdf_array_push(ctx, fields, obj); + } + if (!strcmp(kind, "text")) + pdf_dict_put(ctx, obj, PDF_NAME(FT), PDF_NAME(Tx)); + else if (!strcmp(kind, "choice")) + { + pdf_dict_put(ctx, obj, PDF_NAME(FT), PDF_NAME(Ch)); + pdf_dict_put_int(ctx, obj, PDF_NAME(Ff), PDF_CH_FIELD_IS_COMBO); + put_options(ctx, obj, options); + } + else if (!strcmp(kind, "checkbox")) + { + pdf_dict_put(ctx, obj, PDF_NAME(FT), PDF_NAME(Btn)); + pdf_dict_put(ctx, obj, PDF_NAME(V), PDF_NAME(Off)); + state = pdf_new_name(ctx, "Yes"); + } + /* The on look is drawn under its own state. */ + if (state) + pdf_dict_put(ctx, obj, PDF_NAME(AS), state); + pdf_annot_request_resynthesis(ctx, widget); + pdf_update_annot(ctx, widget); + if (state) + pdf_dict_put(ctx, obj, PDF_NAME(AS), PDF_NAME(Off)); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + fz_drop_pool(ctx, pool); + pdf_drop_obj(ctx, state); + pdf_drop_annot(ctx, widget); + fz_drop_page(ctx, (fz_page *)page); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* Renames a field or sets its options. */ +int ely_change_field(fz_context *ctx, fz_document *doc, int number, int id, const char *json) +{ + pdf_document *pdf = NULL; + pdf_page *page = NULL; + fz_pool *pool = NULL; + char *path = NULL; + int begun = 0; + fz_var(pdf); + fz_var(page); + fz_var(pool); + fz_var(path); + fz_var(begun); + fz_try(ctx) + { + pdf_annot *widget; + pdf_obj *obj, *head; + fz_json *spec, *name, *options; + enum pdf_widget_type type; + pdf = ely_pdf_of(ctx, doc); + page = pdf_load_page(ctx, pdf, number); + widget = ely_find_annot(ctx, page, id, 1); + obj = pdf_annot_obj(ctx, widget); + head = head_of(ctx, obj); + type = pdf_widget_type(ctx, widget); + pool = fz_new_pool(ctx); + spec = fz_parse_json(ctx, pool, json); + name = ely_member(ctx, spec, "name", FZ_JSON_STRING); + options = ely_member(ctx, spec, "options", FZ_JSON_ARRAY); + if (!name && !options) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no change"); + if (name) + { + path = parent_path(ctx, head); + check_name(ctx, pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields"), path, name->u.string, "", head); + } + if (options && type != PDF_WIDGET_TYPE_COMBOBOX && type != PDF_WIDGET_TYPE_LISTBOX) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "only a choice has options"); + if (options) + check_options(ctx, options); + pdf_begin_operation(ctx, pdf, "Change a field"); + begun = 1; + if (name) + pdf_dict_put_text_string(ctx, head, PDF_NAME(T), name->u.string); + if (options) + { + int i, kept = 0; + const char *value = pdf_to_text_string(ctx, pdf_dict_get_inheritable(ctx, obj, PDF_NAME(V))); + for (i = 0; i < fz_json_array_length(ctx, options); i++) + kept |= !strcmp(fz_json_array_get(ctx, options, i)->u.string, value); + if (obj != head) + pdf_dict_del(ctx, obj, PDF_NAME(Opt)); + put_options(ctx, head, options); + if (!kept) + pdf_dict_del(ctx, head, PDF_NAME(V)); + } + pdf_annot_request_resynthesis(ctx, widget); + pdf_update_annot(ctx, widget); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + fz_drop_pool(ctx, pool); + fz_free(ctx, path); + fz_drop_page(ctx, (fz_page *)page); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* Writes each named node under `kids`, with its full name. */ +static void write_names(fz_context *ctx, fz_output *out, pdf_obj *kids, const char *prefix, int depth, int *first) +{ + int i; + if (depth > 32) + fz_throw(ctx, FZ_ERROR_FORMAT, "a field tree too deep"); + for (i = 0; i < pdf_array_len(ctx, kids); i++) + { + pdf_obj *node = pdf_array_get(ctx, kids, i); + pdf_obj *t = pdf_dict_get(ctx, node, PDF_NAME(T)); + char *name = NULL; + fz_var(name); + fz_try(ctx) + { + name = t ? fz_asprintf(ctx, "%s%s%s", prefix, prefix[0] ? "." : "", pdf_to_text_string(ctx, t)) : fz_strdup(ctx, prefix); + if (t) + { + fz_write_string(ctx, out, *first ? "" : ","); + ely_json_string(ctx, out, name); + *first = 0; + } + write_names(ctx, out, pdf_dict_get(ctx, node, PDF_NAME(Kids)), name, depth + 1, first); + } + fz_always(ctx) + fz_free(ctx, name); + fz_catch(ctx) + fz_rethrow(ctx); + } +} + +/* Every field name in the form, widgets or not. */ +int ely_field_names(fz_context *ctx, fz_document *doc, unsigned char **data, size_t *len) +{ + fz_buffer *buf = NULL; + fz_output *out = NULL; + fz_var(buf); + fz_var(out); + fz_try(ctx) + { + int first = 1; + pdf_document *pdf = ely_pdf_of(ctx, doc); + buf = fz_new_buffer(ctx, 256); + out = fz_new_output_with_buffer(ctx, buf); + fz_write_byte(ctx, out, '['); + write_names(ctx, out, pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields"), "", 0, &first); + fz_write_byte(ctx, out, ']'); + fz_close_output(ctx, out); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_drop_output(ctx, out); + fz_drop_buffer(ctx, buf); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} diff --git a/crates/ely-pdf-engine/shim/ely_insert.c b/crates/ely-pdf-engine/shim/ely_insert.c new file mode 100644 index 0000000..77dc1e9 --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_insert.c @@ -0,0 +1,364 @@ +/* Pages from another PDF, with their links, notes and fields. */ + +#include +#include + +#include "ely_shim.h" + +/* A source link to its own page `page`. */ +typedef struct +{ + pdf_obj *annot; + pdf_obj *rest; + int page; +} inner_link; + +/* Takes page references off the source's annotations, links noted. */ +static int unhook(fz_context *ctx, pdf_document *src, inner_link *links, int room) +{ + int i, j, n = 0, count = pdf_count_pages(ctx, src); + for (i = 0; i < count; i++) + { + pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, src, i), PDF_NAME(Annots)); + for (j = 0; j < pdf_array_len(ctx, annots); j++) + { + pdf_obj *annot = pdf_array_get(ctx, annots, j); + pdf_obj *page = ely_target(ctx, src, annot); + int to = page ? pdf_lookup_page_number(ctx, src, page) : -1; + pdf_dict_del(ctx, annot, PDF_NAME(P)); + if (to < 0 || n == room) + continue; + /* The view after the page: /XYZ, /Fit and so on. */ + pdf_obj *dest = pdf_dict_get(ctx, annot, PDF_NAME(Dest)); + if (!dest) + dest = pdf_dict_getp(ctx, annot, "A/D"); + if (pdf_is_name(ctx, dest) || pdf_is_string(ctx, dest)) + dest = pdf_lookup_dest(ctx, src, dest); + if (pdf_is_dict(ctx, dest)) + dest = pdf_dict_get(ctx, dest, PDF_NAME(D)); + links[n].annot = pdf_keep_obj(ctx, annot); + links[n].rest = pdf_copy_array(ctx, dest); + pdf_array_delete(ctx, links[n].rest, 0); + links[n].page = to; + n++; + pdf_dict_del(ctx, annot, PDF_NAME(Dest)); + pdf_dict_del(ctx, annot, PDF_NAME(A)); + } + } + return n; +} + +/* The top of the field tree `widget` belongs to. */ +static pdf_obj *root_field(fz_context *ctx, pdf_obj *widget) +{ + pdf_obj *parent; + while ((parent = pdf_dict_get(ctx, widget, PDF_NAME(Parent))) != NULL) + widget = parent; + return widget; +} + +/* The source form's default look and its font renames. */ +typedef struct +{ + pdf_obj *da; + pdf_obj *q; + pdf_obj *renames; +} form_look; + +static int hex(int c) +{ + return c >= '0' && c <= '9' ? c - '0' : (c | 32) >= 'a' && (c | 32) <= 'f' ? (c | 32) - 'a' + 10 : -1; +} + +/* A name token's bytes, #xx escapes decoded. */ +static void decode_name(const char *token, size_t len, char *out) +{ + size_t i, k = 0; + for (i = 0; i < len; i++) + { + if (token[i] == '#' && i + 2 < len && hex(token[i + 1]) >= 0 && hex(token[i + 2]) >= 0) + { + out[k++] = (char)(hex(token[i + 1]) * 16 + hex(token[i + 2])); + i += 2; + } + else + out[k++] = token[i]; + } + out[k] = 0; +} + +/* Writes `name` as a name token, odd bytes escaped. */ +static void encode_name(fz_context *ctx, fz_buffer *buf, const char *name) +{ + const unsigned char *c; + fz_append_byte(ctx, buf, '/'); + for (c = (const unsigned char *)name; *c; c++) + if (*c < 0x21 || *c > 0x7e || strchr("#/[]()<>{}%", *c)) + fz_append_printf(ctx, buf, "#%02X", *c); + else + fz_append_byte(ctx, buf, *c); +} + +/* `name`, or `name` and `n` when n is above one. */ +static char *candidate(fz_context *ctx, const char *name, const char *gap, int n) +{ + return n == 1 ? fz_strdup(ctx, name) : fz_asprintf(ctx, "%s%s%d", name, gap, n); +} + +/* Merges the source's form fonts; taken names get numbered. */ +static void merge_fonts(fz_context *ctx, pdf_document *pdf, pdf_graft_map *map, pdf_document *src, form_look *look) +{ + pdf_obj *from = pdf_dict_getp(ctx, pdf_trailer(ctx, src), "Root/AcroForm/DR/Font"); + pdf_obj *to, *font = NULL; + char *fresh = NULL; + int i, n; + fz_var(font); + fz_var(fresh); + if (!from) + return; + to = pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/DR/Font"); + if (!to) + { + to = pdf_new_dict(ctx, pdf, 4); + pdf_dict_putp_drop(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/DR/Font", to); + } + fz_try(ctx) + for (i = 0; i < pdf_dict_len(ctx, from); i++) + { + const char *name = pdf_to_name(ctx, pdf_dict_get_key(ctx, from, i)); + font = pdf_graft_mapped_object(ctx, map, pdf_dict_get_val(ctx, from, i)); + for (n = 1;; n++) + { + pdf_obj *held; + fz_free(ctx, fresh); + fresh = candidate(ctx, name, "_", n); + held = pdf_dict_gets(ctx, to, fresh); + if (!held) + pdf_dict_puts(ctx, to, fresh, font); + if (!held || (pdf_is_indirect(ctx, held) && pdf_to_num(ctx, held) == pdf_to_num(ctx, font))) + break; + } + if (n > 1) + pdf_dict_puts_drop(ctx, look->renames, name, pdf_new_name(ctx, fresh)); + pdf_drop_obj(ctx, font); + font = NULL; + } + fz_always(ctx) + { + pdf_drop_obj(ctx, font); + fz_free(ctx, fresh); + } + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* `node`'s /DA with renamed fonts renamed. */ +static void rename_fonts(fz_context *ctx, pdf_obj *node, pdf_obj *renames) +{ + const char *da = pdf_dict_get_text_string(ctx, node, PDF_NAME(DA)); + fz_buffer *buf = NULL; + char *name = NULL; + fz_var(buf); + fz_var(name); + if (!*da || !pdf_dict_len(ctx, renames)) + return; + fz_try(ctx) + { + unsigned char *data; + size_t len; + buf = fz_new_buffer(ctx, 64); + while (*da) + { + size_t word = 1; + pdf_obj *to; + if (*da != '/') + { + fz_append_byte(ctx, buf, *da++); + continue; + } + while (da[word] && !strchr(" \t\r\n\f/[]()<>{}%", da[word])) + word++; + fz_free(ctx, name); + name = fz_malloc(ctx, word); + decode_name(da + 1, word - 1, name); + to = pdf_dict_gets(ctx, renames, name); + if (to) + encode_name(ctx, buf, pdf_to_name(ctx, to)); + else + fz_append_data(ctx, buf, da, word); + da += word; + } + len = fz_buffer_storage(ctx, buf, &data); + pdf_dict_put_string(ctx, node, PDF_NAME(DA), (const char *)data, len); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, buf); + fz_free(ctx, name); + } + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* Gives a field tree the source's look, fonts renamed. */ +static void restyle(fz_context *ctx, pdf_obj *node, form_look *look, int root) +{ + pdf_obj *kids = pdf_dict_get(ctx, node, PDF_NAME(Kids)); + int i; + /* The form's defaults a field inherits: look and alignment. */ + if (root && look->da && !pdf_dict_get(ctx, node, PDF_NAME(DA))) + pdf_dict_put(ctx, node, PDF_NAME(DA), look->da); + if (root && look->q && !pdf_dict_get(ctx, node, PDF_NAME(Q))) + pdf_dict_put(ctx, node, PDF_NAME(Q), look->q); + rename_fonts(ctx, node, look->renames); + for (i = 0; i < pdf_array_len(ctx, kids); i++) + restyle(ctx, pdf_array_get(ctx, kids, i), look, 0); +} + +/* Joins `field` to the form; a taken name gets numbered. */ +static void add_field(fz_context *ctx, pdf_document *pdf, pdf_obj *field, form_look *look) +{ + pdf_obj *fields = pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields"); + const char *name = pdf_dict_get_text_string(ctx, field, PDF_NAME(T)); + char *renamed = NULL; + int i, n, taken; + fz_var(renamed); + if (!fields) + { + fields = pdf_new_array(ctx, pdf, 1); + pdf_dict_putp_drop(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields", fields); + } + for (i = 0; i < pdf_array_len(ctx, fields); i++) + if (pdf_to_num(ctx, pdf_array_get(ctx, fields, i)) == pdf_to_num(ctx, field)) + return; + fz_try(ctx) + { + for (n = 1, taken = 1; taken; n++) + { + fz_free(ctx, renamed); + renamed = candidate(ctx, name, " ", n); + for (taken = 0, i = 0; i < pdf_array_len(ctx, fields) && !taken; i++) + taken = !strcmp(pdf_dict_get_text_string(ctx, pdf_array_get(ctx, fields, i), PDF_NAME(T)), renamed); + } + if (strcmp(renamed, name)) + pdf_dict_put_text_string(ctx, field, PDF_NAME(T), renamed); + restyle(ctx, field, look, 1); + pdf_array_push(ctx, fields, field); + } + fz_always(ctx) + fz_free(ctx, renamed); + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* Grafts each page's annotations and reconnects them. */ +static void reconnect(fz_context *ctx, pdf_document *pdf, pdf_graft_map *map, pdf_document *src, int at, form_look *look) +{ + int i, j, count = pdf_count_pages(ctx, src); + for (i = 0; i < count; i++) + { + pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, src, i), PDF_NAME(Annots)); + pdf_obj *page = pdf_lookup_page_obj(ctx, pdf, at + i); + pdf_obj *grafted; + if (!annots) + continue; + grafted = pdf_graft_mapped_object(ctx, map, annots); + pdf_dict_put_drop(ctx, page, PDF_NAME(Annots), grafted); + for (j = 0; j < pdf_array_len(ctx, grafted); j++) + { + pdf_obj *annot = pdf_array_get(ctx, grafted, j); + pdf_dict_put(ctx, annot, PDF_NAME(P), page); + if (pdf_name_eq(ctx, pdf_dict_get(ctx, annot, PDF_NAME(Subtype)), PDF_NAME(Widget))) + add_field(ctx, pdf, root_field(ctx, annot), look); + } + } +} + +/* Annotations and form look come over; inner links point home. */ +static void graft_annots(fz_context *ctx, pdf_document *pdf, pdf_graft_map *map, pdf_document *src, int at, + inner_link *links, int n) +{ + form_look look = { NULL, NULL, NULL }; + pdf_obj *da = pdf_dict_getp(ctx, pdf_trailer(ctx, src), "Root/AcroForm/DA"); + pdf_obj *q = pdf_dict_getp(ctx, pdf_trailer(ctx, src), "Root/AcroForm/Q"); + int i, j; + fz_var(look.da); + fz_var(look.renames); + fz_var(look.q); + fz_try(ctx) + { + look.renames = pdf_new_dict(ctx, pdf, 1); + look.da = da ? pdf_graft_mapped_object(ctx, map, da) : NULL; + look.q = q ? pdf_graft_mapped_object(ctx, map, q) : NULL; + merge_fonts(ctx, pdf, map, src, &look); + reconnect(ctx, pdf, map, src, at, &look); + } + fz_always(ctx) + { + pdf_drop_obj(ctx, look.renames); + pdf_drop_obj(ctx, look.da); + pdf_drop_obj(ctx, look.q); + } + fz_catch(ctx) + fz_rethrow(ctx); + for (i = 0; i < n; i++) + { + pdf_obj *annot = pdf_graft_mapped_object(ctx, map, links[i].annot); + pdf_obj *dest = pdf_new_array(ctx, pdf, 1 + pdf_array_len(ctx, links[i].rest)); + pdf_array_push(ctx, dest, pdf_lookup_page_obj(ctx, pdf, at + links[i].page)); + for (j = 0; j < pdf_array_len(ctx, links[i].rest); j++) + pdf_array_push(ctx, dest, pdf_array_get(ctx, links[i].rest, j)); + pdf_dict_put_drop(ctx, annot, PDF_NAME(Dest), dest); + pdf_drop_obj(ctx, annot); + } +} + +/* Every page of the PDF at `path`, inserted at `at`. */ +int ely_insert_file(fz_context *ctx, fz_document *doc, int at, const char *path, int *count) +{ + pdf_document *pdf = NULL, *src = NULL; + pdf_graft_map *map = NULL; + inner_link *links = NULL; + int begun = 0, i, n = 0, room = 0; + fz_var(pdf); + fz_var(src); + fz_var(map); + fz_var(links); + fz_var(n); + fz_var(begun); + fz_try(ctx) + { + pdf = ely_pdf_of(ctx, doc); + if (at < 0 || at > pdf_count_pages(ctx, pdf)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no place %d to insert at", at); + src = pdf_open_document(ctx, path); + if (pdf_needs_password(ctx, src)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the file to insert is locked"); + *count = pdf_count_pages(ctx, src); + for (i = 0; i < *count; i++) + room += pdf_array_len(ctx, pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, src, i), PDF_NAME(Annots))); + links = fz_calloc(ctx, room + 1, sizeof *links); + n = unhook(ctx, src, links, room); + map = pdf_new_graft_map(ctx, pdf); + pdf_begin_operation(ctx, pdf, "Insert pages"); + begun = 1; + for (i = 0; i < *count; i++) + pdf_graft_mapped_page(ctx, map, at + i, src, i); + graft_annots(ctx, pdf, map, src, at, links, n); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + for (i = 0; links && i < n; i++) + { + pdf_drop_obj(ctx, links[i].annot); + pdf_drop_obj(ctx, links[i].rest); + } + fz_free(ctx, links); + pdf_drop_graft_map(ctx, map); + pdf_drop_document(ctx, src); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} diff --git a/crates/ely-pdf-engine/shim/ely_meta.c b/crates/ely-pdf-engine/shim/ely_meta.c new file mode 100644 index 0000000..8637fc4 --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_meta.c @@ -0,0 +1,389 @@ +/* Document properties and attachments (PDF-07). */ + +#include +#include +#include + +#include "ely_shim.h" + +static const char *shown[] = { "Title", "Author", "Subject", "Keywords", "Creator", "Producer", "CreationDate", + "ModDate", NULL }; +/* Only these change; the rest the producer wrote. */ +static const char *editable[] = { "Title", "Author", "Subject", "Keywords", NULL }; + +/* Properties as JSON; null when the file has none. */ +int ely_metadata(fz_context *ctx, fz_document *doc, unsigned char **data, size_t *len) +{ + fz_buffer *buf = NULL; + fz_output *out = NULL; + char *value = NULL; + fz_var(buf); + fz_var(out); + fz_var(value); + fz_try(ctx) + { + char key[32]; + int i, n; + buf = fz_new_buffer(ctx, 256); + out = fz_new_output_with_buffer(ctx, buf); + fz_write_byte(ctx, out, '{'); + for (i = 0; shown[i]; i++) + { + fz_snprintf(key, sizeof key, "info:%s", shown[i]); + fz_write_printf(ctx, out, "%s\"%s\":", i ? "," : "", shown[i]); + n = fz_lookup_metadata(ctx, doc, key, NULL, 0); + if (n < 0) + { + fz_write_string(ctx, out, "null"); + continue; + } + fz_free(ctx, value); + value = NULL; + value = fz_malloc(ctx, n); + fz_lookup_metadata(ctx, doc, key, value, n); + ely_json_string(ctx, out, value); + } + fz_write_byte(ctx, out, '}'); + fz_close_output(ctx, out); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_free(ctx, value); + fz_drop_output(ctx, out); + fz_drop_buffer(ctx, buf); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* Sets the given properties, in one step. */ +int ely_set_metadata(fz_context *ctx, fz_document *doc, const char *json) +{ + pdf_document *pdf = NULL; + fz_pool *pool = NULL; + int begun = 0; + fz_var(pdf); + fz_var(pool); + fz_var(begun); + fz_try(ctx) + { + fz_json *spec; + fz_json_object *field; + char key[32]; + int i; + pdf = ely_pdf_of(ctx, doc); + pool = fz_new_pool(ctx); + spec = fz_parse_json(ctx, pool, json); + if (spec->type != FZ_JSON_OBJECT || !spec->u.object) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no properties to set"); + for (field = spec->u.object; field; field = field->next) + { + for (i = 0; editable[i] && strcmp(editable[i], field->key); i++) + continue; + if (!editable[i] || field->value->type != FZ_JSON_STRING) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a property that does not change: %s", field->key); + } + pdf_begin_operation(ctx, pdf, "Set properties"); + begun = 1; + for (field = spec->u.object; field; field = field->next) + { + fz_snprintf(key, sizeof key, "info:%s", field->key); + fz_set_metadata(ctx, doc, key, field->value->u.string); + } + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + fz_drop_pool(ctx, pool); + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +static int by_name(const void *a, const void *b) +{ + return strcmp(*(const char *const *)a, *(const char *const *)b); +} + +/* The embedded files, by name; NULL names end it. */ +static const char **names_of(fz_context *ctx, pdf_obj *tree, int *count) +{ + const char **names; + int i, n = pdf_dict_len(ctx, tree); + names = fz_malloc_array(ctx, n + 1, const char *); + for (i = 0; i < n; i++) + names[i] = pdf_to_name(ctx, pdf_dict_get_key(ctx, tree, i)); + names[n] = NULL; + qsort(names, n, sizeof *names, by_name); + *count = n; + return names; +} + +/* An embedded file's length: its /Size, else its bytes. */ +static int64_t file_size(fz_context *ctx, pdf_obj *fs) +{ + pdf_obj *ef = pdf_dict_get(ctx, fs, PDF_NAME(EF)); + pdf_obj *file = pdf_dict_get(ctx, ef, PDF_NAME(F)) ? pdf_dict_get(ctx, ef, PDF_NAME(F)) : pdf_dict_get(ctx, ef, PDF_NAME(UF)); + pdf_obj *size = pdf_dict_getl(ctx, file, PDF_NAME(Params), PDF_NAME(Size), NULL); + fz_buffer *bytes; + int64_t n; + if (pdf_is_int(ctx, size)) + return pdf_to_int64(ctx, size); + bytes = pdf_load_embedded_file_contents(ctx, fs); + n = (int64_t)fz_buffer_storage(ctx, bytes, NULL); + fz_drop_buffer(ctx, bytes); + return n; +} + +static void write_file(fz_context *ctx, fz_output *out, const char *key, const char *name, int page, pdf_obj *fs, int *first) +{ + pdf_filespec_params params; + pdf_get_filespec_params(ctx, fs, ¶ms); + fz_write_string(ctx, out, *first ? "{\"key\":" : ",{\"key\":"); + *first = 0; + ely_json_string(ctx, out, key); + fz_write_string(ctx, out, ",\"name\":"); + ely_json_string(ctx, out, name); + fz_write_printf(ctx, out, ",\"page\":%d,\"size\":%ld,\"created\":%ld,\"modified\":%ld,\"type\":", page, + (long)file_size(ctx, fs), (long)params.created, (long)params.modified); + ely_json_string(ctx, out, params.mimetype ? params.mimetype : ""); + fz_write_string(ctx, out, ",\"note\":"); + ely_json_string(ctx, out, pdf_dict_get_text_string(ctx, fs, PDF_NAME(Desc))); + fz_write_byte(ctx, out, '}'); +} + +/* The page's file annotations, as attachments. */ +static void write_page_files(fz_context *ctx, fz_output *out, pdf_document *pdf, int number, int *first) +{ + pdf_page *page = pdf_load_page(ctx, pdf, number); + char key[48]; + fz_try(ctx) + { + pdf_annot *annot; + for (annot = pdf_first_annot(ctx, page); annot; annot = pdf_next_annot(ctx, annot)) + { + pdf_obj *fs; + pdf_filespec_params params; + if (pdf_annot_type(ctx, annot) != PDF_ANNOT_FILE_ATTACHMENT) + continue; + fs = pdf_annot_filespec(ctx, annot); + if (!pdf_is_embedded_file(ctx, fs)) + continue; + pdf_get_filespec_params(ctx, fs, ¶ms); + fz_snprintf(key, sizeof key, "a:%d:%d", number, pdf_to_num(ctx, pdf_annot_obj(ctx, annot))); + write_file(ctx, out, key, params.filename ? params.filename : "", number, fs, first); + } + } + fz_always(ctx) + fz_drop_page(ctx, (fz_page *)page); + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* Every embedded file: the tree's by name, then pages'. */ +int ely_attachments(fz_context *ctx, fz_document *doc, unsigned char **data, size_t *len) +{ + pdf_obj *tree = NULL; + const char **names = NULL; + fz_buffer *buf = NULL; + fz_output *out = NULL; + char *key = NULL; + fz_var(tree); + fz_var(names); + fz_var(buf); + fz_var(out); + fz_var(key); + fz_try(ctx) + { + pdf_document *pdf = ely_pdf_of(ctx, doc); + int i, n, first = 1; + tree = pdf_load_name_tree(ctx, pdf, PDF_NAME(EmbeddedFiles)); + names = names_of(ctx, tree, &n); + buf = fz_new_buffer(ctx, 256); + out = fz_new_output_with_buffer(ctx, buf); + fz_write_byte(ctx, out, '['); + for (i = 0; i < n; i++) + { + pdf_obj *fs = pdf_dict_gets(ctx, tree, names[i]); + if (!pdf_is_embedded_file(ctx, fs)) + continue; + fz_free(ctx, key); + key = NULL; + key = fz_asprintf(ctx, "n:%s", names[i]); + write_file(ctx, out, key, names[i], -1, fs, &first); + } + for (i = 0; i < pdf_count_pages(ctx, pdf); i++) + write_page_files(ctx, out, pdf, i, &first); + fz_write_byte(ctx, out, ']'); + fz_close_output(ctx, out); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_free(ctx, key); + fz_drop_output(ctx, out); + fz_drop_buffer(ctx, buf); + fz_free(ctx, names); + pdf_drop_obj(ctx, tree); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* The file `key` names; a page annotation's loads its page. */ +static pdf_obj *file_of(fz_context *ctx, pdf_document *pdf, const char *key, pdf_obj *tree, pdf_page **page, pdf_annot **annot) +{ + pdf_obj *fs = NULL; + int number, id; + if (!strncmp(key, "n:", 2)) + fs = pdf_dict_gets(ctx, tree, key + 2); + else if (sscanf(key, "a:%d:%d", &number, &id) == 2 && number >= 0 && number < pdf_count_pages(ctx, pdf)) + { + *page = pdf_load_page(ctx, pdf, number); + *annot = ely_find_annot(ctx, *page, id, 0); + if (pdf_annot_type(ctx, *annot) == PDF_ANNOT_FILE_ATTACHMENT) + fs = pdf_annot_filespec(ctx, *annot); + } + if (!pdf_is_embedded_file(ctx, fs)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no attachment %s", key); + return fs; +} + +/* An embedded file's bytes. */ +int ely_attachment(fz_context *ctx, fz_document *doc, const char *key, unsigned char **data, size_t *len) +{ + pdf_obj *tree = NULL; + pdf_page *page = NULL; + fz_buffer *buf = NULL; + fz_var(tree); + fz_var(page); + fz_var(buf); + fz_try(ctx) + { + pdf_document *pdf = ely_pdf_of(ctx, doc); + pdf_annot *annot = NULL; + tree = pdf_load_name_tree(ctx, pdf, PDF_NAME(EmbeddedFiles)); + unsigned char *bytes; + buf = pdf_load_embedded_file_contents(ctx, file_of(ctx, pdf, key, tree, &page, &annot)); + /* A copy: the document may hold this buffer. */ + *len = fz_buffer_storage(ctx, buf, &bytes); + *data = fz_malloc(ctx, *len ? *len : 1); + memcpy(*data, bytes, *len); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, buf); + fz_drop_page(ctx, (fz_page *)page); + pdf_drop_obj(ctx, tree); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* Writes `tree` back flat, its names in order. */ +static void put_tree(fz_context *ctx, pdf_document *pdf, pdf_obj *tree) +{ + pdf_obj *root = pdf_dict_get(ctx, pdf_trailer(ctx, pdf), PDF_NAME(Root)); + pdf_obj *names = pdf_dict_get(ctx, root, PDF_NAME(Names)), *list; + const char **sorted = NULL; + int i, n; + fz_var(sorted); + if (!names) + names = pdf_dict_put_dict(ctx, root, PDF_NAME(Names), 1); + list = pdf_dict_put_array(ctx, pdf_dict_put_dict(ctx, names, PDF_NAME(EmbeddedFiles), 1), PDF_NAME(Names), 8); + fz_try(ctx) + { + sorted = names_of(ctx, tree, &n); + for (i = 0; i < n; i++) + { + pdf_array_push_text_string(ctx, list, sorted[i]); + pdf_array_push(ctx, list, pdf_dict_gets(ctx, tree, sorted[i])); + } + } + fz_always(ctx) + fz_free(ctx, sorted); + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* Embeds the file at `path` as `name`, in one step. */ +int ely_attach(fz_context *ctx, fz_document *doc, const char *name, const char *path) +{ + pdf_document *pdf = NULL; + pdf_obj *tree = NULL, *fs = NULL; + fz_buffer *contents = NULL; + int begun = 0; + fz_var(pdf); + fz_var(tree); + fz_var(fs); + fz_var(contents); + fz_var(begun); + fz_try(ctx) + { + pdf = ely_pdf_of(ctx, doc); + if (!name[0]) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "an attachment with no name"); + tree = pdf_load_name_tree(ctx, pdf, PDF_NAME(EmbeddedFiles)); + if (!tree) + tree = pdf_new_dict(ctx, pdf, 1); + if (pdf_dict_gets(ctx, tree, name)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "an attachment named %s exists", name); + contents = fz_read_file(ctx, path); + pdf_begin_operation(ctx, pdf, "Attach a file"); + begun = 1; + fs = pdf_add_embedded_file(ctx, pdf, name, NULL, contents, -1, -1, 1); + pdf_dict_puts(ctx, tree, name, fs); + put_tree(ctx, pdf, tree); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + pdf_drop_obj(ctx, fs); + fz_drop_buffer(ctx, contents); + pdf_drop_obj(ctx, tree); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* Removes the file `key`: a tree entry or its annotation. */ +int ely_detach(fz_context *ctx, fz_document *doc, const char *key) +{ + pdf_document *pdf = NULL; + pdf_obj *tree = NULL; + pdf_page *page = NULL; + int begun = 0; + fz_var(pdf); + fz_var(tree); + fz_var(page); + fz_var(begun); + fz_try(ctx) + { + pdf_annot *annot = NULL; + pdf = ely_pdf_of(ctx, doc); + tree = pdf_load_name_tree(ctx, pdf, PDF_NAME(EmbeddedFiles)); + file_of(ctx, pdf, key, tree, &page, &annot); + pdf_begin_operation(ctx, pdf, "Remove an attachment"); + begun = 1; + if (annot) + pdf_delete_annot(ctx, page, annot); + else + { + pdf_dict_dels(ctx, tree, key + 2); + put_tree(ctx, pdf, tree); + } + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + fz_drop_page(ctx, (fz_page *)page); + pdf_drop_obj(ctx, tree); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} diff --git a/crates/ely-pdf-engine/shim/ely_mupdf.c b/crates/ely-pdf-engine/shim/ely_mupdf.c new file mode 100644 index 0000000..cccbfa9 --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_mupdf.c @@ -0,0 +1,392 @@ +/* MuPDF behind plain return codes: fz_try never crosses into Rust. */ + +#include +#include +#include + +static char last_error[512]; + +/* Takes the caught error to Rust; MuPDF counts it handled. */ +int ely_fail(fz_context *ctx) +{ + int code; + fz_strlcpy(last_error, fz_convert_error(ctx, &code), sizeof last_error); + return -1; +} + +/* A second failure while handling the first. */ +void ely_fail_also(fz_context *ctx, const char *what) +{ + char also[256]; + int code; + fz_snprintf(also, sizeof also, "; %s: %s", what, fz_convert_error(ctx, &code)); + fz_strlcat(last_error, also, sizeof last_error); +} + +const char *ely_error(void) +{ + return last_error; +} + +fz_context *ely_init(void) +{ + fz_context *ctx = fz_new_context(NULL, NULL, FZ_STORE_DEFAULT); + if (!ctx) + { + fz_strlcpy(last_error, "no MuPDF context", sizeof last_error); + return NULL; + } + fz_try(ctx) + fz_register_document_handlers(ctx); + fz_catch(ctx) + { + ely_fail(ctx); + fz_drop_context(ctx); + return NULL; + } + return ctx; +} + +/* What MuPDF warned while opening, for repair reports. */ +static char warned[4096]; + +typedef struct +{ + fz_warning_cb *next; + void *user; +} passing; + +static void keep_warning(void *user, const char *message) +{ + passing *p = user; + if (strlen(warned) + strlen(message) + 2 < sizeof warned) + { + fz_strlcat(warned, message, sizeof warned); + fz_strlcat(warned, "\n", sizeof warned); + } + if (p->next) + p->next(p->user, message); +} + +static passing watching; +static int watched; + +/* Collects MuPDF's warnings until `ely_watch_end`. */ +void ely_watch_begin(fz_context *ctx) +{ + watching.next = fz_warning_callback(ctx, &watching.user); + warned[0] = 0; + watched = 1; + fz_set_warning_callback(ctx, keep_warning, &watching); +} + +/* 1 if MuPDF warned since `ely_watch_begin`. */ +int ely_watch_end(fz_context *ctx) +{ + if (!watched) + return 0; + fz_flush_warnings(ctx); + fz_set_warning_callback(ctx, watching.next, watching.user); + watched = 0; + return warned[0] != 0; +} + +int ely_open(fz_context *ctx, const char *path, fz_document **out) +{ + ely_watch_begin(ctx); + fz_try(ctx) + *out = fz_open_document(ctx, path); + fz_always(ctx) + ely_watch_end(ctx); + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* Reads every object and non-image stream; 1 if any warned. */ +int ely_scan(fz_context *ctx, fz_document *doc) +{ + pdf_document *pdf = pdf_document_from_fz_document(ctx, doc); + int i; + if (!pdf) + return 0; + ely_watch_begin(ctx); + for (i = 1; i < pdf_xref_len(ctx, pdf); i++) + { + pdf_obj *obj = NULL; + fz_buffer *bytes = NULL; + fz_var(obj); + fz_var(bytes); + fz_try(ctx) + { + obj = pdf_new_indirect(ctx, pdf, i, 0); + pdf_cache_object(ctx, pdf, i); + /* Pictures are checked as they draw, or before an overwrite. */ + if (pdf_is_stream(ctx, obj) && !pdf_name_eq(ctx, pdf_dict_get(ctx, obj, PDF_NAME(Subtype)), PDF_NAME(Image))) + bytes = pdf_load_stream_number(ctx, pdf, i); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, bytes); + pdf_drop_obj(ctx, obj); + } + fz_catch(ctx) + { + /* An unreadable object goes in the report. */ + fz_warn(ctx, "object %d: %s", i, fz_caught_message(ctx)); + fz_ignore_error(ctx); + } + } + return ely_watch_end(ctx); +} + +/* The warnings of the last watch. */ +const char *ely_open_warnings(void) +{ + return warned; +} + +void ely_close(fz_context *ctx, fz_document *doc) +{ + fz_drop_document(ctx, doc); +} + +/* 1 locked, 0 open, -1 an error. */ +int ely_needs_password(fz_context *ctx, fz_document *doc) +{ + int locked = 0; + fz_try(ctx) + locked = fz_needs_password(ctx, doc); + fz_catch(ctx) + return ely_fail(ctx); + return locked; +} + +/* Access bits: 4 owner; 0 refused, -1 an error. */ +int ely_authenticate(fz_context *ctx, fz_document *doc, const char *password) +{ + int access = 0; + fz_try(ctx) + access = fz_authenticate_password(ctx, doc, password); + fz_catch(ctx) + return ely_fail(ctx); + return access; +} + +int ely_count_pages(fz_context *ctx, fz_document *doc, int *out) +{ + fz_try(ctx) + *out = fz_count_pages(ctx, doc); + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +int ely_page_bounds(fz_context *ctx, fz_document *doc, int number, float out[4]) +{ + fz_page *page = NULL; + fz_var(page); + fz_try(ctx) + { + page = fz_load_page(ctx, doc, number); + fz_rect box = fz_bound_page(ctx, page); + out[0] = box.x0; out[1] = box.y0; out[2] = box.x1; out[3] = box.y1; + } + fz_always(ctx) + fz_drop_page(ctx, page); + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* The page turned, scaled and moved to the origin. */ +static fz_matrix placed(fz_rect box, float scale, int rotate) +{ + fz_matrix ctm = fz_concat(fz_rotate(rotate), fz_scale(scale, scale)); + fz_rect turned = fz_transform_rect(box, ctm); + return fz_concat(ctm, fz_translate(-turned.x0, -turned.y0)); +} + +/* Paints device pixels x, y, w, h as BGRA. */ +int ely_render(fz_context *ctx, fz_document *doc, int number, float scale, int rotate, + const int tile[4], int dark, unsigned char *bgra) +{ + fz_page *page = NULL; + fz_pixmap *pix = NULL; + fz_device *dev = NULL; + fz_var(page); + fz_var(pix); + fz_var(dev); + fz_try(ctx) + { + page = fz_load_page(ctx, doc, number); + fz_matrix ctm = placed(fz_bound_page(ctx, page), scale, rotate); + fz_irect area = fz_make_irect(tile[0], tile[1], tile[0] + tile[2], tile[1] + tile[3]); + pix = fz_new_pixmap_with_bbox(ctx, fz_device_bgr(ctx), area, NULL, 0); + fz_clear_pixmap_with_value(ctx, pix, 0xff); + dev = fz_new_draw_device(ctx, fz_identity, pix); + fz_run_page(ctx, page, dev, ctm, NULL); + fz_close_device(ctx, dev); + if (dark) + fz_invert_pixmap_luminance(ctx, pix); + int stride = (int)fz_pixmap_stride(ctx, pix); + unsigned char *rows = fz_pixmap_samples(ctx, pix); + for (int y = 0; y < tile[3]; y++) + for (int x = 0; x < tile[2]; x++) + { + const unsigned char *from = rows + y * stride + x * 3; + unsigned char *to = bgra + ((size_t)y * tile[2] + x) * 4; + to[0] = from[0]; to[1] = from[1]; to[2] = from[2]; to[3] = 0xff; + } + } + fz_always(ctx) + { + fz_drop_device(ctx, dev); + fz_drop_pixmap(ctx, pix); + fz_drop_page(ctx, page); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* One character: line, code point, box in page points. */ +typedef struct +{ + int line; + int c; + float x0, y0, x1, y1; +} ely_char; + +static void add_chars(fz_context *ctx, fz_buffer *buf, fz_stext_block *block, int *line) +{ + for (; block; block = block->next) + { + if (block->type == FZ_STEXT_BLOCK_STRUCT && block->u.s.down) + add_chars(ctx, buf, block->u.s.down->first_block, line); + if (block->type != FZ_STEXT_BLOCK_TEXT) + continue; + for (fz_stext_line *l = block->u.t.first_line; l; l = l->next, ++*line) + for (fz_stext_char *ch = l->first_char; ch; ch = ch->next) + { + fz_rect box = fz_rect_from_quad(ch->quad); + ely_char one = { *line, ch->c, box.x0, box.y0, box.x1, box.y1 }; + fz_append_data(ctx, buf, &one, sizeof one); + } + } +} + +/* The page's characters in reading order; free with ely_free. */ +int ely_text(fz_context *ctx, fz_document *doc, int number, unsigned char **data, size_t *len) +{ + fz_page *page = NULL; + fz_stext_page *text = NULL; + fz_buffer *buf = NULL; + fz_var(page); + fz_var(text); + fz_var(buf); + fz_try(ctx) + { + page = fz_load_page(ctx, doc, number); + text = fz_new_stext_page_from_page(ctx, page, NULL); + buf = fz_new_buffer(ctx, 4096); + int line = 0; + add_chars(ctx, buf, text->first_block, &line); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, buf); + fz_drop_stext_page(ctx, text); + fz_drop_page(ctx, page); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* Each hit: its quad count, then those quads. */ +static int add_hit(fz_context *ctx, void *opaque, int count, fz_quad *quads) +{ + fz_buffer *buf = opaque; + fz_append_data(ctx, buf, &count, sizeof count); + fz_append_data(ctx, buf, quads, sizeof *quads * count); + return 0; +} + +/* Every hit on a page; free with ely_free. */ +int ely_search(fz_context *ctx, fz_document *doc, int number, const char *needle, + unsigned char **data, size_t *len) +{ + fz_page *page = NULL; + fz_stext_page *text = NULL; + fz_buffer *buf = NULL; + fz_var(page); + fz_var(text); + fz_var(buf); + fz_try(ctx) + { + page = fz_load_page(ctx, doc, number); + text = fz_new_stext_page_from_page(ctx, page, NULL); + buf = fz_new_buffer(ctx, 256); + fz_search_stext_page_cb(ctx, text, needle, add_hit, buf); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, buf); + fz_drop_stext_page(ctx, text); + fz_drop_page(ctx, page); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +static void outline_json(fz_context *ctx, fz_output *out, fz_document *doc, fz_outline *item) +{ + fz_write_string(ctx, out, "["); + for (; item; item = item->next) + { + int page = item->page.page >= 0 ? fz_page_number_from_location(ctx, doc, item->page) : -1; + fz_write_printf(ctx, out, "{%q:%q,%q:%d,%q:%g,%q:", "title", item->title ? item->title : "", + "page", page, "y", item->y, "down"); + outline_json(ctx, out, doc, item->down); + fz_write_string(ctx, out, item->next ? "}," : "}"); + } + fz_write_string(ctx, out, "]"); +} + +/* Bookmarks as JSON: title, page or -1, y, down. */ +int ely_outline(fz_context *ctx, fz_document *doc, unsigned char **data, size_t *len) +{ + fz_outline *outline = NULL; + fz_buffer *buf = NULL; + fz_output *out = NULL; + fz_var(outline); + fz_var(buf); + fz_var(out); + fz_try(ctx) + { + outline = fz_load_outline(ctx, doc); + buf = fz_new_buffer(ctx, 1024); + out = fz_new_output_with_buffer(ctx, buf); + outline_json(ctx, out, doc, outline); + fz_close_output(ctx, out); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_drop_output(ctx, out); + fz_drop_buffer(ctx, buf); + fz_drop_outline(ctx, outline); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +void ely_free(fz_context *ctx, void *data) +{ + fz_free(ctx, data); +} diff --git a/crates/ely-pdf-engine/shim/ely_ocr.c b/crates/ely-pdf-engine/shim/ely_ocr.c new file mode 100644 index 0000000..83ba130 --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_ocr.c @@ -0,0 +1,284 @@ +/* OCR: recognise a page, add a hidden text layer (PDF-08). */ + +#include + +#include "ely_shim.h" + +static const char to_unicode[] = + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n" + "/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n" + "/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n" + "1 begincodespacerange\n<0000> \nendcodespacerange\n" + "1 beginbfrange\n<0000> <0000>\nendbfrange\n" + "endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"; + +/* Lines Tesseract reads on a page, page space, as JSON. */ +int ely_ocr_page(fz_context *ctx, fz_document *doc, int number, const char *lang, const char *datadir, float dpi, + unsigned char **data, size_t *len) +{ + fz_page *page = NULL; + fz_pixmap *pix = NULL; + fz_image *image = NULL; + fz_stext_page *text = NULL; + fz_device *sdev = NULL, *ocr = NULL; + fz_buffer *buf = NULL, *words = NULL; + fz_output *out = NULL; + fz_var(page); + fz_var(pix); + fz_var(image); + fz_var(text); + fz_var(sdev); + fz_var(ocr); + fz_var(buf); + fz_var(words); + fz_var(out); + fz_try(ctx) + { + float zoom = dpi / 72; + fz_rect area; + fz_stext_block *block; + fz_stext_line *line; + fz_stext_char *ch; + int first = 1; + if (dpi < 72 || dpi > 600) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a resolution of %g dpi", dpi); + page = fz_load_page(ctx, doc, number); + pix = fz_new_pixmap_from_page(ctx, page, fz_scale(zoom, zoom), fz_device_gray(ctx), 0); + image = fz_new_image_from_pixmap(ctx, pix, NULL); + area = fz_make_rect(0, 0, (float)pix->w, (float)pix->h); + text = fz_new_stext_page(ctx, area); + sdev = fz_new_stext_device(ctx, text, NULL); + ocr = fz_new_ocr_device(ctx, sdev, fz_identity, area, 0, lang, datadir, NULL, NULL); + fz_fill_image(ctx, ocr, image, fz_make_matrix(area.x1, 0, 0, area.y1, 0, 0), 1, fz_default_color_params); + fz_close_device(ctx, ocr); + fz_close_device(ctx, sdev); + buf = fz_new_buffer(ctx, 1024); + out = fz_new_output_with_buffer(ctx, buf); + fz_write_byte(ctx, out, '['); + for (block = text->first_block; block; block = block->next) + { + if (block->type != FZ_STEXT_BLOCK_TEXT) + continue; + for (line = block->u.t.first_line; line; line = line->next) + { + fz_rect r = fz_transform_rect(line->bbox, fz_scale(1 / zoom, 1 / zoom)); + if (!line->first_char) + continue; + fz_drop_buffer(ctx, words); + words = NULL; + words = fz_new_buffer(ctx, 128); + for (ch = line->first_char; ch; ch = ch->next) + fz_append_rune(ctx, words, ch->c); + fz_terminate_buffer(ctx, words); + fz_write_printf(ctx, out, "%s{\"rect\":[%g,%g,%g,%g],\"text\":", first ? "" : ",", r.x0, r.y0, r.x1, r.y1); + ely_json_string(ctx, out, fz_string_from_buffer(ctx, words)); + fz_write_byte(ctx, out, '}'); + first = 0; + } + } + fz_write_byte(ctx, out, ']'); + fz_close_output(ctx, out); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_drop_output(ctx, out); + fz_drop_buffer(ctx, buf); + fz_drop_buffer(ctx, words); + fz_drop_device(ctx, ocr); + fz_drop_device(ctx, sdev); + fz_drop_stext_page(ctx, text); + fz_drop_image(ctx, image); + fz_drop_pixmap(ctx, pix); + fz_drop_page(ctx, page); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* A font with no glyphs; codes are UTF-16 units. */ +static pdf_obj *glyphless(fz_context *ctx, pdf_document *pdf) +{ + pdf_obj *font = NULL, *cid = NULL, *desc = NULL, *info, *bbox, *kids; + fz_buffer *cmap = NULL; + fz_var(font); + fz_var(cid); + fz_var(desc); + fz_var(cmap); + fz_try(ctx) + { + desc = pdf_add_new_dict(ctx, pdf, 8); + pdf_dict_put(ctx, desc, PDF_NAME(Type), PDF_NAME(FontDescriptor)); + pdf_dict_put_name(ctx, desc, PDF_NAME(FontName), "GlyphLessFont"); + pdf_dict_put_int(ctx, desc, PDF_NAME(Flags), 5); + bbox = pdf_dict_put_array(ctx, desc, PDF_NAME(FontBBox), 4); + pdf_array_push_int(ctx, bbox, 0), pdf_array_push_int(ctx, bbox, 0); + pdf_array_push_int(ctx, bbox, 500), pdf_array_push_int(ctx, bbox, 1000); + pdf_dict_put_int(ctx, desc, PDF_NAME(ItalicAngle), 0); + pdf_dict_put_int(ctx, desc, PDF_NAME(Ascent), 1000); + pdf_dict_put_int(ctx, desc, PDF_NAME(Descent), 0); + pdf_dict_put_int(ctx, desc, PDF_NAME(CapHeight), 1000); + pdf_dict_put_int(ctx, desc, PDF_NAME(StemV), 80); + cid = pdf_add_new_dict(ctx, pdf, 8); + pdf_dict_put(ctx, cid, PDF_NAME(Type), PDF_NAME(Font)); + pdf_dict_put(ctx, cid, PDF_NAME(Subtype), PDF_NAME(CIDFontType2)); + pdf_dict_put_name(ctx, cid, PDF_NAME(BaseFont), "GlyphLessFont"); + info = pdf_dict_put_dict(ctx, cid, PDF_NAME(CIDSystemInfo), 3); + pdf_dict_put_text_string(ctx, info, PDF_NAME(Registry), "Adobe"); + pdf_dict_put_text_string(ctx, info, PDF_NAME(Ordering), "Identity"); + pdf_dict_put_int(ctx, info, PDF_NAME(Supplement), 0); + pdf_dict_put(ctx, cid, PDF_NAME(FontDescriptor), desc); + pdf_dict_put_int(ctx, cid, PDF_NAME(DW), 500); + pdf_dict_put(ctx, cid, PDF_NAME(CIDToGIDMap), PDF_NAME(Identity)); + font = pdf_add_new_dict(ctx, pdf, 6); + pdf_dict_put(ctx, font, PDF_NAME(Type), PDF_NAME(Font)); + pdf_dict_put(ctx, font, PDF_NAME(Subtype), PDF_NAME(Type0)); + pdf_dict_put_name(ctx, font, PDF_NAME(BaseFont), "GlyphLessFont"); + pdf_dict_put(ctx, font, PDF_NAME(Encoding), PDF_NAME(Identity_H)); + kids = pdf_dict_put_array(ctx, font, PDF_NAME(DescendantFonts), 1); + pdf_array_push(ctx, kids, cid); + cmap = fz_new_buffer_from_copied_data(ctx, (const unsigned char *)to_unicode, strlen(to_unicode)); + pdf_dict_put_drop(ctx, font, PDF_NAME(ToUnicode), pdf_add_stream(ctx, pdf, cmap, NULL, 0)); + } + fz_always(ctx) + { + pdf_drop_obj(ctx, desc); + pdf_drop_obj(ctx, cid); + fz_drop_buffer(ctx, cmap); + } + fz_catch(ctx) + { + pdf_drop_obj(ctx, font); + fz_rethrow(ctx); + } + return font; +} + +/* Drops the page's earlier Ely OCR layers. */ +static void drop_layers(fz_context *ctx, pdf_obj *page) +{ + pdf_obj *contents = pdf_dict_get(ctx, page, PDF_NAME(Contents)); + int i; + for (i = pdf_array_len(ctx, contents) - 1; i >= 0; i--) + if (pdf_dict_gets(ctx, pdf_array_get(ctx, contents, i), "ElyOCR")) + pdf_array_delete(ctx, contents, i); +} + +/* Throws unless every line is whole and in the BMP. */ +static void check_layer(fz_context *ctx, fz_json *pages, int count) +{ + int i, k; + for (i = 0; i < fz_json_array_length(ctx, pages); i++) + { + fz_json *spec = fz_json_array_get(ctx, pages, i); + fz_json *page = ely_member(ctx, spec, "page", FZ_JSON_NUMBER); + fz_json *lines = ely_member(ctx, spec, "lines", FZ_JSON_ARRAY); + if (!page || page->u.number < 0 || page->u.number >= count || !lines) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a layer with no page or lines"); + for (k = 0; k < fz_json_array_length(ctx, lines); k++) + { + fz_json *line = fz_json_array_get(ctx, lines, k); + fz_json *words = ely_member(ctx, line, "text", FZ_JSON_STRING); + const char *s; + int c; + ely_rect(ctx, ely_member(ctx, line, "rect", FZ_JSON_ARRAY), 0.5f); + if (!words || !words->u.string[0]) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a line with no text"); + for (s = words->u.string; *s; ) + { + s += fz_chartorune(&c, s); + if (c > 0xFFFF || c == FZ_REPLACEMENT_CHARACTER) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a character the layer cannot hold: U+%X", c); + } + } + } +} + +/* One line: hidden text stretched over its box. */ +static void write_line(fz_context *ctx, fz_buffer *buf, fz_json *line, fz_matrix to_user, const char *font) +{ + fz_rect r = ely_rect(ctx, ely_member(ctx, line, "rect", FZ_JSON_ARRAY), 0.5f); + const char *s = ely_member(ctx, line, "text", FZ_JSON_STRING)->u.string, *p; + float size = r.y1 - r.y0; + int n = 0, c; + fz_matrix m; + for (p = s; *p; n++) + p += fz_chartorune(&c, p); + /* Each code is half an em wide. */ + m = fz_concat(fz_make_matrix((r.x1 - r.x0) / (0.5f * n), 0, 0, -size, r.x0, r.y1 - 0.2f * size), to_user); + fz_append_printf(ctx, buf, "BT 3 Tr /%s 1 Tf %g %g %g %g %g %g Tm <", font, m.a, m.b, m.c, m.d, m.e, m.f); + for (p = s; *p; ) + { + p += fz_chartorune(&c, p); + fz_append_printf(ctx, buf, "%04x", c); + } + fz_append_string(ctx, buf, "> Tj ET\n"); +} + +/* Hidden text on pages, earlier layers first dropped if asked. */ +int ely_text_layer(fz_context *ctx, fz_document *doc, const char *json, int replace) +{ + pdf_document *pdf = NULL; + pdf_page *page = NULL; + pdf_obj *font = NULL; + fz_buffer *buf = NULL; + fz_pool *pool = NULL; + int begun = 0; + fz_var(pdf); + fz_var(page); + fz_var(font); + fz_var(buf); + fz_var(pool); + fz_var(begun); + fz_try(ctx) + { + fz_json *pages; + int i, k; + pdf = ely_pdf_of(ctx, doc); + pool = fz_new_pool(ctx); + pages = fz_parse_json(ctx, pool, json); + if (pages->type != FZ_JSON_ARRAY || fz_json_array_length(ctx, pages) == 0) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no pages to lay text on"); + check_layer(ctx, pages, pdf_count_pages(ctx, pdf)); + pdf_begin_operation(ctx, pdf, "Add recognised text"); + begun = 1; + for (i = 0; i < fz_json_array_length(ctx, pages); i++) + { + fz_json *spec = fz_json_array_get(ctx, pages, i); + fz_json *lines = ely_member(ctx, spec, "lines", FZ_JSON_ARRAY); + char name[32]; + page = pdf_load_page(ctx, pdf, (int)ely_member(ctx, spec, "page", FZ_JSON_NUMBER)->u.number); + if (replace) + drop_layers(ctx, page->obj); + if (fz_json_array_length(ctx, lines) > 0) + { + if (!font) + font = glyphless(ctx, pdf); + ely_add_resource(ctx, pdf, page->obj, PDF_NAME(Font), "ElyOCR", font, name, sizeof name); + buf = fz_new_buffer(ctx, 1024); + /* Its own state, so later text stays seen. */ + fz_append_string(ctx, buf, "q\n"); + for (k = 0; k < fz_json_array_length(ctx, lines); k++) + write_line(ctx, buf, fz_json_array_get(ctx, lines, k), ely_to_user(ctx, page), name); + fz_append_string(ctx, buf, "Q\n"); + pdf_dict_puts(ctx, ely_append_content(ctx, pdf, page->obj, buf), "ElyOCR", PDF_TRUE); + fz_drop_buffer(ctx, buf); + buf = NULL; + } + fz_drop_page(ctx, (fz_page *)page); + page = NULL; + } + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, buf); + pdf_drop_obj(ctx, font); + fz_drop_page(ctx, (fz_page *)page); + fz_drop_pool(ctx, pool); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} diff --git a/crates/ely-pdf-engine/shim/ely_outline.c b/crates/ely-pdf-engine/shim/ely_outline.c new file mode 100644 index 0000000..ef22a3a --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_outline.c @@ -0,0 +1,237 @@ +/* Bookmarks and links: change, list (PDF-07). */ + +#include + +#include "ely_shim.h" + +/* A page point as a link target. */ +static char *target(fz_context *ctx, fz_document *doc, fz_json *spec) +{ + fz_json *page = ely_member(ctx, spec, "page", FZ_JSON_NUMBER); + fz_json *y = ely_member(ctx, spec, "y", FZ_JSON_NUMBER); + if (!page || page->u.number < 0 || page->u.number >= fz_count_pages(ctx, doc)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a target with no page"); + return fz_format_link_uri(ctx, doc, fz_make_link_dest_xyz(0, (int)page->u.number, 0, y ? (float)y->u.number : 0, 0)); +} + +/* Walks `path`; the last step may land past the end. */ +static int walk(fz_context *ctx, fz_outline_iterator *iter, fz_json *path) +{ + int i, k, n = fz_json_array_length(ctx, path), at = FZ_OUTLINE_ITERATOR_AT_ITEM; + if (n == 0) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "an empty bookmark path"); + for (i = 0; i < n; i++) + { + int steps = (int)ely_number(ctx, path, i); + if (i > 0) + { + at = fz_outline_iterator_down(ctx, iter); + if (at < 0) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no bookmark at that path"); + } + at = fz_outline_iterator_item(ctx, iter) ? FZ_OUTLINE_ITERATOR_AT_ITEM : FZ_OUTLINE_ITERATOR_AT_EMPTY; + for (k = 0; k < steps; k++) + if (at != FZ_OUTLINE_ITERATOR_AT_ITEM || (at = fz_outline_iterator_next(ctx, iter)) < 0) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no bookmark at that path"); + if (i < n - 1 && at != FZ_OUTLINE_ITERATOR_AT_ITEM) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no bookmark at that path"); + } + return at; +} + +/* Adds, renames, retargets or deletes one bookmark, in one step. */ +int ely_outline_edit(fz_context *ctx, fz_document *doc, const char *json) +{ + pdf_document *pdf = NULL; + fz_outline_iterator *iter = NULL; + fz_pool *pool = NULL; + char *uri = NULL; + int begun = 0; + fz_var(pdf); + fz_var(iter); + fz_var(pool); + fz_var(uri); + fz_var(begun); + fz_try(ctx) + { + fz_json *spec, *op, *path, *title, *expect; + fz_outline_item *now, item = { 0 }; + int at; + pdf = ely_pdf_of(ctx, doc); + pool = fz_new_pool(ctx); + spec = fz_parse_json(ctx, pool, json); + op = ely_member(ctx, spec, "op", FZ_JSON_STRING); + path = ely_member(ctx, spec, "path", FZ_JSON_ARRAY); + title = ely_member(ctx, spec, "title", FZ_JSON_STRING); + expect = ely_member(ctx, spec, "expect", FZ_JSON_STRING); + if (!op || !path || (strcmp(op->u.string, "add") && strcmp(op->u.string, "edit") && strcmp(op->u.string, "delete"))) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad bookmark change"); + if (title && !title->u.string[0]) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bookmark with no title"); + if (!strcmp(op->u.string, "add") && !title) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a new bookmark with no title"); + if (strcmp(op->u.string, "add") && !expect) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a change that does not name its bookmark"); + if (ely_member(ctx, spec, "page", FZ_JSON_NUMBER)) + uri = target(ctx, doc, spec); + else if (!strcmp(op->u.string, "add")) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a new bookmark with no target"); + pdf_begin_operation(ctx, pdf, "Change bookmarks"); + begun = 1; + iter = fz_new_outline_iterator(ctx, doc); + at = walk(ctx, iter, path); + if (!strcmp(op->u.string, "add")) + { + item.title = (char *)title->u.string; + item.uri = uri; + fz_outline_iterator_insert(ctx, iter, &item); + } + else + { + now = at == FZ_OUTLINE_ITERATOR_AT_ITEM ? fz_outline_iterator_item(ctx, iter) : NULL; + if (!now || strcmp(now->title ? now->title : "", expect->u.string)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the bookmark changed meanwhile"); + if (!strcmp(op->u.string, "delete")) + fz_outline_iterator_delete(ctx, iter); + else + { + item = *now; + if (title) + item.title = (char *)title->u.string; + if (uri) + item.uri = uri; + fz_outline_iterator_update(ctx, iter, &item); + } + } + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + fz_free(ctx, uri); + fz_drop_outline_iterator(ctx, iter); + fz_drop_pool(ctx, pool); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* The page's links: box, address, and page if inside. */ +int ely_links(fz_context *ctx, fz_document *doc, int number, unsigned char **data, size_t *len) +{ + fz_page *page = NULL; + fz_link *links = NULL, *link; + fz_buffer *buf = NULL; + fz_output *out = NULL; + fz_var(page); + fz_var(links); + fz_var(buf); + fz_var(out); + fz_try(ctx) + { + page = fz_load_page(ctx, doc, number); + links = fz_load_links(ctx, page); + buf = fz_new_buffer(ctx, 256); + out = fz_new_output_with_buffer(ctx, buf); + fz_write_byte(ctx, out, '['); + for (link = links; link; link = link->next) + { + int to = -1; + float x = 0, y = 0; + if (!fz_is_external_link(ctx, link->uri)) + to = fz_page_number_from_location(ctx, doc, fz_resolve_link(ctx, doc, link->uri, &x, &y)); + fz_write_printf(ctx, out, "%s{\"rect\":[%g,%g,%g,%g],\"uri\":", link == links ? "" : ",", + link->rect.x0, link->rect.y0, link->rect.x1, link->rect.y1); + ely_json_string(ctx, out, link->uri ? link->uri : ""); + fz_write_printf(ctx, out, ",\"page\":%d,\"y\":%g}", to, y); + } + fz_write_byte(ctx, out, ']'); + fz_close_output(ctx, out); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_drop_output(ctx, out); + fz_drop_buffer(ctx, buf); + fz_drop_link(ctx, links); + fz_drop_page(ctx, page); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* Only web and mail addresses leave the document. */ +static int outward(const char *uri) +{ + return !strncmp(uri, "https://", 8) || !strncmp(uri, "http://", 7) || !strncmp(uri, "mailto:", 7); +} + +/* Adds, retargets or deletes one link, in one step. */ +int ely_link_edit(fz_context *ctx, fz_document *doc, int number, const char *json) +{ + pdf_document *pdf = NULL; + pdf_page *page = NULL; + fz_pool *pool = NULL; + char *uri = NULL; + int begun = 0; + fz_var(pdf); + fz_var(page); + fz_var(pool); + fz_var(uri); + fz_var(begun); + fz_try(ctx) + { + fz_json *spec, *op, *index, *expect, *web; + fz_link *link = NULL; + fz_rect box = fz_empty_rect; + int i; + pdf = ely_pdf_of(ctx, doc); + page = pdf_load_page(ctx, pdf, number); + pool = fz_new_pool(ctx); + spec = fz_parse_json(ctx, pool, json); + op = ely_member(ctx, spec, "op", FZ_JSON_STRING); + index = ely_member(ctx, spec, "index", FZ_JSON_NUMBER); + expect = ely_member(ctx, spec, "expect", FZ_JSON_STRING); + web = ely_member(ctx, spec, "uri", FZ_JSON_STRING); + if (!op || (strcmp(op->u.string, "add") && strcmp(op->u.string, "edit") && strcmp(op->u.string, "delete"))) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad link change"); + if (web && !outward(web->u.string)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "only web and mail addresses: %s", web->u.string); + if (web) + uri = fz_strdup(ctx, web->u.string); + else if (ely_member(ctx, spec, "page", FZ_JSON_NUMBER)) + uri = target(ctx, doc, spec); + else if (strcmp(op->u.string, "delete")) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a link with no target"); + if (strcmp(op->u.string, "add")) + { + if (!index || !expect) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a change that does not name its link"); + for (link = page->links, i = 0; link && i < (int)index->u.number; link = link->next) + i++; + if (!link || strcmp(link->uri ? link->uri : "", expect->u.string)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the link changed meanwhile"); + } + if (!strcmp(op->u.string, "add")) + box = ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 2); + pdf_begin_operation(ctx, pdf, "Change links"); + begun = 1; + if (!strcmp(op->u.string, "add")) + fz_drop_link(ctx, pdf_create_link(ctx, page, box, uri)); + else if (!strcmp(op->u.string, "delete")) + pdf_delete_link(ctx, page, link); + else + fz_set_link_uri(ctx, link, uri); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + fz_free(ctx, uri); + fz_drop_pool(ctx, pool); + fz_drop_page(ctx, (fz_page *)page); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} diff --git a/crates/ely-pdf-engine/shim/ely_output.c b/crates/ely-pdf-engine/shim/ely_output.c new file mode 100644 index 0000000..aa51fdb --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_output.c @@ -0,0 +1,189 @@ +/* Page images, text, optimized copies, repaired copies (PDF-13, PDF-14). */ + +#include + +#include "ely_shim.h" + +/* Page `number` as a PNG at `dpi`, white, as printed. */ +int ely_export_image(fz_context *ctx, fz_document *doc, int number, float dpi, const char *path) +{ + fz_pixmap *pixmap = NULL; + fz_var(pixmap); + fz_try(ctx) + { + if (dpi < 18 || dpi > 1200) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a resolution outside 18 to 1200 dpi"); + ely_watch_begin(ctx); + pixmap = fz_new_pixmap_from_page_number(ctx, doc, number, fz_scale(dpi / 72, dpi / 72), fz_device_rgb(ctx), 0); + if (ely_watch_end(ctx)) + fz_throw(ctx, FZ_ERROR_FORMAT, "page %d did not read whole: %s", number + 1, ely_open_warnings()); + fz_save_pixmap_as_png(ctx, pixmap, path); + } + fz_always(ctx) + { + ely_watch_end(ctx); + fz_drop_pixmap(ctx, pixmap); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* Every page's text, UTF-8, pages parted by form feeds. */ +int ely_export_text(fz_context *ctx, fz_document *doc, const char *path) +{ + fz_output *out = NULL; + fz_stext_page *text = NULL; + fz_buffer *buf = NULL; + fz_var(out); + fz_var(text); + fz_var(buf); + fz_try(ctx) + { + int i; + ely_watch_begin(ctx); + out = fz_new_output_with_path(ctx, path, 0); + for (i = 0; i < fz_count_pages(ctx, doc); i++) + { + if (i > 0) + fz_write_byte(ctx, out, '\f'); + text = fz_new_stext_page_from_page_number(ctx, doc, i, NULL); + buf = fz_new_buffer_from_stext_page(ctx, text); + fz_write_buffer(ctx, out, buf); + fz_drop_buffer(ctx, buf); + buf = NULL; + fz_drop_stext_page(ctx, text); + text = NULL; + } + /* Partial text is no export. */ + if (ely_watch_end(ctx)) + fz_throw(ctx, FZ_ERROR_FORMAT, "the text did not read whole: %s", ely_open_warnings()); + fz_close_output(ctx, out); + } + fz_always(ctx) + { + ely_watch_end(ctx); + fz_drop_buffer(ctx, buf); + fz_drop_stext_page(ctx, text); + fz_drop_output(ctx, out); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +typedef struct +{ + int from, to; +} downsample; + +static void shrink_images(fz_context *ctx, fz_document *copy, void *arg) +{ + downsample *d = arg; + pdf_image_rewriter_options opts = { 0 }; + if (!d->to) + return; + opts.color_lossless_image_subsample_method = opts.color_lossy_image_subsample_method = FZ_SUBSAMPLE_BICUBIC; + opts.gray_lossless_image_subsample_method = opts.gray_lossy_image_subsample_method = FZ_SUBSAMPLE_BICUBIC; + opts.bitonal_image_subsample_method = FZ_SUBSAMPLE_BICUBIC; + opts.color_lossless_image_subsample_threshold = opts.color_lossy_image_subsample_threshold = d->from; + opts.gray_lossless_image_subsample_threshold = opts.gray_lossy_image_subsample_threshold = d->from; + opts.bitonal_image_subsample_threshold = d->from; + opts.color_lossless_image_subsample_to = opts.color_lossy_image_subsample_to = d->to; + opts.gray_lossless_image_subsample_to = opts.gray_lossy_image_subsample_to = d->to; + opts.bitonal_image_subsample_to = d->to; + /* Never, the default, drops the smaller picture. */ + opts.color_lossless_image_recompress_method = opts.gray_lossless_image_recompress_method = FZ_RECOMPRESS_LOSSLESS; + opts.color_lossy_image_recompress_method = opts.gray_lossy_image_recompress_method = FZ_RECOMPRESS_SAME; + opts.bitonal_image_recompress_method = FZ_RECOMPRESS_SAME; + pdf_rewrite_images(ctx, ely_pdf_of(ctx, copy), &opts); +} + +/* Writes `out` compressed, images above `from` dpi taken to `to`. */ +int ely_optimize(fz_context *ctx, fz_document *doc, const char *raw, const char *out, const char *password, int compress, + int from, int to) +{ + fz_try(ctx) + { + pdf_write_options opts = pdf_default_write_options; + downsample d = { from, to }; + if (to && (to < 36 || from <= to)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a downsampling from %d to %d dpi", from, to); + opts.do_compress = opts.do_compress_images = opts.do_compress_fonts = compress; + ely_save_copy(ctx, ely_pdf_of(ctx, doc), raw, out, password, &opts, shrink_images, &d); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* 1 when MuPDF rebuilt the file's structure to read it. */ +int ely_repaired(fz_context *ctx, fz_document *doc) +{ + int repaired = 0; + fz_try(ctx) + repaired = pdf_was_repaired(ctx, ely_pdf_of(ctx, doc)); + fz_catch(ctx) + return ely_fail(ctx); + return repaired; +} + +/* The repaired document written whole to `path`. */ +int ely_write_repaired(fz_context *ctx, fz_document *doc, const char *path) +{ + fz_try(ctx) + { + pdf_write_options opts = pdf_default_write_options; + opts.do_garbage = 1; + pdf_save_document(ctx, ely_pdf_of(ctx, doc), path, &opts); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* Leave to print was checked; the printer gets no lock. */ +int ely_print_copy(fz_context *ctx, fz_document *doc, const char *raw, const char *out, const char *password) +{ + fz_try(ctx) + { + pdf_write_options opts = pdf_default_write_options; + opts.do_encrypt = PDF_ENCRYPT_NONE; + ely_save_copy(ctx, ely_pdf_of(ctx, doc), raw, out, password, &opts, NULL, NULL); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* Decodes every picture; 1 if MuPDF warned on any. */ +int ely_check_pictures(fz_context *ctx, fz_document *doc) +{ + pdf_document *pdf = ely_pdf_of(ctx, doc); + int i; + ely_watch_begin(ctx); + for (i = 1; i < pdf_xref_len(ctx, pdf); i++) + { + pdf_obj *obj = NULL; + fz_buffer *bytes = NULL; + fz_var(obj); + fz_var(bytes); + fz_try(ctx) + { + obj = pdf_new_indirect(ctx, pdf, i, 0); + if (pdf_is_stream(ctx, obj) && pdf_name_eq(ctx, pdf_dict_get(ctx, obj, PDF_NAME(Subtype)), PDF_NAME(Image))) + bytes = pdf_load_stream_number(ctx, pdf, i); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, bytes); + pdf_drop_obj(ctx, obj); + } + fz_catch(ctx) + { + fz_warn(ctx, "picture %d: %s", i, fz_caught_message(ctx)); + fz_ignore_error(ctx); + } + } + return ely_watch_end(ctx); +} diff --git a/crates/ely-pdf-engine/shim/ely_pages.c b/crates/ely-pdf-engine/shim/ely_pages.c new file mode 100644 index 0000000..853785a --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_pages.c @@ -0,0 +1,487 @@ +/* Page edits, undo and saving; one undo step each. */ + +#include + +#include "ely_shim.h" + +pdf_document *ely_pdf_of(fz_context *ctx, fz_document *doc) +{ + pdf_document *pdf = pdf_specifics(ctx, doc); + if (!pdf) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "not a PDF"); + return pdf; +} + +/* MuPDF's font and image caches outlive an undo; they go. */ +void ely_forget_resources(fz_context *ctx, pdf_document *pdf) +{ + pdf_drop_resource_tables(ctx, pdf); + pdf->resources.fonts = NULL; + pdf->resources.colorspaces = NULL; + pdf->resources.images = NULL; +} + +/* Reports the failure; a begun step is rolled back. */ +int ely_edit_failed(fz_context *ctx, pdf_document *pdf, int begun) +{ + int code = ely_fail(ctx); + if (begun) + { + fz_try(ctx) + { + pdf_abandon_operation(ctx, pdf); + ely_forget_resources(ctx, pdf); + } + fz_catch(ctx) + ely_fail_also(ctx, "rolling back"); + } + return code; +} + +int ely_journal(fz_context *ctx, fz_document *doc) +{ + fz_try(ctx) + pdf_enable_journal(ctx, ely_pdf_of(ctx, doc)); + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +int ely_undo(fz_context *ctx, fz_document *doc, int redo) +{ + fz_try(ctx) + { + pdf_document *pdf = ely_pdf_of(ctx, doc); + if (redo) + pdf_redo(ctx, pdf); + else + pdf_undo(ctx, pdf); + ely_forget_resources(ctx, pdf); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* MuPDF's undo position and step count; saves add steps. */ +int ely_state(fz_context *ctx, fz_document *doc, int *at, int *count) +{ + fz_try(ctx) + *at = pdf_undoredo_state(ctx, ely_pdf_of(ctx, doc), count); + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +static int by_number(const void *a, const void *b) +{ + return *(const int *)a - *(const int *)b; +} + +/* Object numbers, sorted for lookups. */ +typedef struct +{ + int *nums; + int n; +} gone_set; + +static int is_gone(fz_context *ctx, gone_set *gone, pdf_obj *obj) +{ + int num = pdf_to_num(ctx, obj); + return num && bsearch(&num, gone->nums, gone->n, sizeof(int), by_number) != NULL; +} + +/* Deleted pages, and the annotations they held. */ +typedef struct +{ + gone_set pages; + gone_set annots; +} gone_pages; + +/* The page a link, bookmark or action points to. */ +pdf_obj *ely_target(fz_context *ctx, pdf_document *pdf, pdf_obj *obj) +{ + pdf_obj *dest = pdf_dict_get(ctx, obj, PDF_NAME(Dest)); + if (!dest) + { + pdf_obj *action = pdf_dict_get(ctx, obj, PDF_NAME(A)); + if (!pdf_name_eq(ctx, pdf_dict_get(ctx, action, PDF_NAME(S)), PDF_NAME(GoTo))) + return NULL; + dest = pdf_dict_get(ctx, action, PDF_NAME(D)); + } + if (pdf_is_name(ctx, dest) || pdf_is_string(ctx, dest)) + dest = pdf_lookup_dest(ctx, pdf, dest); + if (pdf_is_dict(ctx, dest)) + dest = pdf_dict_get(ctx, dest, PDF_NAME(D)); + return pdf_is_array(ctx, dest) ? pdf_array_get(ctx, dest, 0) : NULL; +} + +/* Bookmarks to gone pages keep their place, lose the jump. */ +static void forget_in_outline(fz_context *ctx, pdf_document *pdf, pdf_obj *item, gone_set *gone) +{ + for (; item; item = pdf_dict_get(ctx, item, PDF_NAME(Next))) + { + pdf_obj *page = ely_target(ctx, pdf, item); + if (page && is_gone(ctx, gone, page)) + { + pdf_dict_del(ctx, item, PDF_NAME(Dest)); + pdf_dict_del(ctx, item, PDF_NAME(A)); + } + forget_in_outline(ctx, pdf, pdf_dict_get(ctx, item, PDF_NAME(First)), gone); + } +} + +/* Whether a field keeps a widget on a kept page. */ +static int prune_field(fz_context *ctx, pdf_obj *field, gone_pages *gone) +{ + pdf_obj *kids = pdf_dict_get(ctx, field, PDF_NAME(Kids)); + int i; + if (!kids) + { + /* A deleted page held it, or its /P names one. */ + pdf_obj *page = pdf_dict_get(ctx, field, PDF_NAME(P)); + if (!pdf_name_eq(ctx, pdf_dict_get(ctx, field, PDF_NAME(Subtype)), PDF_NAME(Widget))) + return 1; + return !is_gone(ctx, &gone->annots, field) && !(page && is_gone(ctx, &gone->pages, page)); + } + for (i = pdf_array_len(ctx, kids) - 1; i >= 0; i--) + if (!prune_field(ctx, pdf_array_get(ctx, kids, i), gone)) + pdf_array_delete(ctx, kids, i); + return pdf_array_len(ctx, kids) > 0; +} + +/* Drops what pointed into deleted pages. */ +static void forget_pages(fz_context *ctx, pdf_document *pdf, gone_pages *gone) +{ + pdf_obj *root = pdf_dict_get(ctx, pdf_trailer(ctx, pdf), PDF_NAME(Root)); + pdf_obj *outlines = pdf_dict_get(ctx, root, PDF_NAME(Outlines)); + pdf_obj *fields = pdf_dict_getp(ctx, root, "AcroForm/Fields"); + int i, j, count = pdf_count_pages(ctx, pdf); + forget_in_outline(ctx, pdf, pdf_dict_get(ctx, outlines, PDF_NAME(First)), &gone->pages); + for (i = 0; i < count; i++) + { + pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, pdf, i), PDF_NAME(Annots)); + for (j = pdf_array_len(ctx, annots) - 1; j >= 0; j--) + { + pdf_obj *annot = pdf_array_get(ctx, annots, j); + pdf_obj *page; + if (!pdf_name_eq(ctx, pdf_dict_get(ctx, annot, PDF_NAME(Subtype)), PDF_NAME(Link))) + continue; + page = ely_target(ctx, pdf, annot); + if (page && is_gone(ctx, &gone->pages, page)) + pdf_array_delete(ctx, annots, j); + } + } + for (i = pdf_array_len(ctx, fields) - 1; i >= 0; i--) + if (!prune_field(ctx, pdf_array_get(ctx, fields, i), gone)) + pdf_array_delete(ctx, fields, i); +} + +/* Checks `pages` are sorted page numbers without repeats. */ +static void check_pages(fz_context *ctx, pdf_document *pdf, const int *pages, int n) +{ + int i, count = pdf_count_pages(ctx, pdf); + if (n <= 0) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no pages given"); + for (i = 0; i < n; i++) + if (pages[i] < 0 || pages[i] >= count || (i > 0 && pages[i] <= pages[i - 1])) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "page %d is out of order or range", pages[i]); +} + +/* Deletes checked `pages` and what pointed there. */ +static void delete_pages(fz_context *ctx, pdf_document *pdf, const int *pages, int n) +{ + gone_pages gone = { { NULL, n }, { NULL, 0 } }; + int i, j, held = 0; + fz_var(gone.pages.nums); + fz_var(gone.annots.nums); + fz_try(ctx) + { + gone.pages.nums = fz_malloc_array(ctx, n, int); + for (i = 0; i < n; i++) + { + pdf_obj *page = pdf_lookup_page_obj(ctx, pdf, pages[i]); + gone.pages.nums[i] = pdf_to_num(ctx, page); + held += pdf_array_len(ctx, pdf_dict_get(ctx, page, PDF_NAME(Annots))); + } + gone.annots.nums = fz_malloc_array(ctx, held + 1, int); + for (i = 0; i < n; i++) + { + pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, pdf, pages[i]), PDF_NAME(Annots)); + for (j = 0; j < pdf_array_len(ctx, annots); j++) + gone.annots.nums[gone.annots.n++] = pdf_to_num(ctx, pdf_array_get(ctx, annots, j)); + } + qsort(gone.pages.nums, n, sizeof(int), by_number); + qsort(gone.annots.nums, gone.annots.n, sizeof(int), by_number); + for (i = n - 1; i >= 0; i--) + pdf_delete_page(ctx, pdf, pages[i]); + forget_pages(ctx, pdf, &gone); + } + fz_always(ctx) + { + fz_free(ctx, gone.pages.nums); + fz_free(ctx, gone.annots.nums); + } + fz_catch(ctx) + fz_rethrow(ctx); +} + +int ely_delete_pages(fz_context *ctx, fz_document *doc, const int *pages, int n) +{ + pdf_document *pdf = NULL; + int begun = 0; + fz_var(pdf); + fz_var(begun); + fz_try(ctx) + { + pdf = ely_pdf_of(ctx, doc); + check_pages(ctx, pdf, pages, n); + if (n == pdf_count_pages(ctx, pdf)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a PDF keeps at least one page"); + pdf_begin_operation(ctx, pdf, "Delete pages"); + begun = 1; + delete_pages(ctx, pdf, pages, n); + pdf_end_operation(ctx, pdf); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* Moves `pages` before page `to`, in their order. */ +int ely_move_pages(fz_context *ctx, fz_document *doc, const int *pages, int n, int to) +{ + pdf_document *pdf = NULL; + pdf_obj **moved = NULL; + int begun = 0, i, at; + fz_var(pdf); + fz_var(begun); + fz_var(moved); + fz_try(ctx) + { + pdf = ely_pdf_of(ctx, doc); + check_pages(ctx, pdf, pages, n); + if (to < 0 || to > pdf_count_pages(ctx, pdf)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no place %d to move to", to); + moved = fz_calloc(ctx, n, sizeof *moved); + for (i = 0; i < n; i++) + moved[i] = pdf_keep_obj(ctx, pdf_lookup_page_obj(ctx, pdf, pages[i])); + pdf_begin_operation(ctx, pdf, "Move pages"); + begun = 1; + for (i = n - 1; i >= 0; i--) + pdf_delete_page(ctx, pdf, pages[i]); + for (at = to, i = 0; i < n; i++) + at -= pages[i] < to; + for (i = 0; i < n; i++) + pdf_insert_page(ctx, pdf, at + i, moved[i]); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + for (i = 0; moved && i < n; i++) + pdf_drop_obj(ctx, moved[i]); + fz_free(ctx, moved); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* Turns `pages` by a multiple of 90 degrees. */ +int ely_rotate_pages(fz_context *ctx, fz_document *doc, const int *pages, int n, int by) +{ + pdf_document *pdf = NULL; + int begun = 0, i; + fz_var(pdf); + fz_var(begun); + fz_try(ctx) + { + pdf = ely_pdf_of(ctx, doc); + check_pages(ctx, pdf, pages, n); + if (by % 90) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a turn of %d degrees", by); + pdf_begin_operation(ctx, pdf, "Rotate pages"); + begun = 1; + for (i = 0; i < n; i++) + { + pdf_obj *page = pdf_lookup_page_obj(ctx, pdf, pages[i]); + int now = pdf_to_int(ctx, pdf_dict_get_inheritable(ctx, page, PDF_NAME(Rotate))); + pdf_dict_put_int(ctx, page, PDF_NAME(Rotate), ((now + by) % 360 + 360) % 360); + } + pdf_end_operation(ctx, pdf); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* Seen margins (top, right, bottom, left) to user sides. */ +static fz_rect inset(fz_rect box, int rotate, const float seen[4]) +{ + /* User sides: 0 y1, 1 x1, 2 y0, 3 x0. */ + static const int under[4][4] = { { 0, 1, 2, 3 }, { 3, 0, 1, 2 }, { 2, 3, 0, 1 }, { 1, 2, 3, 0 } }; + float side[4] = { 0, 0, 0, 0 }; + int i, turn = ((rotate % 360 + 360) % 360) / 90; + for (i = 0; i < 4; i++) + side[under[turn][i]] = seen[i]; + box.y1 -= side[0]; + box.x1 -= side[1]; + box.y0 += side[2]; + box.x0 += side[3]; + return box; +} + +/* Crops `pages` by seen margins in points; zeros uncrop. */ +int ely_crop_pages(fz_context *ctx, fz_document *doc, const int *pages, int n, const float margins[4]) +{ + pdf_document *pdf = NULL; + int begun = 0, i; + fz_var(pdf); + fz_var(begun); + fz_try(ctx) + { + int none = margins[0] == 0 && margins[1] == 0 && margins[2] == 0 && margins[3] == 0; + pdf = ely_pdf_of(ctx, doc); + check_pages(ctx, pdf, pages, n); + pdf_begin_operation(ctx, pdf, "Crop pages"); + begun = 1; + for (i = 0; i < n; i++) + { + pdf_obj *page = pdf_lookup_page_obj(ctx, pdf, pages[i]); + fz_rect media = pdf_to_rect(ctx, pdf_dict_get_inheritable(ctx, page, PDF_NAME(MediaBox))); + int rotate = pdf_to_int(ctx, pdf_dict_get_inheritable(ctx, page, PDF_NAME(Rotate))); + /* Points as shown; user units may be larger. */ + float unit = pdf_to_real(ctx, pdf_dict_get(ctx, page, PDF_NAME(UserUnit))); + float user[4]; + int side; + for (side = 0; side < 4; side++) + user[side] = margins[side] / (unit > 0 ? unit : 1); + fz_rect crop = inset(media, rotate, user); + if (none) + { + pdf_dict_del(ctx, page, PDF_NAME(CropBox)); + continue; + } + if (crop.x1 - crop.x0 < 1 || crop.y1 - crop.y0 < 1) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the margins leave nothing of page %d", pages[i] + 1); + pdf_dict_put_rect(ctx, page, PDF_NAME(CropBox), crop); + } + pdf_end_operation(ctx, pdf); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* A blank page of w by h points at `at`. */ +int ely_insert_blank(fz_context *ctx, fz_document *doc, int at, float w, float h) +{ + pdf_document *pdf = NULL; + pdf_obj *page = NULL; + int begun = 0; + fz_var(pdf); + fz_var(page); + fz_var(begun); + fz_try(ctx) + { + pdf = ely_pdf_of(ctx, doc); + if (at < 0 || at > pdf_count_pages(ctx, pdf) || w < 1 || h < 1) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a blank page at %d of %g by %g", at, w, h); + pdf_begin_operation(ctx, pdf, "Insert a blank page"); + begun = 1; + page = pdf_add_page(ctx, pdf, fz_make_rect(0, 0, w, h), 0, NULL, NULL); + pdf_insert_page(ctx, pdf, at, page); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + pdf_drop_obj(ctx, page); + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* Pages of `pdf` other than the sorted `keep`. */ +static int *others(fz_context *ctx, pdf_document *pdf, const int *keep, int n, int *k) +{ + int i, count = pdf_count_pages(ctx, pdf); + int *rest = fz_malloc_array(ctx, count + 1, int); + *k = 0; + for (i = 0; i < count; i++) + if (bsearch(&i, keep, n, sizeof(int), by_number) == NULL) + rest[(*k)++] = i; + return rest; +} + +/* A snapshot, reopened, changed, unsigned, compacted to `out`. */ +void ely_save_copy(fz_context *ctx, pdf_document *pdf, const char *raw, const char *out, const char *password, + const pdf_write_options *opts, ely_change *change, void *arg) +{ + fz_document *copy = NULL; + fz_var(copy); + fz_try(ctx) + { + pdf_write_options compact = opts ? *opts : pdf_default_write_options; + pdf_save_snapshot(ctx, pdf, raw); + copy = fz_open_document(ctx, raw); + if (fz_needs_password(ctx, copy) && !fz_authenticate_password(ctx, copy, password)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the written copy refused its password"); + if (change) + change(ctx, copy, arg); + ely_unsign(ctx, ely_pdf_of(ctx, copy)); + /* Unused objects, such as deleted pages, go. */ + compact.do_garbage = 2; + pdf_save_document(ctx, ely_pdf_of(ctx, copy), out, &compact); + } + fz_always(ctx) + fz_drop_document(ctx, copy); + fz_catch(ctx) + fz_rethrow(ctx); +} + +typedef struct +{ + const int *pages; + int n; +} kept; + +/* Deletes every page not kept. */ +static void keep_only(fz_context *ctx, fz_document *copy, void *arg) +{ + kept *keep = arg; + pdf_document *written = ely_pdf_of(ctx, copy); + int k, *rest = others(ctx, written, keep->pages, keep->n, &k); + fz_try(ctx) + { + if (k > 0) + delete_pages(ctx, written, rest, k); + } + fz_always(ctx) + fz_free(ctx, rest); + fz_catch(ctx) + fz_rethrow(ctx); +} + +int ely_save(fz_context *ctx, fz_document *doc, const char *raw, const char *out, const char *password) +{ + fz_try(ctx) + ely_save_copy(ctx, ely_pdf_of(ctx, doc), raw, out, password, NULL, NULL, NULL); + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* Saves only `pages` to `out`; the document is untouched. */ +int ely_extract(fz_context *ctx, fz_document *doc, const int *pages, int n, + const char *raw, const char *out, const char *password) +{ + fz_try(ctx) + { + pdf_document *pdf = ely_pdf_of(ctx, doc); + kept keep = { pages, n }; + check_pages(ctx, pdf, pages, n); + ely_save_copy(ctx, pdf, raw, out, password, NULL, keep_only, &keep); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} diff --git a/crates/ely-pdf-engine/shim/ely_place.c b/crates/ely-pdf-engine/shim/ely_place.c new file mode 100644 index 0000000..3d6b048 --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_place.c @@ -0,0 +1,336 @@ +/* Images and form XObjects: list, move, scale, swap, add (PDF-04). */ + +#include + +#include "ely_shim.h" + +/* One drawn object: its kind and where it lands. */ +typedef struct +{ + int form; + fz_matrix ctm; + fz_rect rect; +} drawn; + +/* Counts top-level draws, following q, Q and cm. */ +typedef struct +{ + pdf_processor super; + fz_matrix ctm; + fz_matrix *stack; + int depth, room; + fz_matrix page; + drawn *found; + int n, cap; +} inventory; + +static void inv_q(fz_context *ctx, pdf_processor *proc) +{ + inventory *inv = (inventory *)proc; + if (inv->depth == inv->room) + { + inv->room = inv->room * 2 + 8; + inv->stack = fz_realloc_array(ctx, inv->stack, inv->room, fz_matrix); + } + inv->stack[inv->depth++] = inv->ctm; +} + +static void inv_Q(fz_context *ctx, pdf_processor *proc) +{ + inventory *inv = (inventory *)proc; + if (inv->depth > 0) + inv->ctm = inv->stack[--inv->depth]; +} + +static void inv_cm(fz_context *ctx, pdf_processor *proc, float a, float b, float c, float d, float e, float f) +{ + inventory *inv = (inventory *)proc; + inv->ctm = fz_concat(fz_make_matrix(a, b, c, d, e, f), inv->ctm); +} + +static void found(fz_context *ctx, inventory *inv, int form, fz_rect box) +{ + if (inv->n == inv->cap) + { + inv->cap = inv->cap * 2 + 8; + inv->found = fz_realloc_array(ctx, inv->found, inv->cap, drawn); + } + inv->found[inv->n].form = form; + inv->found[inv->n].ctm = inv->ctm; + inv->found[inv->n++].rect = fz_transform_rect(box, fz_concat(inv->ctm, inv->page)); +} + +static void inv_image(fz_context *ctx, pdf_processor *proc, const char *name, fz_image *image) +{ + found(ctx, (inventory *)proc, 0, fz_unit_rect); +} + +static void inv_form(fz_context *ctx, pdf_processor *proc, const char *name, pdf_obj *form) +{ + fz_rect box = fz_transform_rect(pdf_xobject_bbox(ctx, form), pdf_xobject_matrix(ctx, form)); + found(ctx, (inventory *)proc, 1, box); +} + +/* The page's top-level draws, in content order. */ +static int take_inventory(fz_context *ctx, pdf_document *pdf, pdf_page *page, drawn **out) +{ + inventory *inv = pdf_new_processor(ctx, sizeof *inv); + int n = 0; + fz_try(ctx) + { + inv->super.op_q = inv_q; + inv->super.op_Q = inv_Q; + inv->super.op_cm = inv_cm; + inv->super.op_Do_image = inv_image; + inv->super.op_Do_form = inv_form; + inv->ctm = fz_identity; + pdf_page_transform(ctx, page, NULL, &inv->page); + pdf_process_contents(ctx, &inv->super, pdf, pdf_page_resources(ctx, page), pdf_page_contents(ctx, page), NULL, NULL); + pdf_close_processor(ctx, &inv->super); + *out = inv->found; + n = inv->n; + inv->found = NULL; + } + fz_always(ctx) + { + fz_free(ctx, inv->stack); + fz_free(ctx, inv->found); + pdf_drop_processor(ctx, &inv->super); + } + fz_catch(ctx) + fz_rethrow(ctx); + return n; +} + +/* JSON of the page's images and forms. */ +int ely_objects(fz_context *ctx, fz_document *doc, int number, unsigned char **data, size_t *len) +{ + pdf_page *page = NULL; + drawn *list = NULL; + fz_buffer *buf = NULL; + fz_var(page); + fz_var(list); + fz_var(buf); + fz_try(ctx) + { + pdf_document *pdf = ely_pdf_of(ctx, doc); + int i, n; + page = pdf_load_page(ctx, pdf, number); + n = take_inventory(ctx, pdf, page, &list); + buf = fz_new_buffer(ctx, 256); + fz_append_byte(ctx, buf, '['); + for (i = 0; i < n; i++) + fz_append_printf(ctx, buf, "%s{\"kind\":\"%s\",\"rect\":[%g,%g,%g,%g]}", i ? "," : "", + list[i].form ? "form" : "image", list[i].rect.x0, list[i].rect.y0, list[i].rect.x1, list[i].rect.y1); + fz_append_byte(ctx, buf, ']'); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, buf); + fz_free(ctx, list); + fz_drop_page(ctx, (fz_page *)page); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* What the rewrite does at draw `target`. */ +typedef struct +{ + int target, seen, drop; + fz_matrix x; + const char *rename; + pdf_processor *out; + void (*image)(fz_context *, pdf_processor *, const char *, fz_image *); + void (*form)(fz_context *, pdf_processor *, const char *, pdf_obj *); +} hook; + +/* Wraps the chosen draw in `x`, renames or drops it. */ +static int at_target(fz_context *ctx, hook *h, const char **name) +{ + if (h->seen++ != h->target) + return 0; + if (!h->drop) + { + h->out->op_q(ctx, h->out); + h->out->op_cm(ctx, h->out, h->x.a, h->x.b, h->x.c, h->x.d, h->x.e, h->x.f); + if (h->rename) + *name = h->rename; + } + return 1; +} + +static void hook_image(fz_context *ctx, pdf_processor *proc, const char *name, fz_image *image) +{ + hook *h = fz_user_context(ctx); + int hit = at_target(ctx, h, &name); + if (!(hit && h->drop)) + h->image(ctx, proc, name, image); + if (hit && !h->drop) + h->out->op_Q(ctx, h->out); +} + +static void hook_form(fz_context *ctx, pdf_processor *proc, const char *name, pdf_obj *form) +{ + hook *h = fz_user_context(ctx); + int hit = at_target(ctx, h, &name); + if (!(hit && h->drop)) + h->form(ctx, proc, name, form); + if (hit && !h->drop) + h->out->op_Q(ctx, h->out); +} + +/* Rewrites the page's content with `h` at its target draw. */ +static void rewrite(fz_context *ctx, pdf_document *pdf, pdf_page *page, hook *h) +{ + fz_buffer *buf = fz_new_buffer(ctx, 1024); + pdf_processor *out = NULL; + void *before = fz_user_context(ctx); + fz_var(out); + fz_try(ctx) + { + out = pdf_new_buffer_processor(ctx, buf, 0, 0); + h->out = out; + h->image = out->op_Do_image; + h->form = out->op_Do_form; + out->op_Do_image = hook_image; + out->op_Do_form = hook_form; + fz_set_user_context(ctx, h); + pdf_process_contents(ctx, out, pdf, pdf_page_resources(ctx, page), pdf_page_contents(ctx, page), NULL, NULL); + pdf_close_processor(ctx, out); + if (h->seen <= h->target) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no drawn object %d", h->target); + pdf_dict_put_drop(ctx, page->obj, PDF_NAME(Contents), pdf_add_stream(ctx, pdf, buf, NULL, 0)); + } + fz_always(ctx) + { + fz_set_user_context(ctx, before); + pdf_drop_processor(ctx, out); + fz_drop_buffer(ctx, buf); + } + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* The draw's matrix in page space. */ +static fz_matrix placed_at(fz_context *ctx, pdf_document *pdf, pdf_page *page, int index, fz_rect *rect) +{ + drawn *list = NULL; + fz_matrix at = fz_identity, page_ctm; + fz_var(list); + fz_try(ctx) + { + int n = take_inventory(ctx, pdf, page, &list); + if (index < 0 || index >= n) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no drawn object %d", index); + pdf_page_transform(ctx, page, NULL, &page_ctm); + at = fz_concat(list[index].ctm, page_ctm); + *rect = list[index].rect; + } + fz_always(ctx) + fz_free(ctx, list); + fz_catch(ctx) + fz_rethrow(ctx); + return at; +} + +/* Draw `index` moved by page-space `t`, swapped, or dropped. */ +int ely_place(fz_context *ctx, fz_document *doc, int number, int index, const float t[6], int drop, const char *path) +{ + pdf_document *pdf = NULL; + pdf_page *page = NULL; + fz_image *image = NULL; + pdf_obj *ref = NULL; + int begun = 0; + fz_var(pdf); + fz_var(page); + fz_var(image); + fz_var(ref); + fz_var(begun); + fz_try(ctx) + { + hook h = { index, 0, drop, fz_identity, NULL, NULL, NULL, NULL }; + fz_matrix change = fz_make_matrix(t[0], t[1], t[2], t[3], t[4], t[5]), at; + fz_rect rect; + char name[32]; + pdf = ely_pdf_of(ctx, doc); + page = pdf_load_page(ctx, pdf, number); + pdf_begin_operation(ctx, pdf, drop ? "Delete an object" : path ? "Replace an image" : "Move an object"); + begun = 1; + at = placed_at(ctx, pdf, page, index, &rect); + if (path) + { + /* The new picture fits inside the old one's box. */ + float box = (rect.x1 - rect.x0) / (rect.y1 - rect.y0), own; + float cx = (rect.x0 + rect.x1) / 2, cy = (rect.y0 + rect.y1) / 2; + image = fz_new_image_from_file(ctx, path); + own = (float)image->w / image->h; + change = fz_concat(fz_concat(fz_translate(-cx, -cy), fz_scale(fminf(1, own / box), fminf(1, box / own))), fz_translate(cx, cy)); + ref = pdf_add_image(ctx, pdf, image); + h.rename = ely_add_resource(ctx, pdf, page->obj, PDF_NAME(XObject), "ElyIm", ref, name, sizeof name); + } + if (fz_matrix_expansion(change) < 0.001f) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a change that leaves nothing"); + h.x = fz_concat(fz_concat(at, change), fz_invert_matrix(at)); + rewrite(ctx, pdf, page, &h); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + pdf_drop_obj(ctx, ref); + fz_drop_image(ctx, image); + fz_drop_page(ctx, (fz_page *)page); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} + +/* An image file drawn into page-space `rect`, in one step. */ +int ely_add_image(fz_context *ctx, fz_document *doc, int number, const char *path, const float rect[4]) +{ + pdf_document *pdf = NULL; + pdf_page *page = NULL; + fz_image *image = NULL; + pdf_obj *ref = NULL; + fz_buffer *buf = NULL; + int begun = 0; + fz_var(pdf); + fz_var(page); + fz_var(image); + fz_var(ref); + fz_var(buf); + fz_var(begun); + fz_try(ctx) + { + fz_matrix m; + char name[32]; + pdf = ely_pdf_of(ctx, doc); + page = pdf_load_page(ctx, pdf, number); + if (rect[2] - rect[0] < 1 || rect[3] - rect[1] < 1) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "an image box of nothing"); + image = fz_new_image_from_file(ctx, path); + pdf_begin_operation(ctx, pdf, "Add an image"); + begun = 1; + ref = pdf_add_image(ctx, pdf, image); + ely_add_resource(ctx, pdf, page->obj, PDF_NAME(XObject), "ElyIm", ref, name, sizeof name); + /* The unit square, bottom left first, onto the box. */ + m = fz_concat(fz_make_matrix(rect[2] - rect[0], 0, 0, rect[1] - rect[3], rect[0], rect[3]), ely_to_user(ctx, page)); + buf = fz_new_buffer(ctx, 128); + fz_append_printf(ctx, buf, "q %g %g %g %g %g %g cm /%s Do Q\n", m.a, m.b, m.c, m.d, m.e, m.f, name); + ely_append_content(ctx, pdf, page->obj, buf); + pdf_end_operation(ctx, pdf); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, buf); + pdf_drop_obj(ctx, ref); + fz_drop_image(ctx, image); + fz_drop_page(ctx, (fz_page *)page); + } + fz_catch(ctx) + return ely_edit_failed(ctx, pdf, begun); + return 0; +} diff --git a/crates/ely-pdf-engine/shim/ely_redact.c b/crates/ely-pdf-engine/shim/ely_redact.c new file mode 100644 index 0000000..e04bcd7 --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_redact.c @@ -0,0 +1,423 @@ +/* True removal into a new file, then checked (PDF-09, QA-14). */ + +#include +#include + +#include "ely_shim.h" + +/* Regions by page, page space. */ +typedef struct +{ + int page; + fz_rect rect; +} region; + +static int touches(fz_rect a, fz_rect b) +{ + return !fz_is_empty_rect(fz_intersect_rect(a, b)); +} + +/* Throws unless every region is whole and on a page. */ +static region *regions_of(fz_context *ctx, fz_json *list, int pages, int *n) +{ + region *out; + int i; + *n = list && list->type == FZ_JSON_ARRAY ? fz_json_array_length(ctx, list) : 0; + if (*n == 0) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "nothing marked to remove"); + out = fz_malloc_array(ctx, *n, region); + fz_try(ctx) + { + for (i = 0; i < *n; i++) + { + fz_json *spec = fz_json_array_get(ctx, list, i); + fz_json *page = ely_member(ctx, spec, "page", FZ_JSON_NUMBER); + if (!page || page->u.number < 0 || page->u.number >= pages) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a region off the pages"); + out[i].page = (int)page->u.number; + out[i].rect = ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 1); + } + } + fz_catch(ctx) + { + fz_free(ctx, out); + fz_rethrow(ctx); + } + return out; +} + +/* Drops annotations and fields that touch `area`; they carry text. */ +static void drop_touching(fz_context *ctx, pdf_page *page, fz_rect area) +{ + pdf_annot *annot; + int widgets; + for (widgets = 0; widgets < 2; widgets++) + { +restart: + for (annot = widgets ? pdf_first_widget(ctx, page) : pdf_first_annot(ctx, page); annot; + annot = widgets ? pdf_next_widget(ctx, annot) : pdf_next_annot(ctx, annot)) + if (touches(pdf_bound_annot(ctx, annot), area)) + { + pdf_obj *field = pdf_keep_obj(ctx, pdf_annot_obj(ctx, annot)); + fz_try(ctx) + { + pdf_delete_annot(ctx, page, annot); + /* A field left with no widget still holds its value. */ + if (widgets) + ely_unlink_field(ctx, page->doc, field); + } + fz_always(ctx) + pdf_drop_obj(ctx, field); + fz_catch(ctx) + fz_rethrow(ctx); + goto restart; + } + } +} + +/* Applies `regions` to the copy, page by page. */ +static void redact(fz_context *ctx, pdf_document *pdf, region *regions, int n) +{ + pdf_redact_options opts = { 1, PDF_REDACT_IMAGE_PIXELS, PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED, PDF_REDACT_TEXT_REMOVE }; + pdf_page *page = NULL; + int i, k; + fz_var(page); + fz_try(ctx) + { + for (i = 0; i < pdf_count_pages(ctx, pdf); i++) + { + int any = 0; + for (k = 0; k < n; k++) + any |= regions[k].page == i; + if (!any) + continue; + page = pdf_load_page(ctx, pdf, i); + for (k = 0; k < n; k++) + if (regions[k].page == i) + drop_touching(ctx, page, regions[k].rect); + /* Marks after drops, or a drop takes a mark. */ + for (k = 0; k < n; k++) + if (regions[k].page == i) + { + pdf_annot *mark = pdf_create_annot(ctx, page, PDF_ANNOT_REDACT); + pdf_set_annot_rect(ctx, mark, regions[k].rect); + pdf_drop_annot(ctx, mark); + } + pdf_redact_page(ctx, pdf, page, &opts); + ely_drop_alt(ctx, pdf, page); + pdf_dict_del(ctx, page->obj, PDF_NAME(Thumb)); + fz_drop_page(ctx, (fz_page *)page); + page = NULL; + } + } + fz_always(ctx) + fz_drop_page(ctx, (fz_page *)page); + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* A glyph box shrunk a tenth, as redaction judges. */ +static fz_rect shrunk(fz_rect r) +{ + float w = (r.x1 - r.x0) / 10, h = (r.y1 - r.y0) / 10; + return fz_make_rect(r.x0 + w, r.y0 + h, r.x1 - w, r.y1 - h); +} + +/* Characters on `page` inside `area`, hidden ones too. */ +static int chars_in(fz_context *ctx, fz_document *doc, int number, fz_rect area) +{ + fz_stext_page *text = NULL; + int n = 0; + fz_var(text); + fz_try(ctx) + { + fz_stext_block *block; + fz_stext_line *line; + fz_stext_char *ch; + text = fz_new_stext_page_from_page_number(ctx, doc, number, NULL); + for (block = text->first_block; block; block = block->next) + if (block->type == FZ_STEXT_BLOCK_TEXT) + for (line = block->u.t.first_line; line; line = line->next) + for (ch = line->first_char; ch; ch = ch->next) + n += touches(shrunk(fz_rect_from_quad(ch->quad)), area); + } + fz_always(ctx) + fz_drop_stext_page(ctx, text); + fz_catch(ctx) + fz_rethrow(ctx); + return n; +} + +/* Pixels in `area` that are not black once drawn. */ +static int light_in(fz_context *ctx, fz_document *doc, int number, fz_rect area) +{ + fz_pixmap *pix = NULL; + int n = 0; + fz_var(pix); + fz_try(ctx) + { + fz_page *page = fz_load_page(ctx, doc, number); + fz_irect box = fz_round_rect(fz_intersect_rect(area, fz_bound_page(ctx, page))); + unsigned char *s; + int x, y; + fz_drop_page(ctx, page); + /* One pixel in from each edge: boxes antialias. */ + box.x0++, box.y0++, box.x1--, box.y1--; + if (box.x1 <= box.x0 || box.y1 <= box.y0) + break; + pix = fz_new_pixmap_with_bbox(ctx, fz_device_rgb(ctx), box, NULL, 0); + fz_clear_pixmap_with_value(ctx, pix, 255); + page = fz_load_page(ctx, doc, number); + fz_try(ctx) + { + fz_device *dev = fz_new_draw_device(ctx, fz_identity, pix); + fz_try(ctx) + { + fz_run_page(ctx, page, dev, fz_identity, NULL); + fz_close_device(ctx, dev); + } + fz_always(ctx) + fz_drop_device(ctx, dev); + fz_catch(ctx) + fz_rethrow(ctx); + } + fz_always(ctx) + fz_drop_page(ctx, page); + fz_catch(ctx) + fz_rethrow(ctx); + for (y = 0, s = pix->samples; y < pix->h; y++, s += pix->stride - pix->w * 3) + for (x = 0; x < pix->w; x++, s += 3) + n += s[0] > 40 || s[1] > 40 || s[2] > 40; + } + fz_always(ctx) + fz_drop_pixmap(ctx, pix); + fz_catch(ctx) + fz_rethrow(ctx); + return n; +} + +/* Counts drawings in the regions, our boxes aside. */ +typedef struct +{ + fz_device super; + region *regions; + int n, page, leaks; +} leak_dev; + +static int in_regions(leak_dev *d, fz_rect r) +{ + int k; + for (k = 0; k < d->n; k++) + if (d->regions[k].page == d->page && touches(r, d->regions[k].rect)) + return 1; + return 0; +} + +static int black(fz_context *ctx, fz_colorspace *cs, const float *color) +{ + float rgb[3]; + if (!cs) + return 0; + fz_convert_color(ctx, cs, color, fz_device_rgb(ctx), rgb, NULL, fz_default_color_params); + return rgb[0] <= 0.02f && rgb[1] <= 0.02f && rgb[2] <= 0.02f; +} + +/* Whether `r`, black, is one of the region boxes. */ +static int is_box(fz_context *ctx, leak_dev *d, fz_rect r, fz_colorspace *cs, const float *color) +{ + int k; + if (!black(ctx, cs, color)) + return 0; + for (k = 0; k < d->n; k++) + { + fz_rect b = d->regions[k].rect; + if (d->regions[k].page == d->page && fabsf(r.x0 - b.x0) < 1 && fabsf(r.y0 - b.y0) < 1 && + fabsf(r.x1 - b.x1) < 1 && fabsf(r.y1 - b.y1) < 1) + return 1; + } + return 0; +} + +static void leak_fill_path(fz_context *ctx, fz_device *dev, const fz_path *path, int even_odd, fz_matrix ctm, + fz_colorspace *cs, const float *color, float alpha, fz_color_params cp) +{ + leak_dev *d = (leak_dev *)dev; + fz_rect r = fz_bound_path(ctx, path, NULL, ctm); + (void)even_odd, (void)alpha, (void)cp; + if (in_regions(d, r) && !is_box(ctx, d, r, cs, color)) + d->leaks++; +} + +static void leak_stroke_path(fz_context *ctx, fz_device *dev, const fz_path *path, const fz_stroke_state *stroke, + fz_matrix ctm, fz_colorspace *cs, const float *color, float alpha, fz_color_params cp) +{ + leak_dev *d = (leak_dev *)dev; + (void)cs, (void)color, (void)alpha, (void)cp; + d->leaks += in_regions(d, fz_bound_path(ctx, path, stroke, ctm)); +} + +static void leak_fill_shade(fz_context *ctx, fz_device *dev, fz_shade *shade, fz_matrix ctm, float alpha, fz_color_params cp) +{ + leak_dev *d = (leak_dev *)dev; + (void)alpha, (void)cp; + d->leaks += in_regions(d, fz_bound_shade(ctx, shade, ctm)); +} + +static void leak_fill_image_mask(fz_context *ctx, fz_device *dev, fz_image *image, fz_matrix ctm, fz_colorspace *cs, + const float *color, float alpha, fz_color_params cp) +{ + leak_dev *d = (leak_dev *)dev; + (void)image, (void)alpha, (void)cp; + d->leaks += in_regions(d, fz_transform_rect(fz_unit_rect, ctm)) && !black(ctx, cs, color); +} + +/* Image pixels in `area` neither white nor black. */ +static int image_light(fz_context *ctx, fz_image *image, fz_matrix ctm, fz_rect area) +{ + fz_irect box = fz_round_rect(fz_intersect_rect(area, fz_transform_rect(fz_unit_rect, ctm))); + fz_pixmap *pix = NULL; + fz_device *draw = NULL; + int n = 0, x, y; + box.x0++, box.y0++, box.x1--, box.y1--; + if (box.x1 <= box.x0 || box.y1 <= box.y0) + return 0; + fz_var(pix); + fz_var(draw); + fz_try(ctx) + { + unsigned char *s; + pix = fz_new_pixmap_with_bbox(ctx, fz_device_rgb(ctx), box, NULL, 1); + fz_clear_pixmap(ctx, pix); + draw = fz_new_draw_device(ctx, fz_identity, pix); + fz_fill_image(ctx, draw, image, ctm, 1, fz_default_color_params); + fz_close_device(ctx, draw); + for (y = 0, s = pix->samples; y < pix->h; y++, s += pix->stride - pix->w * 4) + for (x = 0; x < pix->w; x++, s += 4) + { + int high = s[0] > 215 && s[1] > 215 && s[2] > 215, low = s[0] < 40 && s[1] < 40 && s[2] < 40; + n += s[3] > 200 && !high && !low; + } + } + fz_always(ctx) + { + fz_drop_device(ctx, draw); + fz_drop_pixmap(ctx, pix); + } + fz_catch(ctx) + fz_rethrow(ctx); + return n; +} + +static void leak_fill_image(fz_context *ctx, fz_device *dev, fz_image *image, fz_matrix ctm, float alpha, fz_color_params cp) +{ + leak_dev *d = (leak_dev *)dev; + int k; + (void)alpha, (void)cp; + for (k = 0; k < d->n; k++) + if (d->regions[k].page == d->page) + d->leaks += image_light(ctx, image, ctm, d->regions[k].rect); +} + +/* Drawings left in the regions of page `number`. */ +static int leaks_on(fz_context *ctx, fz_document *doc, int number, region *regions, int n) +{ + leak_dev *d = fz_new_derived_device(ctx, leak_dev); + fz_page *page = NULL; + int leaks = 0; + fz_var(page); + d->super.fill_path = leak_fill_path; + d->super.stroke_path = leak_stroke_path; + d->super.fill_shade = leak_fill_shade; + d->super.fill_image = leak_fill_image; + d->super.fill_image_mask = leak_fill_image_mask; + d->regions = regions, d->n = n, d->page = number; + fz_try(ctx) + { + page = fz_load_page(ctx, doc, number); + fz_run_page(ctx, page, &d->super, fz_identity, NULL); + fz_close_device(ctx, &d->super); + leaks = d->leaks; + } + fz_always(ctx) + { + fz_drop_page(ctx, page); + fz_drop_device(ctx, &d->super); + } + fz_catch(ctx) + fz_rethrow(ctx); + return leaks; +} + +typedef struct +{ + region *regions; + int n; +} marked; + +static void apply_marks(fz_context *ctx, fz_document *copy, void *arg) +{ + marked *m = arg; + redact(ctx, ely_pdf_of(ctx, copy), m->regions, m->n); +} + +/* Checks `out`: no text, no light in any region. */ +static void check_redacted(fz_context *ctx, const char *out, const char *password, region *regions, int n, fz_buffer *report) +{ + fz_document *doc = fz_open_document(ctx, out); + fz_try(ctx) + { + int i, text = 0, light = 0, objects = 0, versions; + if (fz_needs_password(ctx, doc) && !fz_authenticate_password(ctx, doc, password)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the written file refused its password"); + for (i = 0; i < n; i++) + { + text += chars_in(ctx, doc, regions[i].page, regions[i].rect); + light += light_in(ctx, doc, regions[i].page, regions[i].rect); + objects += pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, ely_pdf_of(ctx, doc), regions[i].page), PDF_NAME(Thumb)) != NULL; + } + for (i = 0; i < fz_count_pages(ctx, doc); i++) + objects += leaks_on(ctx, doc, i, regions, n); + versions = pdf_count_versions(ctx, ely_pdf_of(ctx, doc)); + fz_append_printf(ctx, report, "{\"regions\":%d,\"text\":%d,\"light\":%d,\"objects\":%d,\"versions\":%d}", n, text, + light, objects, versions); + if (text || light || objects || versions != 1) + fz_throw(ctx, FZ_ERROR_FORMAT, "the written file still holds what was removed: %d characters, %d light pixels, %d drawings, %d versions", + text, light, objects, versions); + } + fz_always(ctx) + fz_drop_document(ctx, doc); + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* Removes the marked regions into `out`, then checks it. */ +int ely_redact(fz_context *ctx, fz_document *doc, const char *json, const char *raw, const char *out, + const char *password, unsigned char **data, size_t *len) +{ + fz_pool *pool = NULL; + marked m = { NULL, 0 }; + fz_buffer *report = NULL; + fz_var(pool); + fz_var(m); + fz_var(report); + fz_try(ctx) + { + pdf_document *pdf = ely_pdf_of(ctx, doc); + pool = fz_new_pool(ctx); + m.regions = regions_of(ctx, fz_parse_json(ctx, pool, json), pdf_count_pages(ctx, pdf), &m.n); + ely_save_copy(ctx, pdf, raw, out, password, NULL, apply_marks, &m); + report = fz_new_buffer(ctx, 128); + check_redacted(ctx, out, password, m.regions, m.n, report); + *len = fz_buffer_extract(ctx, report, data); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, report); + fz_free(ctx, m.regions); + fz_drop_pool(ctx, pool); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + diff --git a/crates/ely-pdf-engine/shim/ely_secure.c b/crates/ely-pdf-engine/shim/ely_secure.c new file mode 100644 index 0000000..bd134f2 --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_secure.c @@ -0,0 +1,74 @@ +/* Permissions and protected copies (PDF-11). */ + +#include + +#include "ely_shim.h" + +enum { MAY_PRINT = 1, MAY_COPY = 2, MAY_EDIT = 4, MAY_ANNOTATE = 8, MAY_FILL = 16, MAY_ASSEMBLE = 32 }; + +/* What the document allows, as Ely's bits. */ +int ely_permissions(fz_context *ctx, fz_document *doc) +{ + int may = 0; + fz_try(ctx) + { + pdf_document *pdf = ely_pdf_of(ctx, doc); + int edit = pdf_has_permission(ctx, pdf, FZ_PERMISSION_EDIT) != 0; + int annotate = pdf_has_permission(ctx, pdf, FZ_PERMISSION_ANNOTATE) != 0; + may |= pdf_has_permission(ctx, pdf, FZ_PERMISSION_PRINT) ? MAY_PRINT : 0; + may |= pdf_has_permission(ctx, pdf, FZ_PERMISSION_COPY) ? MAY_COPY : 0; + may |= edit ? MAY_EDIT : 0; + may |= annotate ? MAY_ANNOTATE : 0; + may |= annotate || pdf_has_permission(ctx, pdf, FZ_PERMISSION_FORM) ? MAY_FILL : 0; + may |= edit || pdf_has_permission(ctx, pdf, FZ_PERMISSION_ASSEMBLE) ? MAY_ASSEMBLE : 0; + } + fz_catch(ctx) + return ely_fail(ctx); + return may; +} + +/* PDF's permission bits for Ely's `may`. */ +static int pdf_bits(int may) +{ + int p = ~(PDF_PERM_PRINT | PDF_PERM_MODIFY | PDF_PERM_COPY | PDF_PERM_ANNOTATE | PDF_PERM_FORM | + PDF_PERM_ASSEMBLE | PDF_PERM_PRINT_HQ | 3); + p |= may & MAY_PRINT ? PDF_PERM_PRINT | PDF_PERM_PRINT_HQ : 0; + p |= may & MAY_COPY ? PDF_PERM_COPY : 0; + p |= may & MAY_EDIT ? PDF_PERM_MODIFY : 0; + p |= may & MAY_ANNOTATE ? PDF_PERM_ANNOTATE : 0; + p |= may & MAY_FILL ? PDF_PERM_FORM : 0; + p |= may & MAY_ASSEMBLE ? PDF_PERM_ASSEMBLE : 0; + return p; +} + +static void put_password(fz_context *ctx, char *into, const char *password) +{ + if (strlen(password) >= 128) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a password longer than 127 bytes"); + strcpy(into, password); +} + +/* Writes `out` with AES-256 and `may`, or unprotected. */ +int ely_protect(fz_context *ctx, fz_document *doc, const char *raw, const char *out, const char *password, + int protect, const char *user, const char *owner, int may) +{ + fz_try(ctx) + { + pdf_write_options opts = pdf_default_write_options; + opts.do_encrypt = protect ? PDF_ENCRYPT_AES_256 : PDF_ENCRYPT_NONE; + if (protect) + { + if (!*owner) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "an owner password is needed"); + if (!strcmp(user, owner)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the owner password equals the open password"); + put_password(ctx, opts.upwd_utf8, user); + put_password(ctx, opts.opwd_utf8, owner); + opts.permissions = pdf_bits(may); + } + ely_save_copy(ctx, ely_pdf_of(ctx, doc), raw, out, password, &opts, NULL, NULL); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} diff --git a/crates/ely-pdf-engine/shim/ely_shim.h b/crates/ely-pdf-engine/shim/ely_shim.h new file mode 100644 index 0000000..6535c51 --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_shim.h @@ -0,0 +1,56 @@ +/* What the shim files share. */ +#ifndef ELY_SHIM_H +#define ELY_SHIM_H + +#include +#include + +int ely_fail(fz_context *ctx); +const char *ely_error(void); +int ely_render(fz_context *ctx, fz_document *doc, int number, float scale, int rotate, const int tile[4], int dark, + unsigned char *bgra); +void ely_fail_also(fz_context *ctx, const char *what); +pdf_document *ely_pdf_of(fz_context *ctx, fz_document *doc); +int ely_edit_failed(fz_context *ctx, pdf_document *pdf, int begun); +void ely_forget_resources(fz_context *ctx, pdf_document *pdf); +pdf_obj *ely_target(fz_context *ctx, pdf_document *pdf, pdf_obj *obj); +typedef void (ely_change)(fz_context *ctx, fz_document *copy, void *arg); +void ely_save_copy(fz_context *ctx, pdf_document *pdf, const char *raw, const char *out, const char *password, + const pdf_write_options *opts, + ely_change *change, void *arg); + +/* A text block as read, page space, and its glyphs. */ +typedef struct +{ + void *glyphs; + fz_font *font; + fz_point first; + float size; + int rgb; + fz_rect bbox; + int lines; + float gap; +} ely_shape; + +fz_matrix ely_to_user(fz_context *ctx, pdf_page *page); +void ely_read_block(fz_context *ctx, fz_document *doc, pdf_page *page, int number, int index, const char *expected, + ely_shape *shape); +void ely_remove_block(fz_context *ctx, pdf_document *pdf, pdf_page *page, ely_shape *shape); +void ely_drop_shape(fz_context *ctx, ely_shape *shape); +pdf_obj *ely_append_content(fz_context *ctx, pdf_document *pdf, pdf_obj *page, fz_buffer *buf); +const char *ely_add_resource(fz_context *ctx, pdf_document *pdf, pdf_obj *page, pdf_obj *type, const char *stem, + pdf_obj *ref, char *name, size_t room); +void ely_json_string(fz_context *ctx, fz_output *out, const char *s); +fz_json *ely_member(fz_context *ctx, fz_json *obj, const char *key, int type); +float ely_number(fz_context *ctx, fz_json *array, int at); +fz_rect ely_rect(fz_context *ctx, fz_json *array, float least); +pdf_annot *ely_find_annot(fz_context *ctx, pdf_page *page, int id, int widget); +void ely_write_fields(fz_context *ctx, fz_output *out, pdf_page *page); +void ely_unlink_field(fz_context *ctx, pdf_document *pdf, pdf_obj *field); +void ely_unsign(fz_context *ctx, pdf_document *pdf); +void ely_watch_begin(fz_context *ctx); +int ely_watch_end(fz_context *ctx); +const char *ely_open_warnings(void); +void ely_drop_alt(fz_context *ctx, pdf_document *pdf, pdf_page *page); + +#endif diff --git a/crates/ely-pdf-engine/shim/ely_sign.c b/crates/ely-pdf-engine/shim/ely_sign.c new file mode 100644 index 0000000..44b4961 --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_sign.c @@ -0,0 +1,409 @@ +/* Signatures: read and checked (PDF-12, QA-15). */ + +#include +#include + +#include +#include + +#include + +#include "ely_shim.h" + +static const char *digest_name(pdf_signature_error error) +{ + switch (error) + { + case PDF_SIGNATURE_ERROR_OKAY: return "ok"; + case PDF_SIGNATURE_ERROR_DIGEST_FAILURE: return "changed"; + case PDF_SIGNATURE_ERROR_NO_CERTIFICATE: return "no_certificate"; + default: return "unreadable"; + } +} + +static void put_hex(fz_context *ctx, fz_output *out, const unsigned char *data, int len) +{ + int i; + fz_write_byte(ctx, out, '"'); + for (i = 0; i < len; i++) + fz_write_printf(ctx, out, "%02x", data[i]); + fz_write_byte(ctx, out, '"'); +} + +static void put_cert(fz_context *ctx, fz_output *out, X509 *cert, int *first) +{ + unsigned char *der = NULL; + int len = i2d_X509(cert, &der); + if (len <= 0) + fz_throw(ctx, FZ_ERROR_FORMAT, "a certificate that will not encode"); + fz_try(ctx) + { + if (!*first) + fz_write_byte(ctx, out, ','); + *first = 0; + put_hex(ctx, out, der, len); + } + fz_always(ctx) + OPENSSL_free(der); + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* The signer's certificate first, then the rest it carries. */ +static void put_certs(fz_context *ctx, fz_output *out, const char *contents, size_t len) +{ + const unsigned char *at = (const unsigned char *)contents; + PKCS7 *p7 = len < INT_MAX ? d2i_PKCS7(NULL, &at, (long)len) : NULL; + STACK_OF(X509) *signers = NULL, *carried; + int i, first = 1; + if (!p7 || !PKCS7_type_is_signed(p7)) + { + PKCS7_free(p7); + fz_throw(ctx, FZ_ERROR_FORMAT, "a signature that is not PKCS #7 signed data"); + } + fz_try(ctx) + { + carried = p7->d.sign->cert; + signers = PKCS7_get0_signers(p7, NULL, 0); + fz_write_string(ctx, out, "["); + for (i = 0; i < sk_X509_num(signers); i++) + put_cert(ctx, out, sk_X509_value(signers, i), &first); + for (i = 0; i < sk_X509_num(carried); i++) + if (sk_X509_find(signers, sk_X509_value(carried, i)) < 0) + put_cert(ctx, out, sk_X509_value(carried, i), &first); + fz_write_string(ctx, out, "]"); + } + fz_always(ctx) + { + sk_X509_free(signers); + PKCS7_free(p7); + } + fz_catch(ctx) + fz_rethrow(ctx); +} + +static void put_text(fz_context *ctx, fz_output *out, const char *key, pdf_obj *value) +{ + fz_write_printf(ctx, out, ",\"%s\":", key); + ely_json_string(ctx, out, pdf_is_string(ctx, value) ? pdf_to_text_string(ctx, value) : ""); +} + +static void put_signed(fz_context *ctx, fz_output *out, pdf_document *pdf, pdf_obj *field, pdf_pkcs7_verifier *verifier) +{ + pdf_obj *v = pdf_dict_get_inheritable(ctx, field, PDF_NAME(V)); + pdf_pkcs7_distinguished_name *dn = pdf_signature_get_signatory(ctx, verifier, pdf, field); + char *signer = NULL, *contents = NULL; + size_t len; + fz_var(signer); + fz_var(contents); + fz_try(ctx) + { + signer = dn ? pdf_signature_format_distinguished_name(ctx, dn) : NULL; + fz_write_printf(ctx, out, ",\"digest\":\"%s\"", digest_name(pdf_check_digest(ctx, verifier, pdf, field))); + fz_write_printf(ctx, out, ",\"later\":%d", pdf_signature_incremental_change_since_signing(ctx, pdf, field)); + fz_write_printf(ctx, out, ",\"broken\":%d", pdf_validate_signature(ctx, pdf, field)); + fz_write_string(ctx, out, ",\"signer\":"); + ely_json_string(ctx, out, signer ? signer : ""); + fz_write_string(ctx, out, ",\"common\":"); + ely_json_string(ctx, out, dn && dn->cn ? dn->cn : ""); + put_text(ctx, out, "date", pdf_dict_get(ctx, v, PDF_NAME(M))); + put_text(ctx, out, "reason", pdf_dict_get(ctx, v, PDF_NAME(Reason))); + put_text(ctx, out, "location", pdf_dict_get(ctx, v, PDF_NAME(Location))); + len = pdf_signature_contents(ctx, pdf, field, &contents); + fz_write_string(ctx, out, ",\"certs\":"); + put_certs(ctx, out, contents, len); + } + fz_always(ctx) + { + fz_free(ctx, contents); + fz_free(ctx, signer); + pdf_signature_drop_distinguished_name(ctx, dn); + } + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* A broken signature reports why; the others still list. */ +static void put_checked(fz_context *ctx, fz_output *out, pdf_document *pdf, pdf_obj *field, pdf_pkcs7_verifier *verifier) +{ + fz_buffer *buf = fz_new_buffer(ctx, 256); + fz_output *part = NULL; + char why[256] = ""; + fz_var(part); + fz_try(ctx) + { + part = fz_new_output_with_buffer(ctx, buf); + put_signed(ctx, part, pdf, field, verifier); + fz_close_output(ctx, part); + } + fz_always(ctx) + fz_drop_output(ctx, part); + fz_catch(ctx) + { + fz_strlcpy(why, fz_caught_message(ctx), sizeof why); + fz_ignore_error(ctx); + } + fz_try(ctx) + { + if (*why) + { + fz_write_string(ctx, out, ",\"digest\":\"unreadable\",\"error\":"); + ely_json_string(ctx, out, why); + } + else + fz_write_buffer(ctx, out, buf); + } + fz_always(ctx) + fz_drop_buffer(ctx, buf); + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* A widget's page and page-space box; -1 for none. */ +static int placed(fz_context *ctx, pdf_document *pdf, pdf_obj *field, fz_rect *box) +{ + pdf_obj *widget = pdf_name_eq(ctx, pdf_dict_get(ctx, field, PDF_NAME(Subtype)), PDF_NAME(Widget)) ? field : + pdf_array_get(ctx, pdf_dict_get(ctx, field, PDF_NAME(Kids)), 0); + pdf_obj *page = pdf_dict_get(ctx, widget, PDF_NAME(P)); + fz_matrix ctm; + int i, k, number = page ? pdf_lookup_page_number(ctx, pdf, page) : -1; + /* /P is optional; the page's /Annots tell. */ + for (i = 0; number < 0 && i < pdf_count_pages(ctx, pdf); i++) + { + pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, pdf, i), PDF_NAME(Annots)); + for (k = 0; k < pdf_array_len(ctx, annots); k++) + if (pdf_to_num(ctx, pdf_array_get(ctx, annots, k)) == pdf_to_num(ctx, widget)) + number = i, page = pdf_lookup_page_obj(ctx, pdf, i); + } + *box = fz_empty_rect; + if (number >= 0) + { + pdf_page_obj_transform(ctx, page, NULL, &ctm); + *box = fz_transform_rect(pdf_dict_get_rect(ctx, widget, PDF_NAME(Rect)), ctm); + } + return number; +} + +static void put_field(fz_context *ctx, fz_output *out, pdf_document *pdf, pdf_obj *field, pdf_pkcs7_verifier *verifier, int *first) +{ + fz_rect r; + int number = placed(ctx, pdf, field, &r), signd = pdf_signature_is_signed(ctx, pdf, field); + char *name = pdf_load_field_name(ctx, field); + fz_try(ctx) + { + fz_write_printf(ctx, out, "%s{\"page\":%d,\"object\":%d,\"rect\":[%g,%g,%g,%g],\"signed\":%s,\"name\":", + *first ? "" : ",", number, pdf_to_num(ctx, field), r.x0, r.y0, r.x1, r.y1, signd ? "true" : "false"); + *first = 0; + ely_json_string(ctx, out, name); + if (signd) + put_checked(ctx, out, pdf, field, verifier); + fz_write_byte(ctx, out, '}'); + } + fz_always(ctx) + fz_free(ctx, name); + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* Signature fields in the form tree, placed or not. */ +static void put_tree(fz_context *ctx, fz_output *out, pdf_document *pdf, pdf_obj *node, pdf_pkcs7_verifier *verifier, int *first) +{ + pdf_obj *kids = pdf_dict_get(ctx, node, PDF_NAME(Kids)); + int i, leaf = 1; + if (pdf_mark_obj(ctx, node)) + return; + fz_try(ctx) + { + for (i = 0; i < pdf_array_len(ctx, kids); i++) + leaf &= pdf_dict_get(ctx, pdf_array_get(ctx, kids, i), PDF_NAME(T)) == NULL; + if (!leaf) + for (i = 0; i < pdf_array_len(ctx, kids); i++) + put_tree(ctx, out, pdf, pdf_array_get(ctx, kids, i), verifier, first); + else if (pdf_name_eq(ctx, pdf_dict_get_inheritable(ctx, node, PDF_NAME(FT)), PDF_NAME(Sig))) + put_field(ctx, out, pdf, node, verifier, first); + } + fz_always(ctx) + pdf_unmark_obj(ctx, node); + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* Every signature field, as JSON, with its check. */ +int ely_signatures(fz_context *ctx, fz_document *doc, unsigned char **data, size_t *len) +{ + pdf_pkcs7_verifier *verifier = NULL; + fz_buffer *buf = NULL; + fz_output *out = NULL; + fz_var(verifier); + fz_var(buf); + fz_var(out); + fz_try(ctx) + { + pdf_document *pdf = ely_pdf_of(ctx, doc); + pdf_obj *fields = pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields"); + int i, first = 1; + verifier = pkcs7_openssl_new_verifier(ctx); + buf = fz_new_buffer(ctx, 256); + out = fz_new_output_with_buffer(ctx, buf); + fz_write_byte(ctx, out, '['); + for (i = 0; i < pdf_array_len(ctx, fields); i++) + put_tree(ctx, out, pdf, pdf_array_get(ctx, fields, i), verifier, &first); + fz_write_byte(ctx, out, ']'); + fz_close_output(ctx, out); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_drop_output(ctx, out); + fz_drop_buffer(ctx, buf); + pdf_drop_verifier(ctx, verifier); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* An empty field by number, or a new one. */ +static pdf_annot *sign_field(fz_context *ctx, pdf_document *pdf, pdf_page *page, int object, const float *rect, const char *name) +{ + pdf_obj *form = pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields"); + pdf_annot *widget; + if (object >= 0) + { + widget = ely_find_annot(ctx, page, object, 1); + if (pdf_widget_type(ctx, widget) != PDF_WIDGET_TYPE_SIGNATURE) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "field %d is not a signature field", object); + if (pdf_signature_is_signed(ctx, pdf, pdf_annot_obj(ctx, widget))) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "field %d is signed already", object); + return widget; + } + if (!*name || strchr(name, '.') || (form && pdf_lookup_field(ctx, form, name))) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "a signature field needs a new name without dots"); + widget = pdf_create_signature_widget(ctx, page, (char *)name); + pdf_set_annot_rect(ctx, widget, fz_make_rect(rect[0], rect[1], rect[2], rect[3])); + return widget; +} + +/* Signs into `raw` by an update, then checks it. */ +int ely_sign(fz_context *ctx, fz_document *doc, const char *raw, const char *password, const char *pfx, const char *pfx_password, + int number, int object, const float *rect, const char *name, const char *reason, const char *location) +{ + pdf_pkcs7_signer *signer = NULL; + pdf_pkcs7_verifier *verifier = NULL; + fz_document *copy = NULL, *signd = NULL; + pdf_page *page = NULL; + pdf_obj *made = NULL; + fz_var(made); + fz_var(signer); + fz_var(verifier); + fz_var(copy); + fz_var(signd); + fz_var(page); + fz_try(ctx) + { + pdf_write_options update = pdf_default_write_options; + pdf_document *pdf; + pdf_annot *widget; + int field; + signer = pkcs7_openssl_read_pfx(ctx, pfx, pfx_password); + pdf_save_snapshot(ctx, ely_pdf_of(ctx, doc), raw); + copy = fz_open_document(ctx, raw); + if (fz_needs_password(ctx, copy) && !fz_authenticate_password(ctx, copy, password)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the copy refused its password"); + pdf = ely_pdf_of(ctx, copy); + page = pdf_load_page(ctx, pdf, number); + widget = sign_field(ctx, pdf, page, object, rect, name); + field = pdf_to_num(ctx, pdf_annot_obj(ctx, widget)); + pdf_sign_signature(ctx, widget, signer, PDF_SIGNATURE_SHOW_LABELS | PDF_SIGNATURE_SHOW_DATE | PDF_SIGNATURE_SHOW_TEXT_NAME, NULL, reason, location); + /* Earlier signatures cover earlier bytes; only append. */ + update.do_incremental = 1; + pdf_save_document(ctx, pdf, raw, &update); + fz_drop_page(ctx, (fz_page *)page); + page = NULL; + signd = fz_open_document(ctx, raw); + if (fz_needs_password(ctx, signd) && !fz_authenticate_password(ctx, signd, password)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the signed copy refused its password"); + verifier = pkcs7_openssl_new_verifier(ctx); + made = pdf_new_indirect(ctx, ely_pdf_of(ctx, signd), field, 0); + if (pdf_check_digest(ctx, verifier, ely_pdf_of(ctx, signd), made) != PDF_SIGNATURE_ERROR_OKAY) + fz_throw(ctx, FZ_ERROR_FORMAT, "the new signature does not check"); + } + fz_always(ctx) + { + pdf_drop_obj(ctx, made); + fz_drop_page(ctx, (fz_page *)page); + fz_drop_document(ctx, signd); + fz_drop_document(ctx, copy); + pdf_drop_verifier(ctx, verifier); + pdf_drop_signer(ctx, signer); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* Signed fields under `field`; with `drop`, their values go. */ +static int signed_under(fz_context *ctx, pdf_obj *field, int drop) +{ + pdf_obj *kids = pdf_dict_get(ctx, field, PDF_NAME(Kids)); + int i, n = 0; + if (pdf_mark_obj(ctx, field)) + return 0; + fz_try(ctx) + { + if (pdf_name_eq(ctx, pdf_dict_get_inheritable(ctx, field, PDF_NAME(FT)), PDF_NAME(Sig)) && + pdf_is_dict(ctx, pdf_dict_get(ctx, field, PDF_NAME(V)))) + { + n++; + if (drop) + { + /* The drawn signature would still claim it. */ + pdf_dict_del(ctx, field, PDF_NAME(V)); + pdf_dict_del(ctx, field, PDF_NAME(AP)); + /* Its own lock went with it; it can sign again. */ + pdf_dict_put_int(ctx, field, PDF_NAME(Ff), pdf_dict_get_int(ctx, field, PDF_NAME(Ff)) & ~PDF_FIELD_IS_READ_ONLY); + for (i = 0; i < pdf_array_len(ctx, kids); i++) + pdf_dict_del(ctx, pdf_array_get(ctx, kids, i), PDF_NAME(AP)); + } + } + for (i = 0; i < pdf_array_len(ctx, kids); i++) + n += signed_under(ctx, pdf_array_get(ctx, kids, i), drop); + } + fz_always(ctx) + pdf_unmark_obj(ctx, field); + fz_catch(ctx) + fz_rethrow(ctx); + return n; +} + +/* SigFlags bit 2; MuPDF keeps its name private. */ +enum { APPEND_ONLY = 2 }; + +static int signed_fields(fz_context *ctx, pdf_document *pdf, int drop) +{ + pdf_obj *form = pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm"); + pdf_obj *fields = pdf_dict_get(ctx, form, PDF_NAME(Fields)); + int i, n = 0; + for (i = 0; i < pdf_array_len(ctx, fields); i++) + n += signed_under(ctx, pdf_array_get(ctx, fields, i), drop); + if (drop && n) + pdf_dict_put_int(ctx, form, PDF_NAME(SigFlags), pdf_dict_get_int(ctx, form, PDF_NAME(SigFlags)) & ~APPEND_ONLY); + return n; +} + +/* Ely's whole-file writes break signatures, so carry none. */ +void ely_unsign(fz_context *ctx, pdf_document *pdf) +{ + signed_fields(ctx, pdf, 1); +} + +/* How many signed fields the form holds. */ +int ely_signed(fz_context *ctx, fz_document *doc) +{ + int n = 0; + fz_try(ctx) + n = signed_fields(ctx, ely_pdf_of(ctx, doc), 0); + fz_catch(ctx) + return ely_fail(ctx); + return n; +} diff --git a/crates/ely-pdf-engine/shim/ely_text.c b/crates/ely-pdf-engine/shim/ely_text.c new file mode 100644 index 0000000..8882a0c --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_text.c @@ -0,0 +1,294 @@ +/* Text blocks: list, take out glyphs, write new text (PDF-03). */ + +#include +#include + +#include "ely_shim.h" + +/* The page's text, read the same way every time. */ +static fz_stext_page *read_text(fz_context *ctx, fz_document *doc, int number) +{ + fz_stext_options opts = { 0 }; + return fz_new_stext_page_from_page_number(ctx, doc, number, &opts); +} + +/* The `index`th text block, or NULL. */ +static fz_stext_block *nth_block(fz_stext_page *text, int index) +{ + fz_stext_block *block; + for (block = text->first_block; block; block = block->next) + if (block->type == FZ_STEXT_BLOCK_TEXT && index-- == 0) + return block; + return NULL; +} + +/* Glyph origins to take out, in user space. */ +typedef struct +{ + fz_point *at; + int *c; + int n; + int hit; +} targets; + +/* The glyphs of `block` with their user-space origins. */ +static void block_targets(fz_context *ctx, fz_stext_block *block, fz_matrix to_user, targets *t) +{ + fz_stext_line *line; + fz_stext_char *ch; + int n = 0; + for (line = block->u.t.first_line; line; line = line->next) + for (ch = line->first_char; ch; ch = ch->next) + n++; + t->at = fz_malloc_array(ctx, n + 1, fz_point); + t->c = fz_malloc_array(ctx, n + 1, int); + t->n = t->hit = 0; + for (line = block->u.t.first_line; line; line = line->next) + for (ch = line->first_char; ch; ch = ch->next) + if (!(ch->flags & FZ_STEXT_SYNTHETIC)) + { + t->at[t->n] = fz_transform_point(ch->origin, to_user); + t->c[t->n++] = ch->c; + } +} + +/* Removes a glyph drawn at one of the targets. */ +static int take_glyph(fz_context *ctx, void *opaque, int *ucs, int ucslen, fz_matrix trm, fz_matrix ctm, + fz_rect bbox, int tr, float ca, float CA) +{ + targets *t = opaque; + fz_point at = fz_transform_point(fz_transform_point(fz_make_point(0, 0), trm), ctm); + int i, k; + for (i = 0; i < t->n; i++) + { + if (fabsf(at.x - t->at[i].x) > 0.5f || fabsf(at.y - t->at[i].y) > 0.5f) + continue; + for (k = 0; k < ucslen; k++) + if (ucs[k] == t->c[i] || t->c[i] == FZ_REPLACEMENT_CHARACTER) + { + t->hit++; + return 1; + } + } + return 0; +} + +/* Runs the glyph filter over the page; `dry` changes nothing. */ +static void filter_glyphs(fz_context *ctx, pdf_document *pdf, pdf_page *page, targets *t, int dry) +{ + pdf_sanitize_filter_options sopts = { 0 }; + pdf_filter_factory list[2] = { { pdf_new_sanitize_filter, NULL }, { NULL, NULL } }; + pdf_filter_options opts = { 0 }; + sopts.opaque = t; + sopts.text_filter = take_glyph; + list[0].options = &sopts; + opts.filters = list; + opts.no_update = dry; + t->hit = 0; + pdf_filter_page_contents(ctx, pdf, page, &opts); +} + +/* User space from the page space MuPDF reports in. */ +fz_matrix ely_to_user(fz_context *ctx, pdf_page *page) +{ + fz_matrix ctm; + pdf_page_transform(ctx, page, NULL, &ctm); + return fz_invert_matrix(ctm); +} + +/* Whether all of `block` sits in the page's own content. */ +static int reachable(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stext_block *block) +{ + targets t = { NULL, NULL, 0, 0 }; + int ok = 0; + fz_var(t.at); + fz_var(t.c); + pdf_begin_operation(ctx, pdf, "Look at text"); + fz_try(ctx) + { + block_targets(ctx, block, ely_to_user(ctx, page), &t); + filter_glyphs(ctx, pdf, page, &t, 1); + ok = t.n > 0 && t.hit == t.n; + } + fz_always(ctx) + { + pdf_abandon_operation(ctx, pdf); + ely_forget_resources(ctx, pdf); + fz_free(ctx, t.at); + fz_free(ctx, t.c); + } + fz_catch(ctx) + fz_rethrow(ctx); + return ok; +} + +/* The text of `block`, lines on lines. */ +static void block_text(fz_context *ctx, fz_buffer *buf, fz_stext_block *block) +{ + fz_stext_line *line; + fz_stext_char *ch; + for (line = block->u.t.first_line; line; line = line->next) + { + if (line != block->u.t.first_line) + fz_append_byte(ctx, buf, '\n'); + for (ch = line->first_char; ch; ch = ch->next) + fz_append_rune(ctx, buf, ch->c); + } +} + +/* Horizontal lines only; turned text is not edited. */ +static int upright(fz_stext_block *block) +{ + fz_stext_line *line; + for (line = block->u.t.first_line; line; line = line->next) + if (line->wmode || fabsf(line->dir.x - 1) > 0.01f) + return 0; + return 1; +} + +/* JSON of the page's text blocks. */ +int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char **data, size_t *len) +{ + fz_stext_page *text = NULL; + pdf_page *page = NULL; + fz_buffer *buf = NULL, *words = NULL; + fz_output *out = NULL; + fz_var(text); + fz_var(page); + fz_var(buf); + fz_var(words); + fz_var(out); + fz_try(ctx) + { + pdf_document *pdf = ely_pdf_of(ctx, doc); + fz_stext_block *block; + int first = 1; + text = read_text(ctx, doc, number); + page = pdf_load_page(ctx, pdf, number); + buf = fz_new_buffer(ctx, 1024); + out = fz_new_output_with_buffer(ctx, buf); + fz_write_string(ctx, out, "["); + for (block = text->first_block; block; block = block->next) + { + fz_stext_char *ch; + int editable; + if (block->type != FZ_STEXT_BLOCK_TEXT) + continue; + ch = block->u.t.first_line->first_char; + editable = upright(block) && reachable(ctx, pdf, page, block); + fz_drop_buffer(ctx, words); + words = NULL; + words = fz_new_buffer(ctx, 256); + block_text(ctx, words, block); + fz_terminate_buffer(ctx, words); + fz_write_printf(ctx, out, "%s{\"rect\":[%g,%g,%g,%g],\"text\":%q,\"font\":%q,\"size\":%g,\"color\":%d,\"editable\":%s}", + first ? "" : ",", block->bbox.x0, block->bbox.y0, block->bbox.x1, block->bbox.y1, + fz_string_from_buffer(ctx, words), fz_font_name(ctx, ch->font), ch->size, (int)(ch->argb & 0xffffff), + editable ? "true" : "false"); + first = 0; + } + fz_write_string(ctx, out, "]"); + fz_close_output(ctx, out); + *len = fz_buffer_extract(ctx, buf, data); + } + fz_always(ctx) + { + fz_drop_output(ctx, out); + fz_drop_buffer(ctx, buf); + fz_drop_buffer(ctx, words); + fz_drop_page(ctx, (fz_page *)page); + fz_drop_stext_page(ctx, text); + } + fz_catch(ctx) + return ely_fail(ctx); + return 0; +} + +/* Distinct baselines, and the gap between the first two. */ +static void baselines(fz_stext_block *block, ely_shape *shape) +{ + fz_stext_line *line; + float last = block->u.t.first_line->first_char->origin.y; + shape->lines = 1; + shape->gap = 0; + for (line = block->u.t.first_line->next; line; line = line->next) + { + float y = line->first_char->origin.y; + if (fabsf(y - last) <= 0.5f) + continue; + if (shape->lines == 1) + shape->gap = y - shape->first.y; + shape->lines++; + last = y; + } + /* Runs on one baseline: an ordinary line gap. */ + if (shape->gap <= 0) + shape->gap = shape->size * 1.2f; +} + +/* Reads block `index` and checks it; nothing changes. */ +void ely_read_block(fz_context *ctx, fz_document *doc, pdf_page *page, int number, int index, const char *expected, + ely_shape *shape) +{ + pdf_document *pdf = ely_pdf_of(ctx, doc); + fz_stext_page *text = NULL; + fz_buffer *words = NULL; + targets *t = fz_malloc_struct(ctx, targets); + shape->glyphs = t; + fz_var(text); + fz_var(words); + fz_try(ctx) + { + fz_stext_block *block; + fz_stext_char *ch; + text = read_text(ctx, doc, number); + block = nth_block(text, index); + if (!block) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no text block %d", index); + words = fz_new_buffer(ctx, 256); + block_text(ctx, words, block); + fz_terminate_buffer(ctx, words); + if (strcmp(fz_string_from_buffer(ctx, words), expected)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the text changed since it was read"); + if (!upright(block) || !reachable(ctx, pdf, page, block)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "this text is not in the page's own content"); + block_targets(ctx, block, ely_to_user(ctx, page), t); + ch = block->u.t.first_line->first_char; + shape->font = fz_keep_font(ctx, ch->font); + shape->first = ch->origin; + shape->size = ch->size; + shape->rgb = (int)(ch->argb & 0xffffff); + shape->bbox = block->bbox; + baselines(block, shape); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, words); + fz_drop_stext_page(ctx, text); + } + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* Takes out the glyphs `ely_read_block` found. */ +void ely_remove_block(fz_context *ctx, pdf_document *pdf, pdf_page *page, ely_shape *shape) +{ + targets *t = shape->glyphs; + filter_glyphs(ctx, pdf, page, t, 0); + if (t->hit != t->n) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "%d of %d glyphs were found", t->hit, t->n); +} + +void ely_drop_shape(fz_context *ctx, ely_shape *shape) +{ + targets *t = shape->glyphs; + fz_drop_font(ctx, shape->font); + if (t) + { + fz_free(ctx, t->at); + fz_free(ctx, t->c); + fz_free(ctx, t); + } + shape->font = NULL; + shape->glyphs = NULL; +} diff --git a/crates/ely-pdf-engine/shim/ely_write.c b/crates/ely-pdf-engine/shim/ely_write.c new file mode 100644 index 0000000..bd94547 --- /dev/null +++ b/crates/ely-pdf-engine/shim/ely_write.c @@ -0,0 +1,449 @@ +/* New text on a page: a pen, lines, content (PDF-03). */ + +#include +#include + +#include "ely_shim.h" + +typedef enum +{ + PEN_CID, + PEN_SIMPLE, + PEN_CJK +} pen_kind; + +/* A font to write with and its resource. */ +typedef struct +{ + pen_kind kind; + fz_font *font; + int ordering; + pdf_obj *ref; +} pen; + +/* What to write, where, in what. */ +typedef struct +{ + const char *text; + const char *font; + float size; + int rgb; + /* Page space: left, top, width. */ + float box[3]; +} text_args; + +/* The script face for typed signatures, from the worker. */ +static const unsigned char *script_data; +static size_t script_len; + +void ely_script_font(const unsigned char *data, size_t len) +{ + script_data = data; + script_len = len; +} + +/* The pen named `choice`; "original" writes in `own`. */ +static void choose_pen(fz_context *ctx, const char *choice, fz_font *own, pen *p) +{ + static const struct { const char *choice, *name; } latin[] = { + { "helvetica", "Helvetica" }, { "times", "Times-Roman" }, { "courier", "Courier" } }; + static const struct { const char *choice; int ordering; } cjk[] = { + { "cjk-sc", FZ_ADOBE_GB }, { "cjk-tc", FZ_ADOBE_CNS }, { "cjk-jp", FZ_ADOBE_JAPAN }, { "cjk-kr", FZ_ADOBE_KOREA } }; + size_t i; + if (!strcmp(choice, "script")) + { + if (!script_data) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no script font was given"); + p->kind = PEN_CID; + p->font = fz_new_font_from_memory(ctx, "GreatVibes", script_data, (int)script_len, 0, 0); + return; + } + if (!strcmp(choice, "original")) + { + if (!own || !pdf_font_writing_supported(ctx, own)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the document's font cannot write new text"); + p->kind = PEN_CID; + p->font = fz_keep_font(ctx, own); + return; + } + for (i = 0; i < nelem(latin); i++) + if (!strcmp(choice, latin[i].choice)) + { + p->kind = PEN_SIMPLE; + p->font = fz_new_base14_font(ctx, latin[i].name); + return; + } + for (i = 0; i < nelem(cjk); i++) + if (!strcmp(choice, cjk[i].choice)) + { + p->kind = PEN_CJK; + p->ordering = cjk[i].ordering; + p->font = fz_new_cjk_font(ctx, cjk[i].ordering); + return; + } + fz_throw(ctx, FZ_ERROR_ARGUMENT, "no font %s", choice); +} + +/* The pen's font as a resource of `pdf`. */ +static void add_pen(fz_context *ctx, pdf_document *pdf, pen *p) +{ + switch (p->kind) + { + case PEN_CID: + p->ref = pdf_add_cid_font(ctx, pdf, p->font); + break; + case PEN_SIMPLE: + p->ref = pdf_add_simple_font(ctx, pdf, p->font, PDF_SIMPLE_ENCODING_LATIN); + break; + case PEN_CJK: + p->ref = pdf_add_cjk_font(ctx, pdf, p->font, p->ordering, 0, 0); + break; + } +} + +/* The pen's glyph for `u`; 0 when it has none. */ +static int glyph(fz_context *ctx, pen *p, int u) +{ + if (p->kind == PEN_SIMPLE && fz_windows_1252_from_unicode(u) < 0) + return 0; + return fz_encode_character(ctx, p->font, u); +} + +/* `u` as the pen's code, in hex. */ +static void code(fz_context *ctx, fz_buffer *buf, pen *p, int u, int gid) +{ + switch (p->kind) + { + case PEN_CID: + fz_append_printf(ctx, buf, "%04x", gid); + break; + case PEN_SIMPLE: + fz_append_printf(ctx, buf, "%02x", fz_windows_1252_from_unicode(u)); + break; + case PEN_CJK: + if (u > 0xffff) + fz_append_printf(ctx, buf, "%04x%04x", 0xd800 + ((u - 0x10000) >> 10), 0xdc00 + ((u - 0x10000) & 0x3ff)); + else + fz_append_printf(ctx, buf, "%04x", u); + break; + } +} + +/* `text` as code points; the count is returned. */ +static int runes(fz_context *ctx, const char *text, int **out) +{ + int n = 0, c; + const char *s; + for (s = text; *s; n++) + s += fz_chartorune(&c, s); + *out = fz_malloc_array(ctx, n + 1, int); + for (n = 0, s = text; *s; n++) + s += fz_chartorune(&(*out)[n], s); + return n; +} + +static float advance(fz_context *ctx, pen *p, int u, float size) +{ + return fz_advance_glyph(ctx, p->font, glyph(ctx, p, u), 0) * size; +} + +/* Lines no wider than `width`: [from, to) each. */ +static int lay_out(fz_context *ctx, pen *p, const int *u, int n, float size, float width, int *from, int *to, + float *widest) +{ + int i = 0, lines = 0; + *widest = 0; + while (i <= n) + { + int j, space = -1; + float run = 0, before = 0; + for (j = i; j < n && u[j] != '\n'; j++) + { + float w = advance(ctx, p, u[j], size); + if (run + w > width && j > i) + break; + if (u[j] == ' ') + space = j, before = run; + run += w; + } + from[lines] = i; + if (j < n && u[j] != '\n' && space > i) + { + /* Too long for the line: wrap at a space. */ + to[lines++] = space; + *widest = fz_max(*widest, before); + i = space + 1; + } + else + { + to[lines++] = j; + *widest = fz_max(*widest, run); + i = j < n && u[j] != '\n' ? j : j + 1; + } + } + return lines; +} + +/* A resource name in `type` that is free, given `ref`. */ +const char *ely_add_resource(fz_context *ctx, pdf_document *pdf, pdf_obj *page, pdf_obj *type, const char *stem, + pdf_obj *ref, char *name, size_t room) +{ + pdf_obj *res = pdf_dict_get_inheritable(ctx, page, PDF_NAME(Resources)); + pdf_obj *dict; + int n; + if (!res) + res = pdf_dict_put_dict(ctx, page, PDF_NAME(Resources), 2); + dict = pdf_dict_get(ctx, res, type); + if (!dict) + dict = pdf_dict_put_dict(ctx, res, type, 2); + for (n = 1;; n++) + { + fz_snprintf(name, room, "%s%d", stem, n); + if (!pdf_dict_gets(ctx, dict, name)) + break; + } + pdf_dict_puts(ctx, dict, name, ref); + return name; +} + +/* The marker of a page whose own content Ely wrapped. */ +static const char wrap[] = "q\n%Ely\n"; + +/* Adds `buf` after the page's content; the new stream. */ +pdf_obj *ely_append_content(fz_context *ctx, pdf_document *pdf, pdf_obj *page, fz_buffer *buf) +{ + pdf_obj *contents = pdf_dict_get(ctx, page, PDF_NAME(Contents)); + pdf_obj *list, *first = pdf_is_array(ctx, contents) ? pdf_array_get(ctx, contents, 0) : NULL; + fz_buffer *head = NULL, *mark = first ? pdf_load_stream(ctx, first) : NULL; + pdf_obj *added = NULL; + int wrapped; + fz_var(head); + fz_try(ctx) + { + wrapped = mark && mark->len == strlen(wrap) && !memcmp(mark->data, wrap, mark->len); + if (wrapped) + list = contents; + else + { + list = pdf_new_array(ctx, pdf, 4); + head = fz_new_buffer_from_copied_data(ctx, (const unsigned char *)wrap, strlen(wrap)); + pdf_array_push_drop(ctx, list, pdf_add_stream(ctx, pdf, head, NULL, 0)); + if (pdf_is_array(ctx, contents)) + { + int i; + for (i = 0; i < pdf_array_len(ctx, contents); i++) + pdf_array_push(ctx, list, pdf_array_get(ctx, contents, i)); + } + else if (contents) + pdf_array_push(ctx, list, contents); + fz_drop_buffer(ctx, head); + head = fz_new_buffer_from_copied_data(ctx, (const unsigned char *)"Q\n", 2); + pdf_array_push_drop(ctx, list, pdf_add_stream(ctx, pdf, head, NULL, 0)); + pdf_dict_put_drop(ctx, page, PDF_NAME(Contents), list); + } + added = pdf_add_stream(ctx, pdf, buf, NULL, 0); + pdf_array_push_drop(ctx, list, added); + } + fz_always(ctx) + { + fz_drop_buffer(ctx, head); + fz_drop_buffer(ctx, mark); + } + fz_catch(ctx) + fz_rethrow(ctx); + return added; +} + +/* Text laid out: the pen, code points, lines, placement. */ +typedef struct +{ + pen p; + int *u, n; + int *from, *to, lines; + float widest; + fz_point first; + float gap; +} laid; + +static void drop_laid(fz_context *ctx, laid *l) +{ + fz_drop_font(ctx, l->p.font); + pdf_drop_obj(ctx, l->p.ref); + fz_free(ctx, l->u); + fz_free(ctx, l->from); + fz_free(ctx, l->to); +} + +/* Lays `a` out from `first`, or names a missing glyph. */ +static int lay_text(fz_context *ctx, text_args *a, fz_font *own, fz_point first, float gap, laid *l) +{ + int i; + choose_pen(ctx, a->font, own, &l->p); + l->n = runes(ctx, a->text, &l->u); + for (i = 0; i < l->n; i++) + if (l->u[i] != '\n' && !glyph(ctx, &l->p, l->u[i])) + return l->u[i]; + l->from = fz_malloc_array(ctx, l->n + 2, int); + l->to = fz_malloc_array(ctx, l->n + 2, int); + l->lines = lay_out(ctx, &l->p, l->u, l->n, a->size, a->box[2], l->from, l->to, &l->widest); + l->first = first; + l->gap = gap; + return 0; +} + +/* The area laid text covers, in page space. */ +static fz_rect laid_area(laid *l, float size) +{ + return fz_make_rect(l->first.x, l->first.y - size, l->first.x + l->widest, l->first.y + (l->lines - 1) * l->gap + size * 0.3f); +} + +/* Writes laid text onto `page` of `pdf`. */ +static void draw_laid(fz_context *ctx, pdf_document *pdf, pdf_page *page, laid *l, text_args *a) +{ + fz_buffer *buf = NULL; + fz_var(buf); + fz_try(ctx) + { + fz_matrix to_user = ely_to_user(ctx, page); + char name[32]; + int i, line; + pdf_drop_obj(ctx, l->p.ref); + l->p.ref = NULL; + add_pen(ctx, pdf, &l->p); + ely_add_resource(ctx, pdf, page->obj, PDF_NAME(Font), "ElyF", l->p.ref, name, sizeof name); + buf = fz_new_buffer(ctx, 256); + fz_append_string(ctx, buf, "q\n"); + for (line = 0; line < l->lines; line++) + { + fz_matrix tm = fz_concat(fz_make_matrix(1, 0, 0, -1, l->first.x, l->first.y + line * l->gap), to_user); + fz_append_printf(ctx, buf, "BT /%s %g Tf %g %g %g rg %g %g %g %g %g %g Tm <", name, a->size, + ((a->rgb >> 16) & 255) / 255.0f, ((a->rgb >> 8) & 255) / 255.0f, (a->rgb & 255) / 255.0f, + tm.a, tm.b, tm.c, tm.d, tm.e, tm.f); + for (i = l->from[line]; i < l->to[line]; i++) + code(ctx, buf, &l->p, l->u[i], glyph(ctx, &l->p, l->u[i])); + fz_append_string(ctx, buf, "> Tj ET\n"); + } + fz_append_string(ctx, buf, "Q\n"); + ely_append_content(ctx, pdf, page->obj, buf); + } + fz_always(ctx) + fz_drop_buffer(ctx, buf); + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* Edits a snapshot copy and draws `region`; the document stays. */ +static void preview(fz_context *ctx, pdf_document *pdf, int number, int block, const char *expected, text_args *a, + laid *l, const char *password, fz_rect region, float scale, unsigned char **pixels, int *w, int *h) +{ + fz_buffer *buf = fz_new_buffer(ctx, 4096); + fz_output *out = NULL; + fz_document *copy = NULL; + pdf_page *page = NULL; + ely_shape shape = { NULL }; + fz_var(out); + fz_var(copy); + fz_var(page); + fz_try(ctx) + { + int tile[4]; + out = fz_new_output_with_buffer(ctx, buf); + pdf_write_snapshot(ctx, pdf, out); + fz_close_output(ctx, out); + copy = fz_open_document_with_buffer(ctx, "application/pdf", buf); + if (fz_needs_password(ctx, copy) && !fz_authenticate_password(ctx, copy, password)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, "the copy refused its password"); + page = pdf_load_page(ctx, ely_pdf_of(ctx, copy), number); + if (block >= 0) + { + ely_read_block(ctx, copy, page, number, block, expected, &shape); + ely_remove_block(ctx, ely_pdf_of(ctx, copy), page, &shape); + } + draw_laid(ctx, ely_pdf_of(ctx, copy), page, l, a); + region = fz_expand_rect(region, 8); + tile[0] = (int)floorf(region.x0 * scale), tile[1] = (int)floorf(region.y0 * scale); + tile[2] = (int)ceilf(region.x1 * scale) - tile[0], tile[3] = (int)ceilf(region.y1 * scale) - tile[1]; + *pixels = fz_malloc(ctx, (size_t)tile[2] * tile[3] * 4); + if (ely_render(ctx, copy, number, scale, 0, tile, 0, *pixels) < 0) + fz_throw(ctx, FZ_ERROR_GENERIC, "%s", ely_error()); + *w = tile[2], *h = tile[3]; + } + fz_always(ctx) + { + ely_drop_shape(ctx, &shape); + fz_drop_page(ctx, (fz_page *)page); + fz_drop_document(ctx, copy); + fz_drop_output(ctx, out); + fz_drop_buffer(ctx, buf); + } + fz_catch(ctx) + fz_rethrow(ctx); +} + +/* Edits, adds or previews text: 0, 2 missing, -1 failed. */ +int ely_put_text(fz_context *ctx, fz_document *doc, int number, int block, const char *expected, const char *text, + const char *font, float size, int rgb, const float box[3], const char *password, float scale, int *missing, + int *grew, unsigned char **pixels, int *w, int *h) +{ + pdf_document *pdf = NULL; + pdf_page *page = NULL; + ely_shape shape = { NULL }; + laid l = { { PEN_SIMPLE, NULL, 0, NULL } }; + int begun = 0; + fz_var(pdf); + fz_var(page); + fz_var(begun); + *pixels = NULL; + *grew = 0; + fz_try(ctx) + { + text_args a = { text, font, size, rgb, { box[0], box[1], box[2] } }; + fz_point first = fz_make_point(box[0], box[1] + size * 0.8f); + float gap = size * 1.2f; + pdf = ely_pdf_of(ctx, doc); + page = pdf_load_page(ctx, pdf, number); + /* Checks precede the step, so redo survives a refusal. */ + if (block >= 0) + { + ely_read_block(ctx, doc, page, number, block, expected, &shape); + first = shape.first; + gap = shape.gap; + } + *missing = lay_text(ctx, &a, shape.font, first, gap, &l); + if (!*missing) + { + fz_rect drawn = laid_area(&l, size), region = drawn; + if (block >= 0) + { + fz_rect room = fz_expand_rect(shape.bbox, 1); + region = fz_union_rect(shape.bbox, drawn); + *grew = fabsf(size - shape.size) > 0.01f || !fz_contains_rect(room, drawn); + } + if (scale > 0) + preview(ctx, pdf, number, block, expected, &a, &l, password, region, scale, pixels, w, h); + else + { + pdf_begin_operation(ctx, pdf, block >= 0 ? "Edit text" : "Add text"); + begun = 1; + if (block >= 0) + ely_remove_block(ctx, pdf, page, &shape); + draw_laid(ctx, pdf, page, &l, &a); + pdf_end_operation(ctx, pdf); + begun = 0; + } + } + } + fz_always(ctx) + { + drop_laid(ctx, &l); + ely_drop_shape(ctx, &shape); + fz_drop_page(ctx, (fz_page *)page); + } + fz_catch(ctx) + { + fz_free(ctx, *pixels); + *pixels = NULL; + return ely_edit_failed(ctx, pdf, begun); + } + return *missing ? 2 : 0; +} diff --git a/crates/ely-pdf-engine/src/bin/ely-pdf-worker/content.rs b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/content.rs new file mode 100644 index 0000000..2175f2a --- /dev/null +++ b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/content.rs @@ -0,0 +1,135 @@ +//! The shim's text and object calls (PDF-03, PDF-04). + +use std::{ + ffi::{c_char, c_int}, + io, ptr, +}; + +use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text}; + +unsafe extern "C" { + fn ely_text_blocks(ctx: *mut Context, doc: *mut Document, page: c_int, data: *mut *mut u8, len: *mut usize) -> c_int; + fn ely_objects(ctx: *mut Context, doc: *mut Document, page: c_int, data: *mut *mut u8, len: *mut usize) -> c_int; + fn ely_put_text( + ctx: *mut Context, + doc: *mut Document, + page: c_int, + block: c_int, + expected: *const c_char, + text: *const c_char, + font: *const c_char, + size: f32, + rgb: c_int, + bx: *const f32, + password: *const c_char, + scale: f32, + missing: *mut c_int, + grew: *mut c_int, + pixels: *mut *mut u8, + w: *mut c_int, + h: *mut c_int, + ) -> c_int; + fn ely_place( + ctx: *mut Context, + doc: *mut Document, + page: c_int, + index: c_int, + t: *const f32, + drop: c_int, + path: *const c_char, + ) -> c_int; + fn ely_add_image(ctx: *mut Context, doc: *mut Document, page: c_int, path: *const c_char, rect: *const f32) -> c_int; +} + +/// New text and where it goes. +pub struct Writing { + /// The block replaced, or none for new text. + pub block: Option, + pub expected: String, + pub text: String, + pub font: String, + pub size: f32, + pub rgb: u32, + /// Page space: left, top, width. + pub bx: [f32; 3], +} + +/// What a text write did. +pub enum Wrote { + Done, + /// A character the font cannot draw; nothing changed. + Missing(char), + /// The would-be result: BGRA, size, and whether it grew. + Preview(Vec, (u32, u32), bool), +} + +fn number(value: usize) -> io::Result { + c_int::try_from(value).map_err(|_| io::Error::other("a number too large")) +} + +impl Mupdf { + pub fn text_blocks(&self, doc: &Open, at: usize) -> io::Result> { + let at = page(at)?; + self.owned(|data, len| unsafe { ely_text_blocks(self.ctx, doc.0, at, data, len) }) + } + + pub fn objects(&self, doc: &Open, at: usize) -> io::Result> { + let at = page(at)?; + self.owned(|data, len| unsafe { ely_objects(self.ctx, doc.0, at, data, len) }) + } + + /// Writes `w` on page `at`; a positive `scale` only previews. + pub fn put_text(&self, doc: &Open, at: usize, w: &Writing, password: &str, scale: f32) -> io::Result { + let block = w.block.map_or(Ok(-1), number)?; + let (expected, words, font) = (text(&w.expected)?, text(&w.text)?, text(&w.font)?); + let password = text(password)?; + let rgb = c_int::try_from(w.rgb & 0xff_ffff).map_err(io::Error::other)?; + let (mut missing, mut grew, mut pixels, mut width, mut height) = (0, 0, ptr::null_mut(), 0, 0); + let status = checked(unsafe { + ely_put_text( + self.ctx, + doc.0, + page(at)?, + block, + expected.as_ptr(), + words.as_ptr(), + font.as_ptr(), + w.size, + rgb, + w.bx.as_ptr(), + password.as_ptr(), + scale, + &mut missing, + &mut grew, + &mut pixels, + &mut width, + &mut height, + ) + })?; + if status == 2 { + let missing = u32::try_from(missing).ok().and_then(char::from_u32); + return Ok(Wrote::Missing(missing.ok_or_else(|| io::Error::other("a missing glyph out of range"))?)); + } + if scale <= 0.0 { + return Ok(Wrote::Done); + } + let size = (u32::try_from(width).map_err(io::Error::other)?, u32::try_from(height).map_err(io::Error::other)?); + let bytes = unsafe { std::slice::from_raw_parts(pixels, size.0 as usize * size.1 as usize * 4) }.to_vec(); + self.free(pixels); + Ok(Wrote::Preview(bytes, size, grew != 0)) + } + + /// Moves draw `index` by page-space `t`, swaps or drops it. + pub fn place(&self, doc: &Open, at: usize, index: usize, t: [f32; 6], drop: bool, swap: Option<&str>) -> io::Result<()> { + let swap = swap.map(text).transpose()?; + let path = swap.as_ref().map_or(ptr::null(), |path| path.as_ptr()); + let index = number(index)?; + checked(unsafe { ely_place(self.ctx, doc.0, page(at)?, index, t.as_ptr(), c_int::from(drop), path) }).map(|_| ()) + } + + /// Draws the image at `path` into page-space `rect`. + pub fn add_image(&self, doc: &Open, at: usize, path: &str, rect: [f32; 4]) -> io::Result<()> { + let path = text(path)?; + checked(unsafe { ely_add_image(self.ctx, doc.0, page(at)?, path.as_ptr(), rect.as_ptr()) }).map(|_| ()) + } +} diff --git a/crates/ely-pdf-engine/src/bin/ely-pdf-worker/edit.rs b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/edit.rs new file mode 100644 index 0000000..e7e71e1 --- /dev/null +++ b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/edit.rs @@ -0,0 +1,147 @@ +//! The shim's page edits, undo and saves. + +use std::{ + ffi::{c_char, c_int}, + io, + path::Path, +}; + +use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text}; + +unsafe extern "C" { + fn ely_journal(ctx: *mut Context, doc: *mut Document) -> c_int; + fn ely_undo(ctx: *mut Context, doc: *mut Document, redo: c_int) -> c_int; + fn ely_state(ctx: *mut Context, doc: *mut Document, at: *mut c_int, count: *mut c_int) -> c_int; + fn ely_delete_pages(ctx: *mut Context, doc: *mut Document, pages: *const c_int, n: c_int) -> c_int; + fn ely_move_pages(ctx: *mut Context, doc: *mut Document, pages: *const c_int, n: c_int, to: c_int) -> c_int; + fn ely_rotate_pages(ctx: *mut Context, doc: *mut Document, pages: *const c_int, n: c_int, by: c_int) -> c_int; + fn ely_crop_pages( + ctx: *mut Context, + doc: *mut Document, + pages: *const c_int, + n: c_int, + margins: *const f32, + ) -> c_int; + fn ely_insert_blank(ctx: *mut Context, doc: *mut Document, at: c_int, w: f32, h: f32) -> c_int; + fn ely_insert_file( + ctx: *mut Context, + doc: *mut Document, + at: c_int, + path: *const c_char, + count: *mut c_int, + ) -> c_int; + fn ely_save( + ctx: *mut Context, + doc: *mut Document, + raw: *const c_char, + out: *const c_char, + password: *const c_char, + ) -> c_int; + fn ely_extract( + ctx: *mut Context, + doc: *mut Document, + pages: *const c_int, + n: c_int, + raw: *const c_char, + out: *const c_char, + password: *const c_char, + ) -> c_int; +} + +/// Page numbers as the shim takes them. +fn numbers(pages: &[usize]) -> io::Result<(Vec, c_int)> { + let pages = pages.iter().map(|at| page(*at)).collect::>>()?; + let n = c_int::try_from(pages.len()).map_err(|_| io::Error::other("too many pages"))?; + Ok((pages, n)) +} + +/// The scratch copy a save writes first, removed after. +pub(crate) fn raw(out: &Path) -> std::path::PathBuf { + let mut name = out.as_os_str().to_owned(); + name.push(".raw"); + name.into() +} + +pub(crate) fn utf8(path: &Path) -> io::Result<&str> { + path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8")) +} + +impl Mupdf { + /// Starts the undo history; every edit becomes a step. + pub fn journal(&self, doc: &Open) -> io::Result<()> { + checked(unsafe { ely_journal(self.ctx, doc.0) }).map(|_| ()) + } + + /// MuPDF's undo position and step count. + pub fn state(&self, doc: &Open) -> io::Result<(usize, usize)> { + let (mut at, mut count) = (0, 0); + checked(unsafe { ely_state(self.ctx, doc.0, &mut at, &mut count) })?; + let size = |n: c_int| usize::try_from(n).map_err(io::Error::other); + Ok((size(at)?, size(count)?)) + } + + pub fn undo(&self, doc: &Open, redo: bool) -> io::Result<()> { + checked(unsafe { ely_undo(self.ctx, doc.0, c_int::from(redo)) }).map(|_| ()) + } + + pub fn delete_pages(&self, doc: &Open, pages: &[usize]) -> io::Result<()> { + let (pages, n) = numbers(pages)?; + checked(unsafe { ely_delete_pages(self.ctx, doc.0, pages.as_ptr(), n) }).map(|_| ()) + } + + pub fn move_pages(&self, doc: &Open, pages: &[usize], to: usize) -> io::Result<()> { + let (pages, n) = numbers(pages)?; + checked(unsafe { ely_move_pages(self.ctx, doc.0, pages.as_ptr(), n, page(to)?) }).map(|_| ()) + } + + pub fn rotate_pages(&self, doc: &Open, pages: &[usize], by: i32) -> io::Result<()> { + let (pages, n) = numbers(pages)?; + checked(unsafe { ely_rotate_pages(self.ctx, doc.0, pages.as_ptr(), n, by) }).map(|_| ()) + } + + pub fn crop_pages(&self, doc: &Open, pages: &[usize], margins: [f32; 4]) -> io::Result<()> { + let (pages, n) = numbers(pages)?; + checked(unsafe { ely_crop_pages(self.ctx, doc.0, pages.as_ptr(), n, margins.as_ptr()) }).map(|_| ()) + } + + pub fn insert_blank(&self, doc: &Open, at: usize, (w, h): (f32, f32)) -> io::Result<()> { + checked(unsafe { ely_insert_blank(self.ctx, doc.0, page(at)?, w, h) }).map(|_| ()) + } + + /// Inserts the pages of `path`; how many came. + pub fn insert_file(&self, doc: &Open, at: usize, path: &str) -> io::Result { + let (path, mut count) = (text(path)?, 0); + checked(unsafe { ely_insert_file(self.ctx, doc.0, page(at)?, path.as_ptr(), &mut count) })?; + usize::try_from(count).map_err(io::Error::other) + } + + /// Writes a compacted copy to `out`. + pub fn save(&self, doc: &Open, out: &Path, password: &str) -> io::Result<()> { + let scratch = raw(out); + let (raw_c, out_c, password) = (text(utf8(&scratch)?)?, text(utf8(out)?)?, text(password)?); + let saved = checked(unsafe { ely_save(self.ctx, doc.0, raw_c.as_ptr(), out_c.as_ptr(), password.as_ptr()) }); + clean(&scratch); + saved.map(|_| ()) + } + + /// Writes only `pages` to `out`; the document stays. + pub fn extract(&self, doc: &Open, pages: &[usize], out: &Path, password: &str) -> io::Result<()> { + let (pages, n) = numbers(pages)?; + let scratch = raw(out); + let (raw_c, out_c, password) = (text(utf8(&scratch)?)?, text(utf8(out)?)?, text(password)?); + let done = checked(unsafe { + ely_extract(self.ctx, doc.0, pages.as_ptr(), n, raw_c.as_ptr(), out_c.as_ptr(), password.as_ptr()) + }); + clean(&scratch); + done.map(|_| ()) + } +} + +/// Removes a scratch copy; one never written is fine. +pub(crate) fn clean(scratch: &Path) { + match std::fs::remove_file(scratch) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => log::warn!("pdf-worker: the scratch copy stays: {error}"), + } +} diff --git a/crates/ely-pdf-engine/src/bin/ely-pdf-worker/ffi.rs b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/ffi.rs new file mode 100644 index 0000000..3f39381 --- /dev/null +++ b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/ffi.rs @@ -0,0 +1,200 @@ +//! The C shim's calls; each failure leaves a message behind. + +use std::{ + ffi::{CStr, CString, c_char, c_int, c_void}, + io, ptr, +}; + +#[repr(C)] +pub struct Context { + _private: [u8; 0], +} + +#[repr(C)] +pub struct Document { + _private: [u8; 0], +} + +unsafe extern "C" { + fn ely_error() -> *const c_char; + fn ely_init() -> *mut Context; + fn ely_open(ctx: *mut Context, path: *const c_char, out: *mut *mut Document) -> c_int; + fn ely_close(ctx: *mut Context, doc: *mut Document); + fn ely_needs_password(ctx: *mut Context, doc: *mut Document) -> c_int; + fn ely_authenticate(ctx: *mut Context, doc: *mut Document, password: *const c_char) -> c_int; + fn ely_count_pages(ctx: *mut Context, doc: *mut Document, out: *mut c_int) -> c_int; + fn ely_page_bounds(ctx: *mut Context, doc: *mut Document, page: c_int, out: *mut f32) -> c_int; + fn ely_render( + ctx: *mut Context, + doc: *mut Document, + page: c_int, + scale: f32, + rotate: c_int, + tile: *const c_int, + dark: c_int, + bgra: *mut u8, + ) -> c_int; + fn ely_text( + ctx: *mut Context, + doc: *mut Document, + page: c_int, + data: *mut *mut u8, + len: *mut usize, + ) -> c_int; + fn ely_search( + ctx: *mut Context, + doc: *mut Document, + page: c_int, + needle: *const c_char, + data: *mut *mut u8, + len: *mut usize, + ) -> c_int; + fn ely_outline( + ctx: *mut Context, + doc: *mut Document, + data: *mut *mut u8, + len: *mut usize, + ) -> c_int; + fn ely_free(ctx: *mut Context, data: *mut c_void); + fn ely_script_font(data: *const u8, len: usize); +} + +/// Great Vibes (OFL), for typed signatures. +static SCRIPT: &[u8] = include_bytes!("../../../fonts/GreatVibes-Regular.ttf"); + +/// Hands MuPDF the script face, alive while the worker is. +pub fn script_font() { + unsafe { ely_script_font(SCRIPT.as_ptr(), SCRIPT.len()) } +} + +fn error() -> io::Error { + let text = unsafe { CStr::from_ptr(ely_error()) }; + io::Error::other(text.to_string_lossy().into_owned()) +} + +pub(crate) fn checked(code: c_int) -> io::Result { + match code { + -1 => Err(error()), + other => Ok(other), + } +} + +pub(crate) fn text(value: &str) -> io::Result { + CString::new(value).map_err(|_| io::Error::other("text with a NUL byte")) +} + +pub(crate) fn page(number: usize) -> io::Result { + c_int::try_from(number).map_err(|_| io::Error::other("a page number too large")) +} + +/// The one MuPDF context; the worker is single threaded. +pub struct Mupdf { + pub(crate) ctx: *mut Context, +} + +/// An open document, closed by `Mupdf::close`. +pub struct Open(pub(crate) *mut Document); + +impl Mupdf { + pub fn new() -> io::Result { + let ctx = unsafe { ely_init() }; + match ctx.is_null() { + true => Err(error()), + false => Ok(Mupdf { ctx }), + } + } + + pub fn open(&self, path: &str) -> io::Result { + let path = text(path)?; + let mut doc = ptr::null_mut(); + checked(unsafe { ely_open(self.ctx, path.as_ptr(), &mut doc) })?; + Ok(Open(doc)) + } + + pub fn close(&self, doc: Open) { + unsafe { ely_close(self.ctx, doc.0) } + } + + pub fn locked(&self, doc: &Open) -> io::Result { + checked(unsafe { ely_needs_password(self.ctx, doc.0) }).map(|locked| locked != 0) + } + + /// MuPDF's access bits; 0 is refused, 4 the owner. + pub fn unlock(&self, doc: &Open, password: &str) -> io::Result { + let password = text(password)?; + checked(unsafe { ely_authenticate(self.ctx, doc.0, password.as_ptr()) }).map(|access| access as u32) + } + + pub fn pages(&self, doc: &Open) -> io::Result { + let mut count = 0; + checked(unsafe { ely_count_pages(self.ctx, doc.0, &mut count) })?; + usize::try_from(count).map_err(io::Error::other) + } + + pub fn bounds(&self, doc: &Open, number: usize) -> io::Result<[f32; 4]> { + let mut out = [0f32; 4]; + checked(unsafe { ely_page_bounds(self.ctx, doc.0, page(number)?, out.as_mut_ptr()) })?; + Ok(out) + } + + pub fn render( + &self, + doc: &Open, + number: usize, + (scale, rotate): (f32, i32), + tile: [i32; 4], + dark: bool, + ) -> io::Result> { + let (w, h) = (usize::try_from(tile[2]), usize::try_from(tile[3])); + let (Ok(w), Ok(h)) = (w, h) else { + return Err(io::Error::other("a tile of negative size")); + }; + let mut pixels = vec![0u8; w * h * 4]; + let code = unsafe { + ely_render( + self.ctx, + doc.0, + page(number)?, + scale, + rotate, + tile.as_ptr(), + c_int::from(dark), + pixels.as_mut_ptr(), + ) + }; + checked(code)?; + Ok(pixels) + } + + /// Frees memory the shim handed over. + pub(crate) fn free(&self, data: *mut u8) { + unsafe { ely_free(self.ctx, data.cast()) } + } + + /// Takes a buffer the shim made, then frees it. + pub(crate) fn owned(&self, call: impl FnOnce(*mut *mut u8, *mut usize) -> c_int) -> io::Result> { + let (mut data, mut len) = (ptr::null_mut(), 0usize); + checked(call(&mut data, &mut len))?; + let bytes = match data.is_null() { + true => Vec::new(), + false => unsafe { std::slice::from_raw_parts(data, len) }.to_vec(), + }; + unsafe { ely_free(self.ctx, data.cast()) }; + Ok(bytes) + } + + pub fn text(&self, doc: &Open, number: usize) -> io::Result> { + let number = page(number)?; + self.owned(|data, len| unsafe { ely_text(self.ctx, doc.0, number, data, len) }) + } + + pub fn outline(&self, doc: &Open) -> io::Result> { + self.owned(|data, len| unsafe { ely_outline(self.ctx, doc.0, data, len) }) + } + + /// Each hit as its quad count, then its quads. + pub fn search(&self, doc: &Open, number: usize, needle: &str) -> io::Result> { + let (needle, number) = (text(needle)?, page(number)?); + self.owned(|data, len| unsafe { ely_search(self.ctx, doc.0, number, needle.as_ptr(), data, len) }) + } +} diff --git a/crates/ely-pdf-engine/src/bin/ely-pdf-worker/main.rs b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/main.rs new file mode 100644 index 0000000..9ffbd95 --- /dev/null +++ b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/main.rs @@ -0,0 +1,478 @@ +//! The PDF worker: MuPDF behind framed stdin and stdout. + +use std::{ + collections::HashMap, + io::{self, BufReader, BufWriter}, +}; + +use ely_ipc::{Pack, Unpack, read_frame, write_frame}; +use ely_pdf_engine::proto::{may, tag}; + +mod content; +mod edit; +mod ffi; +mod marks; +mod ocr; +mod output; +mod redact; +mod secure; +mod sign; +mod structure; + +use ffi::{Mupdf, Open}; + +/// An open document, its password and undo steps. +struct Doc { + open: Open, + /// Kept in memory only, to reopen saved copies. + password: String, + /// Opened with the owner password, or unprotected. + owner: bool, + /// What MuPDF warned at open; kept as the repair report. + warnings: String, + repair: Option, + /// The repaired copy standing in; removed on close. + copy: Option, + /// MuPDF's steps: an edit's id, or none for a save. + steps: Vec>, + /// Steps done, as MuPDF counts them. + at: usize, +} + +impl Doc { + /// Equal marks, equal documents. + fn mark(&self) -> String { + self.steps[..self.at].iter().rev().flatten().next().map_or_else(|| "0".into(), u64::to_string) + } + + /// Matches MuPDF's steps; a new last step is `made`. + fn sync(&mut self, mupdf: &Mupdf, made: Option) -> io::Result<()> { + let (at, count) = mupdf.state(&self.open)?; + if at > self.at { + self.steps.truncate(self.at); + self.steps.extend((self.at..at).map(|step| made.filter(|_| step + 1 == at))); + } + // Steps MuPDF keeps but no edit made are never redone. + self.steps.resize(count, None); + self.at = at; + Ok(()) + } + + /// Undoes or redoes MuPDF steps until one edit is passed. + fn step(&mut self, mupdf: &Mupdf, redo: bool) -> io::Result { + let able = match redo { + true => self.steps[self.at..].iter().any(Option::is_some), + false => self.steps[..self.at].iter().any(Option::is_some), + }; + if !able { + return Err(io::Error::other(format!("nothing to {}", if redo { "redo" } else { "undo" }))); + } + loop { + mupdf.undo(&self.open, redo)?; + let passed = match redo { + true => { + self.at += 1; + self.steps[self.at - 1] + } + false => { + self.at -= 1; + self.steps[self.at] + } + }; + if passed.is_some() { + return Ok(self.mark()); + } + } + } +} + +struct Worker { + mupdf: Mupdf, + docs: HashMap, + next: u64, + /// Edits made so far; each step's id. + edits: u64, +} + +/// Sorted distinct page numbers. +fn pages(ask: &mut Unpack) -> io::Result> { + let count = ask.u64()? as usize; + (0..count).map(|_| Ok(ask.u64()? as usize)).collect() +} + +impl Worker { + fn doc(&self, id: u64) -> io::Result<&Open> { + Ok(&self.record(id)?.open) + } + + fn record(&self, id: u64) -> io::Result<&Doc> { + self.docs + .get(&id) + .ok_or_else(|| io::Error::other(format!("no document {id}"))) + } + + /// Runs one edit on `id`; it becomes the next step. + fn edited(&mut self, id: u64, edit: impl FnOnce(&Mupdf, &Open) -> io::Result<()>) -> io::Result { + let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?; + if let Err(error) = edit(&self.mupdf, &doc.open) { + // A failed edit may have dropped MuPDF's redo steps. + doc.sync(&self.mupdf, None)?; + return Err(error); + } + self.edits += 1; + doc.sync(&self.mupdf, Some(self.edits))?; + Ok(doc.mark()) + } + + /// Writes or previews text; a missing glyph changes nothing. + fn put_text(&mut self, id: u64, ask: &mut Unpack) -> io::Result> { + let at = ask.u64()? as usize; + let block = usize::try_from(ask.i64()?).ok(); + let (expected, text, font) = (ask.str()?, ask.str()?, ask.str()?); + let size = f32::from_bits(ask.u64()? as u32); + let rgb = ask.u64()? as u32; + let mut bx = [0f32; 3]; + for value in &mut bx { + *value = f32::from_bits(ask.u64()? as u32); + } + let scale = f32::from_bits(ask.u64()? as u32); + let writing = content::Writing { block, expected, text, font, size, rgb, bx }; + let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?; + let wrote = self.mupdf.put_text(&doc.open, at, &writing, &doc.password, scale); + if matches!(wrote, Ok(content::Wrote::Done)) { + self.edits += 1; + } + let made = matches!(wrote, Ok(content::Wrote::Done)).then_some(self.edits); + doc.sync(&self.mupdf, made)?; + Ok(match wrote? { + content::Wrote::Done => Pack::new().u64(0).str(&doc.mark()).bytes(), + content::Wrote::Missing(c) => Pack::new().u64(2).u64(u64::from(c)).bytes(), + content::Wrote::Preview(pixels, (w, h), grew) => { + Pack::new().u64(1).u64(u64::from(w)).u64(u64::from(h)).u64(u64::from(grew)).blob(&pixels).bytes() + } + }) + } + + fn edit(&mut self, kind: u8, ask: &mut Unpack) -> io::Result> { + let id = ask.u64()?; + self.guard(id, kind)?; + let mark = match kind { + tag::HISTORY => self.record(id)?.mark(), + tag::UNDO => { + let redo = ask.u64()? != 0; + let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?; + doc.step(&self.mupdf, redo)? + } + tag::DELETE => { + let pages = pages(ask)?; + self.edited(id, |mupdf, doc| mupdf.delete_pages(doc, &pages))? + } + tag::MOVE => { + let (pages, to) = (pages(ask)?, ask.u64()? as usize); + self.edited(id, |mupdf, doc| mupdf.move_pages(doc, &pages, to))? + } + tag::ROTATE => { + let (pages, by) = (pages(ask)?, i32::try_from(ask.i64()?).map_err(io::Error::other)?); + self.edited(id, |mupdf, doc| mupdf.rotate_pages(doc, &pages, by))? + } + tag::CROP => { + let pages = pages(ask)?; + let mut margins = [0f32; 4]; + for side in &mut margins { + *side = f32::from_bits(ask.u64()? as u32); + } + self.edited(id, |mupdf, doc| mupdf.crop_pages(doc, &pages, margins))? + } + tag::BLANK => { + let at = ask.u64()? as usize; + let size = (f32::from_bits(ask.u64()? as u32), f32::from_bits(ask.u64()? as u32)); + self.edited(id, |mupdf, doc| mupdf.insert_blank(doc, at, size))? + } + tag::INSERT => { + let (at, path, mut count) = (ask.u64()? as usize, ask.str()?, 0); + let mark = self.edited(id, |mupdf, doc| { + count = mupdf.insert_file(doc, at, &path)?; + Ok(()) + })?; + return Ok(Pack::new().u64(count as u64).str(&mark).bytes()); + } + tag::EXTRACT => { + let (pages, out) = (pages(ask)?, ask.str()?); + let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?; + let done = self.mupdf.extract(&doc.open, &pages, out.as_ref(), &doc.password); + doc.sync(&self.mupdf, None)?; + done?; + return Ok(Vec::new()); + } + tag::TEXT_BLOCKS => return self.mupdf.text_blocks(&self.record(id)?.open, ask.u64()? as usize), + tag::OBJECTS => return self.mupdf.objects(&self.record(id)?.open, ask.u64()? as usize), + tag::PUT_TEXT => return self.put_text(id, ask), + tag::PLACE => { + let at = ask.u64()? as usize; + let (index, mut t) = (ask.u64()? as usize, [0f32; 6]); + for value in &mut t { + *value = f32::from_bits(ask.u64()? as u32); + } + let (drop, swap) = (ask.u64()? != 0, ask.str()?); + let swap = Some(swap.as_str()).filter(|path| !path.is_empty()); + self.edited(id, |mupdf, doc| mupdf.place(doc, at, index, t, drop, swap))? + } + tag::ADD_IMAGE => { + let (at, path, mut rect) = (ask.u64()? as usize, ask.str()?, [0f32; 4]); + for value in &mut rect { + *value = f32::from_bits(ask.u64()? as u32); + } + self.edited(id, |mupdf, doc| mupdf.add_image(doc, at, &path, rect))? + } + tag::ANNOTS => return self.mupdf.annots(&self.record(id)?.open, ask.u64()? as usize), + tag::OCR_PAGE => { + let (at, lang, datadir) = (ask.u64()? as usize, ask.str()?, ask.str()?); + let dpi = f32::from_bits(ask.u64()? as u32); + let lines = self.mupdf.ocr_page(&self.record(id)?.open, at, &lang, &datadir, dpi)?; + log::info!("pdf-worker: OCR read page {at} of document {id}"); + return Ok(lines); + } + tag::TEXT_LAYER => { + let (json, replace) = (ask.str()?, ask.u64()? != 0); + self.edited(id, |mupdf, doc| mupdf.text_layer(doc, &json, replace))? + } + tag::OUTLINE_EDIT => { + let json = ask.str()?; + self.edited(id, |mupdf, doc| mupdf.outline_edit(doc, &json))? + } + tag::LINKS => return self.mupdf.links(&self.record(id)?.open, ask.u64()? as usize), + tag::LINK_EDIT => { + let (at, json) = (ask.u64()? as usize, ask.str()?); + self.edited(id, |mupdf, doc| mupdf.link_edit(doc, at, &json))? + } + tag::METADATA => return self.mupdf.metadata(&self.record(id)?.open), + tag::SET_METADATA => { + let json = ask.str()?; + self.edited(id, |mupdf, doc| mupdf.set_metadata(doc, &json))? + } + tag::ATTACHMENTS => return self.mupdf.attachments(&self.record(id)?.open), + tag::ATTACHMENT => return self.mupdf.attachment(&self.record(id)?.open, &ask.str()?), + tag::ATTACH => { + let (name, path) = (ask.str()?, ask.str()?); + self.edited(id, |mupdf, doc| mupdf.attach(doc, &name, &path))? + } + tag::DETACH => { + let name = ask.str()?; + self.edited(id, |mupdf, doc| mupdf.detach(doc, &name))? + } + tag::REDACT => { + let (json, out) = (ask.str()?, ask.str()?); + let record = self.record(id)?; + let report = self.mupdf.redact(&record.open, &json, out.as_ref(), &record.password)?; + log::info!("pdf-worker: wrote a redacted copy of document {id}"); + return Ok(report); + } + tag::CLEAN_SCAN => { + let scratch = ask.str()?; + let record = self.record(id)?; + return self.mupdf.clean_scan(&record.open, scratch.as_ref(), &record.password); + } + tag::CLEAN => { + let (mask, out) = (ask.u64()? as u32, ask.str()?); + let record = self.record(id)?; + let left = self.mupdf.clean(&record.open, mask, out.as_ref(), &record.password)?; + log::info!("pdf-worker: wrote a cleaned copy of document {id}"); + return Ok(left); + } + tag::FIELD_NAMES => return self.mupdf.field_names(&self.record(id)?.open), + tag::ANNOTATE => { + let json = ask.str()?; + self.edited(id, |mupdf, doc| mupdf.annotate(doc, &json))? + } + tag::CHANGE_ANNOT | tag::CHANGE_FIELD | tag::FILL | tag::DELETE_ANNOT => { + let (at, object, value) = (ask.u64()? as usize, ask.u64()?, ask.str()?); + if kind == tag::DELETE_ANNOT { + let needed = if value == "field" { may::EDIT | may::ANNOTATE } else { may::ANNOTATE }; + secure::refuse(needed, self.allowed(id)?)?; + } + self.edited(id, |mupdf, doc| match kind { + tag::CHANGE_ANNOT => mupdf.change_annot(doc, at, object, &value), + tag::CHANGE_FIELD => mupdf.change_field(doc, at, object, &value), + tag::FILL => mupdf.fill(doc, at, object, &value), + _ => match value.as_str() { + "field" | "annot" => mupdf.delete_annot(doc, at, object, value == "field"), + other => Err(io::Error::other(format!("no object kind {other}"))), + }, + })? + } + tag::ADD_FIELD => { + let (at, json) = (ask.u64()? as usize, ask.str()?); + self.edited(id, |mupdf, doc| mupdf.add_field(doc, at, &json))? + } + tag::SAVE => { + let out = ask.str()?; + let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?; + let done = self.mupdf.save(&doc.open, out.as_ref(), &doc.password); + doc.sync(&self.mupdf, None)?; + done?; + log::info!("pdf-worker: saved document {id}"); + return Ok(Vec::new()); + } + tag::PERMISSIONS | tag::OWNER | tag::PROTECT => return self.secure(kind, id, ask), + tag::SIGNATURES | tag::SIGN | tag::SIGNED => return self.signing(kind, id, ask), + tag::CHECK_PICTURES => return self.check_pictures(id), + tag::EXPORT_IMAGE | tag::EXPORT_TEXT | tag::OPTIMIZE | tag::PRINT_COPY | tag::REPAIR_REPORT => return self.output(kind, id, ask), + other => return Err(io::Error::other(format!("no request {other}"))), + }; + Ok(Pack::new().str(&mark).bytes()) + } + + fn answer(&mut self, kind: u8, payload: &[u8]) -> io::Result> { + // A document request names the state it was made for. + let stamped; + let payload = match kind { + tag::OPEN | tag::HELLO => payload, + _ => { + stamped = self.current(payload)?; + &stamped + } + }; + let mut ask = Unpack::new(payload); + match kind { + tag::OPEN => { + let open = self.mupdf.open(&ask.str()?)?; + let prepared = (|| { + let locked = self.mupdf.locked(&open)?; + // Unprotected files answer 1; owners carry bit 4. + let owner = !locked && matches!(self.mupdf.unlock(&open, "")?, access if access == 1 || access & 4 != 0); + self.mupdf.journal(&open)?; + Ok::<_, io::Error>((locked, owner)) + })(); + // A failed start releases what MuPDF opened. + let (locked, owner) = match prepared { + Ok(prepared) => prepared, + Err(error) => { + self.mupdf.close(open); + return Err(error); + } + }; + let id = self.next; + self.next += 1; + let warnings = output::open_warnings(); + let doc = Doc { open, password: String::new(), owner, warnings, repair: None, copy: None, steps: Vec::new(), at: 0 }; + self.docs.insert(id, doc); + if !locked && let Err(error) = self.repair(id) { + self.forget(id)?; + return Err(error); + } + log::info!("pdf-worker: opened document {id}, locked {locked}"); + Ok(Pack::new().u64(id).u64(u64::from(locked)).bytes()) + } + tag::UNLOCK => { + let (id, password) = (ask.u64()?, ask.str()?); + let access = self.mupdf.unlock(self.doc(id)?, &password)?; + if access != 0 { + let doc = self.docs.get_mut(&id).expect("just read"); + (doc.password, doc.owner) = (password, access & 4 != 0); + self.repair(id)?; + } + Ok(Pack::new().u64(u64::from(access != 0)).bytes()) + } + tag::PAGES => Ok(Pack::new() + .u64(self.mupdf.pages(self.doc(ask.u64()?)?)? as u64) + .bytes()), + tag::BOUNDS => { + let doc = self.doc(ask.u64()?)?; + let (first, count) = (ask.u64()? as usize, ask.u64()? as usize); + let mut out = Pack::new(); + for number in first..first + count { + for value in self.mupdf.bounds(doc, number)? { + out = out.u64(u64::from(value.to_bits())); + } + } + Ok(out.bytes()) + } + tag::RENDER => { + let id = ask.u64()?; + let page = ask.u64()? as usize; + let scale = f32::from_bits(ask.u64()? as u32); + let rotate = i32::try_from(ask.i64()?).map_err(io::Error::other)?; + let mut tile = [0i32; 4]; + for value in &mut tile { + *value = i32::try_from(ask.i64()?).map_err(io::Error::other)?; + } + let dark = ask.u64()? != 0; + self.drawn(id, page, (scale, rotate), tile, dark) + } + tag::TEXT => { + let doc = self.doc(ask.u64()?)?; + self.mupdf.text(doc, ask.u64()? as usize) + } + tag::SEARCH => { + let doc = self.doc(ask.u64()?)?; + let page = ask.u64()? as usize; + self.mupdf.search(doc, page, &ask.str()?) + } + tag::OUTLINE => self.mupdf.outline(self.doc(ask.u64()?)?), + tag::HELLO => Ok(Pack::new().str(ely_pdf_engine::proto::BUILD).bytes()), + tag::CLOSE => { + self.forget(ask.u64()?)?; + Ok(Vec::new()) + } + other => self.edit(other, &mut ask), + } + } + + /// The request without its revision, if it is still current. + fn current(&self, payload: &[u8]) -> io::Result> { + let mut ask = Unpack::new(payload); + let (id, expected) = (ask.u64()?, ask.u64()?); + let doc = self.record(id)?; + if expected != ely_pdf_engine::proto::ANY && doc.mark() != expected.to_string() { + return Err(io::Error::other(format!("{}document {id} is at {}, not {expected}", ely_pdf_engine::proto::STALE, doc.mark()))); + } + Ok([&id.to_le_bytes()[..], ask.rest()].concat()) + } + + /// Releases document `id` and its repaired copy. + fn forget(&mut self, id: u64) -> io::Result<()> { + let doc = self.docs.remove(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?; + self.mupdf.close(doc.open); + if let Some(copy) = doc.copy { + std::fs::remove_file(copy)?; + } + Ok(()) + } +} + +fn main() -> io::Result<()> { + env_logger::Builder::from_env(env_logger::Env::new().filter_or("ELY_LOG", "info")).init(); + ffi::script_font(); + // Numbers from another worker process match nothing here. + let started = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(io::Error::other)?; + let prefix = (u64::from(std::process::id()) ^ u64::from(started.subsec_nanos())) & 0xffff_ffff; + let mut worker = Worker { + mupdf: Mupdf::new()?, + docs: HashMap::new(), + next: (prefix << 32) | 1, + edits: 0, + }; + let (mut input, mut output) = ( + BufReader::new(io::stdin().lock()), + BufWriter::new(io::stdout().lock()), + ); + while let Some((kind, id, payload)) = read_frame(&mut input)? { + let written = match worker.answer(kind, &payload) { + Ok(reply) => write_frame(&mut output, tag::OK, id, &reply), + Err(error) => { + // Text may quote the document: reply, never log. + log::warn!("pdf-worker: request {kind} failed ({:?})", error.kind()); + write_frame(&mut output, tag::ERR, id, error.to_string().as_bytes()) + } + }; + // An oversized answer is refused; the worker lives. + if let Err(error) = written { + if error.kind() != io::ErrorKind::InvalidInput { + return Err(error); + } + log::warn!("pdf-worker: request {kind} answer too large"); + write_frame(&mut output, tag::ERR, id, error.to_string().as_bytes())?; + } + } + Ok(()) +} diff --git a/crates/ely-pdf-engine/src/bin/ely-pdf-worker/marks.rs b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/marks.rs new file mode 100644 index 0000000..b38fd00 --- /dev/null +++ b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/marks.rs @@ -0,0 +1,63 @@ +//! The shim's annotation and form calls (PDF-05, PDF-06). + +use std::{ + ffi::{c_char, c_int}, + io, +}; + +use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text}; + +unsafe extern "C" { + fn ely_annots(ctx: *mut Context, doc: *mut Document, page: c_int, data: *mut *mut u8, len: *mut usize) -> c_int; + fn ely_annotate(ctx: *mut Context, doc: *mut Document, json: *const c_char) -> c_int; + fn ely_change_annot(ctx: *mut Context, doc: *mut Document, page: c_int, id: c_int, json: *const c_char) -> c_int; + fn ely_delete_annot(ctx: *mut Context, doc: *mut Document, page: c_int, id: c_int, widget: c_int) -> c_int; + fn ely_add_field(ctx: *mut Context, doc: *mut Document, page: c_int, json: *const c_char) -> c_int; + fn ely_fill(ctx: *mut Context, doc: *mut Document, page: c_int, id: c_int, value: *const c_char) -> c_int; + fn ely_field_names(ctx: *mut Context, doc: *mut Document, data: *mut *mut u8, len: *mut usize) -> c_int; + fn ely_change_field(ctx: *mut Context, doc: *mut Document, page: c_int, id: c_int, json: *const c_char) -> c_int; +} + +fn id(value: u64) -> io::Result { + c_int::try_from(value).map_err(|_| io::Error::other("an object number too large")) +} + +impl Mupdf { + pub fn annots(&self, doc: &Open, at: usize) -> io::Result> { + let at = page(at)?; + self.owned(|data, len| unsafe { ely_annots(self.ctx, doc.0, at, data, len) }) + } + + pub fn field_names(&self, doc: &Open) -> io::Result> { + self.owned(|data, len| unsafe { ely_field_names(self.ctx, doc.0, data, len) }) + } + + pub fn annotate(&self, doc: &Open, json: &str) -> io::Result<()> { + let json = text(json)?; + checked(unsafe { ely_annotate(self.ctx, doc.0, json.as_ptr()) }).map(|_| ()) + } + + pub fn change_annot(&self, doc: &Open, at: usize, annot: u64, json: &str) -> io::Result<()> { + let json = text(json)?; + checked(unsafe { ely_change_annot(self.ctx, doc.0, page(at)?, id(annot)?, json.as_ptr()) }).map(|_| ()) + } + + pub fn delete_annot(&self, doc: &Open, at: usize, annot: u64, field: bool) -> io::Result<()> { + checked(unsafe { ely_delete_annot(self.ctx, doc.0, page(at)?, id(annot)?, c_int::from(field)) }).map(|_| ()) + } + + pub fn add_field(&self, doc: &Open, at: usize, json: &str) -> io::Result<()> { + let json = text(json)?; + checked(unsafe { ely_add_field(self.ctx, doc.0, page(at)?, json.as_ptr()) }).map(|_| ()) + } + + pub fn fill(&self, doc: &Open, at: usize, field: u64, value: &str) -> io::Result<()> { + let value = text(value)?; + checked(unsafe { ely_fill(self.ctx, doc.0, page(at)?, id(field)?, value.as_ptr()) }).map(|_| ()) + } + + pub fn change_field(&self, doc: &Open, at: usize, field: u64, json: &str) -> io::Result<()> { + let json = text(json)?; + checked(unsafe { ely_change_field(self.ctx, doc.0, page(at)?, id(field)?, json.as_ptr()) }).map(|_| ()) + } +} diff --git a/crates/ely-pdf-engine/src/bin/ely-pdf-worker/ocr.rs b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/ocr.rs new file mode 100644 index 0000000..4d77231 --- /dev/null +++ b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/ocr.rs @@ -0,0 +1,34 @@ +//! The shim's OCR calls (PDF-08). + +use std::{ + ffi::{c_char, c_int}, + io, +}; + +use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text}; + +unsafe extern "C" { + fn ely_ocr_page( + ctx: *mut Context, + doc: *mut Document, + page: c_int, + lang: *const c_char, + datadir: *const c_char, + dpi: f32, + data: *mut *mut u8, + len: *mut usize, + ) -> c_int; + fn ely_text_layer(ctx: *mut Context, doc: *mut Document, json: *const c_char, replace: c_int) -> c_int; +} + +impl Mupdf { + pub fn ocr_page(&self, doc: &Open, at: usize, lang: &str, datadir: &str, dpi: f32) -> io::Result> { + let (at, lang, datadir) = (page(at)?, text(lang)?, text(datadir)?); + self.owned(|data, len| unsafe { ely_ocr_page(self.ctx, doc.0, at, lang.as_ptr(), datadir.as_ptr(), dpi, data, len) }) + } + + pub fn text_layer(&self, doc: &Open, json: &str, replace: bool) -> io::Result<()> { + let json = text(json)?; + checked(unsafe { ely_text_layer(self.ctx, doc.0, json.as_ptr(), c_int::from(replace)) }).map(|_| ()) + } +} diff --git a/crates/ely-pdf-engine/src/bin/ely-pdf-worker/output.rs b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/output.rs new file mode 100644 index 0000000..84fbe92 --- /dev/null +++ b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/output.rs @@ -0,0 +1,183 @@ +//! Page images, text, optimized and repaired copies (PDF-13, PDF-14). + +use std::{ + ffi::{CStr, c_char, c_int}, + io, + path::{Path, PathBuf}, +}; + +use ely_ipc::{Pack, Unpack}; +use ely_pdf_engine::proto::{may, tag}; + +use crate::edit::{clean, raw, utf8}; +use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text}; +use crate::secure::refuse; + +unsafe extern "C" { + fn ely_export_image(ctx: *mut Context, doc: *mut Document, number: c_int, dpi: f32, path: *const c_char) -> c_int; + fn ely_export_text(ctx: *mut Context, doc: *mut Document, path: *const c_char) -> c_int; + fn ely_optimize( + ctx: *mut Context, + doc: *mut Document, + raw: *const c_char, + out: *const c_char, + password: *const c_char, + compress: c_int, + from: c_int, + to: c_int, + ) -> c_int; + fn ely_repaired(ctx: *mut Context, doc: *mut Document) -> c_int; + fn ely_write_repaired(ctx: *mut Context, doc: *mut Document, path: *const c_char) -> c_int; + fn ely_open_warnings() -> *const c_char; + fn ely_scan(ctx: *mut Context, doc: *mut Document) -> c_int; + fn ely_watch_begin(ctx: *mut Context); + fn ely_watch_end(ctx: *mut Context) -> c_int; + fn ely_check_pictures(ctx: *mut Context, doc: *mut Document) -> c_int; + fn ely_print_copy(ctx: *mut Context, doc: *mut Document, raw: *const c_char, out: *const c_char, password: *const c_char) -> c_int; +} + +/// What MuPDF warned while opening the last file. +pub fn open_warnings() -> String { + unsafe { CStr::from_ptr(ely_open_warnings()) }.to_string_lossy().into_owned() +} + +impl Mupdf { + fn optimize(&self, doc: &Open, out: &Path, password: &str, compress: bool, (from, to): (u32, u32)) -> io::Result<()> { + let scratch = raw(out); + let (raw_c, out_c, password) = (text(utf8(&scratch)?)?, text(utf8(out)?)?, text(password)?); + let (from, to) = (c_int::try_from(from).map_err(io::Error::other)?, c_int::try_from(to).map_err(io::Error::other)?); + let done = checked(unsafe { + ely_optimize(self.ctx, doc.0, raw_c.as_ptr(), out_c.as_ptr(), password.as_ptr(), c_int::from(compress), from, to) + }); + clean(&scratch); + if done.is_err() { + clean(out); + } + done.map(|_| ()) + } + + pub fn repaired(&self, doc: &Open) -> io::Result { + checked(unsafe { ely_repaired(self.ctx, doc.0) }).map(|repaired| repaired != 0) + } +} + +impl crate::Worker { + /// MuPDF warned on reading: the file is damaged. + fn note_damage(&mut self, id: u64) -> io::Result<()> { + let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?; + let report = doc.repair.get_or_insert_with(String::new); + report.push_str(&open_warnings()); + log::warn!("pdf-worker: document {id} is damaged"); + Ok(()) + } + + /// A tile, and whether drawing it met damage. + pub(crate) fn drawn(&mut self, id: u64, page: usize, view: (f32, i32), tile: [i32; 4], dark: bool) -> io::Result> { + unsafe { ely_watch_begin(self.mupdf.ctx) }; + let pixels = self.mupdf.render(self.doc(id)?, page, view, tile, dark); + let warned = unsafe { ely_watch_end(self.mupdf.ctx) } != 0; + if warned { + self.note_damage(id)?; + } + Ok(Pack::new().u64(u64::from(warned)).blob(&pixels?).bytes()) + } + + /// Every picture decoded before an overwrite; true if any failed. + pub(crate) fn check_pictures(&mut self, id: u64) -> io::Result> { + let failed = checked(unsafe { ely_check_pictures(self.mupdf.ctx, self.doc(id)?.0) })? != 0; + if failed { + self.note_damage(id)?; + } + Ok(Pack::new().u64(u64::from(failed)).bytes()) + } + + /// A repaired file gives way to a whole copy. + pub(crate) fn repair(&mut self, id: u64) -> io::Result<()> { + let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?; + if doc.repair.is_some() { + return Ok(()); + } + // Late repairs and broken streams show only on reading. + let warned = checked(unsafe { ely_scan(self.mupdf.ctx, doc.open.0) })? != 0; + doc.warnings.push_str(&open_warnings()); + if !warned && !self.mupdf.repaired(&doc.open)? { + return Ok(()); + } + let copy: PathBuf = std::env::temp_dir().join(format!("ely-pdf-{}-{id}.pdf", std::process::id())); + let path = text(utf8(©)?)?; + // Any failure here removes the copy and what was opened. + let mupdf = &self.mupdf; + let fresh = (|| { + checked(unsafe { ely_write_repaired(mupdf.ctx, doc.open.0, path.as_ptr()) })?; + let fresh = mupdf.open(utf8(©)?)?; + let ready = (|| { + if mupdf.locked(&fresh)? && mupdf.unlock(&fresh, &doc.password)? == 0 { + return Err(io::Error::other("the repaired copy refused the password")); + } + mupdf.journal(&fresh) + })(); + match ready { + Ok(()) => Ok(fresh), + Err(error) => { + mupdf.close(fresh); + Err(error) + } + } + })(); + let fresh = match fresh { + Ok(fresh) => fresh, + Err(error) => { + clean(©); + return Err(error); + } + }; + let broken = std::mem::replace(&mut doc.open, fresh); + self.mupdf.close(broken); + doc.repair = Some(std::mem::take(&mut doc.warnings)); + doc.copy = Some(copy); + log::info!("pdf-worker: document {id} was damaged; a repaired copy stands in"); + Ok(()) + } + + pub(crate) fn output(&mut self, kind: u8, id: u64, ask: &mut Unpack) -> io::Result> { + let has = self.allowed(id)?; + let doc = self.record(id)?; + match kind { + tag::EXPORT_IMAGE => { + refuse(may::COPY, has)?; + let (at, dpi, path) = (ask.u64()? as usize, f32::from_bits(ask.u64()? as u32), ask.str()?); + let path_c = text(&path)?; + checked(unsafe { ely_export_image(self.mupdf.ctx, doc.open.0, page(at)?, dpi, path_c.as_ptr()) })?; + } + tag::EXPORT_TEXT => { + refuse(may::COPY, has)?; + let path = text(&ask.str()?)?; + checked(unsafe { ely_export_text(self.mupdf.ctx, doc.open.0, path.as_ptr()) })?; + } + tag::OPTIMIZE => { + refuse(may::EDIT, has)?; + let (out, compress) = (ask.str()?, ask.u64()? != 0); + let (from, to) = (ask.u64()? as u32, ask.u64()? as u32); + self.mupdf.optimize(&doc.open, out.as_ref(), &doc.password, compress, (from, to))?; + } + tag::PRINT_COPY => { + refuse(may::PRINT, has)?; + let out = ask.str()?; + let scratch = raw(out.as_ref()); + let (raw_c, out_c, password) = (text(utf8(&scratch)?)?, text(&out)?, text(&doc.password)?); + let done = checked(unsafe { ely_print_copy(self.mupdf.ctx, doc.open.0, raw_c.as_ptr(), out_c.as_ptr(), password.as_ptr()) }); + clean(&scratch); + done?; + } + tag::REPAIR_REPORT => { + return Ok(match &doc.repair { + Some(report) => Pack::new().u64(1).str(report).bytes(), + None => Pack::new().u64(0).bytes(), + }); + } + other => return Err(io::Error::other(format!("no request {other}"))), + } + log::info!("pdf-worker: request {kind} done for document {id}"); + Ok(Vec::new()) + } +} diff --git a/crates/ely-pdf-engine/src/bin/ely-pdf-worker/redact.rs b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/redact.rs new file mode 100644 index 0000000..a1aaa7a --- /dev/null +++ b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/redact.rs @@ -0,0 +1,81 @@ +//! The shim's removal and cleaning calls (PDF-09, PDF-10). + +use std::{ + ffi::{c_char, c_int}, + io, + path::Path, +}; + +use crate::edit::{clean, raw, utf8}; +use crate::ffi::{Context, Document, Mupdf, Open, text}; + +unsafe extern "C" { + fn ely_redact( + ctx: *mut Context, + doc: *mut Document, + json: *const c_char, + raw: *const c_char, + out: *const c_char, + password: *const c_char, + data: *mut *mut u8, + len: *mut usize, + ) -> c_int; + fn ely_clean_scan( + ctx: *mut Context, + doc: *mut Document, + raw: *const c_char, + password: *const c_char, + data: *mut *mut u8, + len: *mut usize, + ) -> c_int; + fn ely_clean( + ctx: *mut Context, + doc: *mut Document, + mask: c_int, + raw: *const c_char, + out: *const c_char, + password: *const c_char, + data: *mut *mut u8, + len: *mut usize, + ) -> c_int; +} + +impl Mupdf { + /// Writes `out` with `json`'s regions removed; the check. + pub fn redact(&self, doc: &Open, json: &str, out: &Path, password: &str) -> io::Result> { + let scratch = raw(out); + let (json, raw_c, out_c, password) = (text(json)?, text(utf8(&scratch)?)?, text(utf8(out)?)?, text(password)?); + let done = self.owned(|data, len| unsafe { + ely_redact(self.ctx, doc.0, json.as_ptr(), raw_c.as_ptr(), out_c.as_ptr(), password.as_ptr(), data, len) + }); + clean(&scratch); + // A file that failed its check is not left behind. + if done.is_err() { + clean(out); + } + done + } + + /// Counts on a copy written to `scratch`, then removed. + pub fn clean_scan(&self, doc: &Open, scratch: &Path, password: &str) -> io::Result> { + let (raw_c, password) = (text(utf8(scratch)?)?, text(password)?); + let done = self.owned(|data, len| unsafe { ely_clean_scan(self.ctx, doc.0, raw_c.as_ptr(), password.as_ptr(), data, len) }); + clean(scratch); + done + } + + /// Writes `out` cleaned of `mask`; what is left. + pub fn clean(&self, doc: &Open, mask: u32, out: &Path, password: &str) -> io::Result> { + let mask = c_int::try_from(mask).map_err(io::Error::other)?; + let scratch = raw(out); + let (raw_c, out_c, password) = (text(utf8(&scratch)?)?, text(utf8(out)?)?, text(password)?); + let done = self.owned(|data, len| unsafe { + ely_clean(self.ctx, doc.0, mask, raw_c.as_ptr(), out_c.as_ptr(), password.as_ptr(), data, len) + }); + clean(&scratch); + if done.is_err() { + clean(out); + } + done + } +} diff --git a/crates/ely-pdf-engine/src/bin/ely-pdf-worker/secure.rs b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/secure.rs new file mode 100644 index 0000000..9cb20a2 --- /dev/null +++ b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/secure.rs @@ -0,0 +1,131 @@ +//! Permissions, owner access and protected copies (PDF-11). + +use std::{ + ffi::{c_char, c_int}, + io, + path::Path, +}; + +use ely_pdf_engine::proto::{may, tag}; + +use crate::edit::{clean, raw, utf8}; +use crate::ffi::{Context, Document, Mupdf, Open, checked, text}; + +unsafe extern "C" { + fn ely_permissions(ctx: *mut Context, doc: *mut Document) -> c_int; + fn ely_protect( + ctx: *mut Context, + doc: *mut Document, + raw: *const c_char, + out: *const c_char, + password: *const c_char, + protect: c_int, + user: *const c_char, + owner: *const c_char, + may: c_int, + ) -> c_int; +} + +/// What each request needs the document to allow. +pub fn needs(kind: u8) -> u32 { + match kind { + tag::DELETE | tag::MOVE | tag::ROTATE | tag::BLANK | tag::INSERT | tag::OUTLINE_EDIT => may::ASSEMBLE, + tag::CROP | tag::PUT_TEXT | tag::PLACE | tag::ADD_IMAGE | tag::TEXT_LAYER | tag::LINK_EDIT => may::EDIT, + tag::SET_METADATA | tag::ATTACH | tag::DETACH | tag::REDACT | tag::CLEAN => may::EDIT, + tag::ANNOTATE | tag::CHANGE_ANNOT => may::ANNOTATE, + tag::ADD_FIELD | tag::CHANGE_FIELD => may::EDIT | may::ANNOTATE, + tag::FILL => may::FILL, + tag::EXTRACT => may::COPY, + tag::PROTECT => may::OWNER, + _ => 0, + } +} + +/// The bits `needed` lacks in `has`, as an error. +pub fn refuse(needed: u32, has: u32) -> io::Result<()> { + let missing = needed & !has; + match may::NAMES.iter().find(|(bit, _)| missing & bit != 0) { + Some((_, name)) => Err(io::Error::other(format!("{}{name}", may::REFUSED))), + None => Ok(()), + } +} + +impl Mupdf { + /// Ely's permission bits; `owner` adds full access. + pub fn permissions(&self, doc: &Open, owner: bool) -> io::Result { + let bits = checked(unsafe { ely_permissions(self.ctx, doc.0) })? as u32; + Ok(if owner { bits | may::OWNER } else { bits }) + } + + /// Writes `out` protected, or unprotected without `lock`. + pub fn protect(&self, doc: &Open, out: &Path, password: &str, lock: Option<(&str, &str, u32)>) -> io::Result<()> { + let (user, owner, bits) = lock.unwrap_or(("", "", 0)); + let scratch = raw(out); + let (raw_c, out_c, password) = (text(utf8(&scratch)?)?, text(utf8(out)?)?, text(password)?); + let (user, owner) = (text(user)?, text(owner)?); + let bits = c_int::try_from(bits).map_err(io::Error::other)?; + let done = checked(unsafe { + ely_protect( + self.ctx, + doc.0, + raw_c.as_ptr(), + out_c.as_ptr(), + password.as_ptr(), + c_int::from(lock.is_some()), + user.as_ptr(), + owner.as_ptr(), + bits, + ) + }); + clean(&scratch); + if done.is_err() { + clean(out); + } + done.map(|_| ()) + } +} + +impl crate::Worker { + /// What document `id` allows now. + pub(crate) fn allowed(&self, id: u64) -> io::Result { + let doc = self.record(id)?; + self.mupdf.permissions(&doc.open, doc.owner) + } + + /// Refuses `kind` on `id` when the document forbids it. + pub(crate) fn guard(&self, id: u64, kind: u8) -> io::Result<()> { + match needs(kind) { + 0 => Ok(()), + needed => refuse(needed, self.allowed(id)?), + } + } + + pub(crate) fn secure(&mut self, kind: u8, id: u64, ask: &mut ely_ipc::Unpack) -> io::Result> { + match kind { + tag::PERMISSIONS => Ok(ely_ipc::Pack::new().u64(u64::from(self.allowed(id)?)).bytes()), + tag::OWNER => { + let password = ask.str()?; + let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?; + let access = self.mupdf.unlock(&doc.open, &password)?; + let owner = access & 4 != 0; + match owner { + true => (doc.password, doc.owner) = (password, true), + // A refused try drops MuPDF's keys; the old ones return. + false => _ = self.mupdf.unlock(&doc.open, &doc.password)?, + } + log::info!("pdf-worker: owner password for document {id}: {}", if owner { "accepted" } else { "refused" }); + Ok(ely_ipc::Pack::new().u64(u64::from(owner)).bytes()) + } + tag::PROTECT => { + let (out, lock) = (ask.str()?, ask.u64()? != 0); + let (user, owner, bits) = (ask.str()?, ask.str()?, ask.u64()? as u32); + let doc = self.record(id)?; + let lock = lock.then_some((user.as_str(), owner.as_str(), bits)); + self.mupdf.protect(&doc.open, out.as_ref(), &doc.password, lock)?; + log::info!("pdf-worker: wrote a {} copy of document {id}", if lock.is_some() { "protected" } else { "unprotected" }); + Ok(Vec::new()) + } + other => Err(io::Error::other(format!("no request {other}"))), + } + } +} diff --git a/crates/ely-pdf-engine/src/bin/ely-pdf-worker/sign.rs b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/sign.rs new file mode 100644 index 0000000..33a271e --- /dev/null +++ b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/sign.rs @@ -0,0 +1,113 @@ +//! The shim's signature calls (PDF-12). + +use std::{ + ffi::{c_char, c_int}, + io, + path::Path, +}; + +use ely_pdf_engine::{Signing, proto::{may, tag}}; + +use crate::edit::{clean, utf8}; +use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text}; +use crate::secure::refuse; + +unsafe extern "C" { + fn ely_signatures(ctx: *mut Context, doc: *mut Document, data: *mut *mut u8, len: *mut usize) -> c_int; + fn ely_signed(ctx: *mut Context, doc: *mut Document) -> c_int; + fn ely_sign( + ctx: *mut Context, + doc: *mut Document, + raw: *const c_char, + password: *const c_char, + pfx: *const c_char, + pfx_password: *const c_char, + number: c_int, + object: c_int, + rect: *const f32, + name: *const c_char, + reason: *const c_char, + location: *const c_char, + ) -> c_int; +} + +impl Mupdf { + pub fn signatures(&self, doc: &Open) -> io::Result> { + self.owned(|data, len| unsafe { ely_signatures(self.ctx, doc.0, data, len) }) + } + + /// Writes `out` signed; a failure leaves nothing. + pub fn sign(&self, doc: &Open, out: &Path, password: &str, how: &Signing) -> io::Result<()> { + let object = match how.object { + Some(object) => c_int::try_from(object).map_err(io::Error::other)?, + None => -1, + }; + let (out_c, password) = (text(utf8(out)?)?, text(password)?); + let (pfx, pfx_password, name) = (text(&how.pfx)?, text(&how.pfx_password)?, text(&how.name)?); + let (reason, location) = (text(&how.reason)?, text(&how.location)?); + let done = checked(unsafe { + ely_sign( + self.ctx, + doc.0, + out_c.as_ptr(), + password.as_ptr(), + pfx.as_ptr(), + pfx_password.as_ptr(), + page(how.page)?, + object, + how.rect.as_ptr(), + name.as_ptr(), + reason.as_ptr(), + location.as_ptr(), + ) + }); + if done.is_err() { + clean(out); + } + done.map(|_| ()) + } +} + +impl crate::Worker { + pub(crate) fn signing(&mut self, kind: u8, id: u64, ask: &mut ely_ipc::Unpack) -> io::Result> { + match kind { + tag::SIGNATURES => { + // A file's signatures, not the edited copy's. + let path = ask.str()?; + let password = self.record(id)?.password.clone(); + let file = self.mupdf.open(&path)?; + let read = match self.mupdf.locked(&file) { + Ok(true) if self.mupdf.unlock(&file, &password)? == 0 => Err(io::Error::other("the file refused the document's password")), + Ok(_) => self.mupdf.signatures(&file), + Err(error) => Err(error), + }; + self.mupdf.close(file); + read + } + tag::SIGNED => { + let count = checked(unsafe { ely_signed(self.mupdf.ctx, self.record(id)?.open.0) })?; + Ok(ely_ipc::Pack::new().u64(count as u64).bytes()) + } + tag::SIGN => { + let out = ask.str()?; + let (page, object) = (ask.u64()? as usize, ask.i64()?); + let mut rect = [0f32; 4]; + for value in &mut rect { + *value = f32::from_bits(ask.u64()? as u32); + } + let (name, pfx, pfx_password) = (ask.str()?, ask.str()?, ask.str()?); + let (reason, location) = (ask.str()?, ask.str()?); + let object = u32::try_from(object).ok(); + // Filling a field, or adding one to the page. + let needed = if object.is_some() { may::FILL } else { may::EDIT | may::ANNOTATE }; + refuse(needed, self.allowed(id)?)?; + let how = Signing { page, object, rect, name, pfx, pfx_password, reason, location }; + let doc = self.record(id)?; + self.mupdf.sign(&doc.open, out.as_ref(), &doc.password, &how)?; + log::info!("pdf-worker: wrote a signed copy of document {id}"); + Ok(Vec::new()) + } + other => Err(io::Error::other(format!("no request {other}"))), + } + } +} diff --git a/crates/ely-pdf-engine/src/bin/ely-pdf-worker/structure.rs b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/structure.rs new file mode 100644 index 0000000..397bc44 --- /dev/null +++ b/crates/ely-pdf-engine/src/bin/ely-pdf-worker/structure.rs @@ -0,0 +1,65 @@ +//! The shim's bookmark, link, property and attachment calls (PDF-07). + +use std::{ + ffi::{c_char, c_int}, + io, +}; + +use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text}; + +unsafe extern "C" { + fn ely_outline_edit(ctx: *mut Context, doc: *mut Document, json: *const c_char) -> c_int; + fn ely_links(ctx: *mut Context, doc: *mut Document, page: c_int, data: *mut *mut u8, len: *mut usize) -> c_int; + fn ely_link_edit(ctx: *mut Context, doc: *mut Document, page: c_int, json: *const c_char) -> c_int; + fn ely_metadata(ctx: *mut Context, doc: *mut Document, data: *mut *mut u8, len: *mut usize) -> c_int; + fn ely_set_metadata(ctx: *mut Context, doc: *mut Document, json: *const c_char) -> c_int; + fn ely_attachments(ctx: *mut Context, doc: *mut Document, data: *mut *mut u8, len: *mut usize) -> c_int; + fn ely_attachment(ctx: *mut Context, doc: *mut Document, name: *const c_char, data: *mut *mut u8, len: *mut usize) -> c_int; + fn ely_attach(ctx: *mut Context, doc: *mut Document, name: *const c_char, path: *const c_char) -> c_int; + fn ely_detach(ctx: *mut Context, doc: *mut Document, name: *const c_char) -> c_int; +} + +impl Mupdf { + pub fn outline_edit(&self, doc: &Open, json: &str) -> io::Result<()> { + let json = text(json)?; + checked(unsafe { ely_outline_edit(self.ctx, doc.0, json.as_ptr()) }).map(|_| ()) + } + + pub fn links(&self, doc: &Open, at: usize) -> io::Result> { + let at = page(at)?; + self.owned(|data, len| unsafe { ely_links(self.ctx, doc.0, at, data, len) }) + } + + pub fn link_edit(&self, doc: &Open, at: usize, json: &str) -> io::Result<()> { + let json = text(json)?; + checked(unsafe { ely_link_edit(self.ctx, doc.0, page(at)?, json.as_ptr()) }).map(|_| ()) + } + + pub fn metadata(&self, doc: &Open) -> io::Result> { + self.owned(|data, len| unsafe { ely_metadata(self.ctx, doc.0, data, len) }) + } + + pub fn set_metadata(&self, doc: &Open, json: &str) -> io::Result<()> { + let json = text(json)?; + checked(unsafe { ely_set_metadata(self.ctx, doc.0, json.as_ptr()) }).map(|_| ()) + } + + pub fn attachments(&self, doc: &Open) -> io::Result> { + self.owned(|data, len| unsafe { ely_attachments(self.ctx, doc.0, data, len) }) + } + + pub fn attachment(&self, doc: &Open, name: &str) -> io::Result> { + let name = text(name)?; + self.owned(|data, len| unsafe { ely_attachment(self.ctx, doc.0, name.as_ptr(), data, len) }) + } + + pub fn attach(&self, doc: &Open, name: &str, path: &str) -> io::Result<()> { + let (name, path) = (text(name)?, text(path)?); + checked(unsafe { ely_attach(self.ctx, doc.0, name.as_ptr(), path.as_ptr()) }).map(|_| ()) + } + + pub fn detach(&self, doc: &Open, name: &str) -> io::Result<()> { + let name = text(name)?; + checked(unsafe { ely_detach(self.ctx, doc.0, name.as_ptr()) }).map(|_| ()) + } +} diff --git a/crates/ely-pdf-engine/src/client.rs b/crates/ely-pdf-engine/src/client.rs new file mode 100644 index 0000000..a224c5c --- /dev/null +++ b/crates/ely-pdf-engine/src/client.rs @@ -0,0 +1,302 @@ +//! The app's side: spawn the worker, ask, read the answer. + +use std::{ + io::{self, BufReader, BufWriter}, + path::{Path, PathBuf}, + process::{Child, ChildStdin, ChildStdout, Command, Stdio}, +}; + +use ely_ipc::{Pack, Unpack, check_reply, read_frame, write_frame}; + +use crate::proto::{self, Bookmark, Char, tag}; + + +/// The PDF worker process. +pub struct Worker { + child: Child, + stdin: BufWriter, + stdout: BufReader, + /// The last request's id; each reply must carry it. + asked: u64, + /// This worker among all spawned; its documents carry it. + session: u64, +} + +static SESSIONS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// An open document: its worker, number and history mark. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Doc { + worker: u64, + id: u64, + /// The history mark this handle was read at. + mark: u64, +} + +impl Doc { + /// This document as of history `mark`, which an edit answered. + pub fn at(self, mark: &str) -> io::Result { + let mark = mark.parse().map_err(|_| io::Error::other(format!("a history mark that is not a number: {mark}")))?; + Ok(Doc { mark, ..self }) + } +} + +/// Whether `error` refused a request made for an older state. +pub fn is_stale(error: &io::Error) -> bool { + error.to_string().starts_with(proto::STALE) +} + +/// Drawn pixels, BGRA, and whether drawing them met damage. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Tile { + pub bgra: Vec, + pub damaged: bool, +} + +/// A document just opened: locked until its password is given. +#[derive(Clone, Copy, Debug)] +pub struct Opened { + pub doc: Doc, + pub locked: bool, +} + +const WORKER: &str = if cfg!(windows) { + "ely-pdf-worker.exe" +} else { + "ely-pdf-worker" +}; + +/// The worker binary: `ELY_PDF_WORKER`, beside the app, or this build's. +fn worker_bin() -> io::Result { + if let Some(bin) = std::env::var_os("ELY_PDF_WORKER") { + let bin = PathBuf::from(bin); + return match bin.is_file() { + true => Ok(bin), + false => Err(io::Error::new( + io::ErrorKind::NotFound, + format!("ELY_PDF_WORKER {} is not there", bin.display()), + )), + }; + } + let exe = std::env::current_exe()?; + let beside = exe.parent().unwrap_or(Path::new(".")).join(WORKER); + if beside.is_file() { + return Ok(beside); + } + // Test binaries live one folder below the worker. + let above = exe + .parent() + .and_then(Path::parent) + .map(|dir| dir.join(WORKER)); + above.filter(|bin| bin.is_file()).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "no ely-pdf-worker binary (set ELY_PDF_WORKER)", + ) + }) +} + +impl Worker { + pub fn spawn() -> io::Result { + Self::spawn_from(&worker_bin()?) + } + + /// Spawns `bin`; one from other sources is refused. + pub fn spawn_from(bin: &Path) -> io::Result { + let mut child = Command::new(bin) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| io::Error::new(error.kind(), format!("pdf-worker spawn: {error}")))?; + // Its records reach the app log; nothing reads them back. + drop(ely_ipc::relay(child.stderr.take().expect("piped stderr"), "pdf-worker")); + let stdin = BufWriter::new(child.stdin.take().expect("piped stdin")); + let stdout = BufReader::new(child.stdout.take().expect("piped stdout")); + let mut worker = Worker { + child, + stdin, + stdout, + asked: 0, + session: SESSIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst), + }; + log::info!("pdf: worker from {}", bin.display()); + let reply = worker.request(tag::HELLO, &[])?; + if Unpack::new(&reply).str()? != proto::BUILD { + return Err(io::Error::other(format!( + "the pdf worker {} is from other sources; run cargo build -p ely-pdf-engine --bins", + bin.display() + ))); + } + log::info!("pdf: worker up"); + Ok(worker) + } + + /// `doc` as it is now, for tests that chain edits. + #[cfg(any(test, feature = "test-support"))] + pub fn now(&mut self, doc: Doc) -> io::Result { + let mark = self.history(doc)?; + doc.at(&mark) + } + + /// Why the worker process ended, if it has. + pub fn ended(&mut self) -> Option { + match self.child.try_wait() { + Ok(status) => status.map(|status| status.to_string()), + Err(error) => Some(error.to_string()), + } + } + + /// The worker's process id, for resource tests. + #[cfg(any(test, feature = "test-support"))] + pub fn pid(&self) -> u32 { + self.child.id() + } + + /// A request aimed at pages or objects `doc` saw. + pub(crate) fn on(&self, doc: &Doc) -> io::Result { + Ok(Pack::new().u64(self.doc_id(doc)?).u64(doc.mark)) + } + + /// A request on `doc` whatever its state: the whole document. + pub(crate) fn whole(&self, doc: &Doc) -> io::Result { + Ok(Pack::new().u64(self.doc_id(doc)?).u64(proto::ANY)) + } + + /// `doc`'s number here; another worker's document is refused. + fn doc_id(&self, doc: &Doc) -> io::Result { + match doc.worker == self.session { + true => Ok(doc.id), + false => Err(io::Error::new(io::ErrorKind::NotFound, "a document of another pdf worker")), + } + } + + pub(crate) fn request(&mut self, kind: u8, payload: &[u8]) -> io::Result> { + self.asked += 1; + write_frame(&mut self.stdin, kind, self.asked, payload)?; + let frame = read_frame(&mut self.stdout)?; + if let Some((_, id, _)) = &frame { + check_reply(self.asked, *id)?; + } + match frame { + Some((tag::OK, _, reply)) => Ok(reply), + Some((tag::ERR, _, reply)) => Err(refusal(String::from_utf8_lossy(&reply).into_owned())), + Some((other, _, _)) => Err(io::Error::other(format!("pdf-worker answered tag {other}"))), + None => Err(io::Error::other(match self.child.try_wait() { + Ok(Some(status)) => format!("the pdf worker exited: {status}"), + _ => "the pdf worker closed its pipe".to_string(), + })), + } + } + + /// Opens `path`; a locked file waits for `unlock`. + pub fn open(&mut self, path: &Path) -> io::Result { + let path = path + .to_str() + .ok_or_else(|| io::Error::other("a path that is not UTF-8"))?; + let reply = self.request(tag::OPEN, &Pack::new().str(path).bytes())?; + let mut reply = Unpack::new(&reply); + Ok(Opened { + doc: Doc { worker: self.session, id: reply.u64()?, mark: 0 }, + locked: reply.u64()? != 0, + }) + } + + /// Tries `password`; false when it is wrong. + pub fn unlock(&mut self, doc: Doc, password: &str) -> io::Result { + let reply = self.request(tag::UNLOCK, &self.whole(&doc)?.str(password).bytes())?; + Ok(Unpack::new(&reply).u64()? != 0) + } + + pub fn pages(&mut self, doc: Doc) -> io::Result { + let reply = self.request(tag::PAGES, &self.whole(&doc)?.bytes())?; + Ok(Unpack::new(&reply).u64()? as usize) + } + + /// Page boxes in points, `count` from `first`. + pub fn bounds(&mut self, doc: Doc, first: usize, count: usize) -> io::Result> { + let ask = self.on(&doc)? + .u64(first as u64) + .u64(count as u64) + .bytes(); + let reply = self.request(tag::BOUNDS, &ask)?; + let mut reply = Unpack::new(&reply); + let mut boxes = Vec::with_capacity(count); + for _ in 0..count { + let mut one = [0f32; 4]; + for value in &mut one { + *value = f32::from_bits(reply.u64()? as u32); + } + boxes.push(one); + } + match reply.rest().is_empty() { + true => Ok(boxes), + false => Err(io::Error::other("a bounds reply too long")), + } + } + + /// A page's device pixels `tile` (x, y, w, h), BGRA. + pub fn render( + &mut self, + doc: Doc, + page: usize, + (scale, rotate): (f32, i32), + tile: [i32; 4], + dark: bool, + ) -> io::Result { + let mut ask = self.on(&doc)? + .u64(page as u64) + .u64(u64::from(scale.to_bits())) + .i64(i64::from(rotate)); + for value in tile { + ask = ask.i64(i64::from(value)); + } + let reply = self.request(tag::RENDER, &ask.u64(u64::from(dark)).bytes())?; + let mut reply = Unpack::new(&reply); + let (damaged, bgra) = (reply.u64()? != 0, reply.blob()?); + match bgra.len() == tile[2] as usize * tile[3] as usize * 4 { + true => Ok(Tile { bgra, damaged }), + false => Err(io::Error::other("a tile of the wrong size")), + } + } + + pub fn text(&mut self, doc: Doc, page: usize) -> io::Result> { + proto::chars(&self.request(tag::TEXT, &self.on(&doc)?.u64(page as u64).bytes())?) + } + + /// Where `needle` is on a page: each hit's quads. + pub fn search(&mut self, doc: Doc, page: usize, needle: &str) -> io::Result>> { + let ask = self.on(&doc)?.u64(page as u64).str(needle).bytes(); + proto::hits(&self.request(tag::SEARCH, &ask)?) + } + + pub fn outline(&mut self, doc: Doc) -> io::Result> { + let reply = self.request(tag::OUTLINE, &self.whole(&doc)?.bytes())?; + proto::outline(&String::from_utf8(reply).map_err(io::Error::other)?) + } + + pub fn close(&mut self, doc: Doc) -> io::Result<()> { + self.request(tag::CLOSE, &self.whole(&doc)?.bytes()) + .map(|_| ()) + } +} + +impl Drop for Worker { + fn drop(&mut self) { + // The worker holds nothing that needs saving. + if let Err(error) = self.child.kill() { + log::info!("pdf: the worker was already gone: {error}"); + } + if let Err(error) = self.child.wait() { + log::warn!("pdf: the worker was not reaped: {error}"); + } + } +} + +/// A permission refusal is `PermissionDenied`, named by what it needs. +fn refusal(text: String) -> io::Error { + match text.strip_prefix(crate::proto::may::REFUSED) { + Some(needed) => io::Error::new(io::ErrorKind::PermissionDenied, needed.to_string()), + None => io::Error::other(text), + } +} diff --git a/crates/ely-pdf-engine/src/content.rs b/crates/ely-pdf-engine/src/content.rs new file mode 100644 index 0000000..7d63c0f --- /dev/null +++ b/crates/ely-pdf-engine/src/content.rs @@ -0,0 +1,151 @@ +//! Text blocks and drawn objects: read, write, move (PDF-03, PDF-04). + +use std::{io, path::Path}; + +use ely_ipc::{Pack, Unpack}; +use serde_json::Value; + +use crate::client::{Doc, Worker}; +use crate::proto::tag; + +/// A text block as MuPDF reads it, in page points. +#[derive(Clone, Debug, PartialEq)] +pub struct TextBlock { + pub rect: [f32; 4], + pub text: String, + pub font: String, + pub size: f32, + pub rgb: u32, + /// Upright, and every glyph in the page's own content. + pub editable: bool, +} + +/// An image or form XObject drawn on the page. +#[derive(Clone, Debug, PartialEq)] +pub struct Drawn { + pub form: bool, + pub rect: [f32; 4], +} + +/// Text to write, replacing block `block` or new. +#[derive(Clone, Debug, PartialEq)] +pub struct Writing { + pub block: Option, + /// The block's text as read; a change since refuses. + pub expected: String, + pub text: String, + /// "original", "helvetica", "times", "courier", "cjk-sc" and so on. + pub font: String, + pub size: f32, + pub rgb: u32, + /// Page points: left, top, width. + pub bx: [f32; 3], +} + +/// What writing did, or would do. +#[derive(Clone, Debug, PartialEq)] +pub enum Wrote { + /// Done; the history mark after it. + Done(String), + /// The font lacks this character; nothing changed. + Missing(char), + /// BGRA of the area, its size, whether text grew. + Preview(Vec, (u32, u32), bool), +} + +fn rect(value: &Value) -> io::Result<[f32; 4]> { + let bad = || io::Error::other(format!("a bad rectangle {value}")); + let list = value.as_array().filter(|list| list.len() == 4).ok_or_else(bad)?; + let mut out = [0f32; 4]; + for (at, side) in list.iter().enumerate() { + out[at] = side.as_f64().ok_or_else(bad)? as f32; + } + Ok(out) +} + +fn list(bytes: &[u8]) -> io::Result> { + match serde_json::from_slice(bytes).map_err(io::Error::other)? { + Value::Array(list) => Ok(list), + other => Err(io::Error::other(format!("not a list: {other}"))), + } +} + +fn block(value: &Value) -> io::Result { + let bad = || io::Error::other(format!("a bad text block {value}")); + Ok(TextBlock { + rect: rect(&value["rect"])?, + text: value["text"].as_str().ok_or_else(bad)?.to_string(), + font: value["font"].as_str().ok_or_else(bad)?.to_string(), + size: value["size"].as_f64().ok_or_else(bad)? as f32, + rgb: value["color"].as_u64().ok_or_else(bad)? as u32, + editable: value["editable"].as_bool().ok_or_else(bad)?, + }) +} + +fn floats(ask: Pack, values: &[f32]) -> Pack { + values.iter().fold(ask, |ask, value| ask.u64(u64::from(value.to_bits()))) +} + +impl Worker { + pub fn text_blocks(&mut self, doc: Doc, page: usize) -> io::Result> { + let reply = self.request(tag::TEXT_BLOCKS, &self.on(&doc)?.u64(page as u64).bytes())?; + list(&reply)?.iter().map(block).collect() + } + + pub fn objects(&mut self, doc: Doc, page: usize) -> io::Result> { + let reply = self.request(tag::OBJECTS, &self.on(&doc)?.u64(page as u64).bytes())?; + list(&reply)? + .iter() + .map(|value| Ok(Drawn { form: value["kind"] == "form", rect: rect(&value["rect"])? })) + .collect() + } + + /// Writes `w` on `page`; a `scale` above zero only previews. + pub fn put_text(&mut self, doc: Doc, page: usize, w: &Writing, scale: f32) -> io::Result { + let block = w.block.map_or(-1, |block| block as i64); + let ask = self.on(&doc)?.u64(page as u64).i64(block).str(&w.expected).str(&w.text).str(&w.font); + let ask = floats(ask.u64(u64::from(w.size.to_bits())).u64(u64::from(w.rgb)), &w.bx); + let reply = self.request(tag::PUT_TEXT, &ask.u64(u64::from(scale.to_bits())).bytes())?; + let mut reply = Unpack::new(&reply); + match reply.u64()? { + 0 => Ok(Wrote::Done(reply.str()?)), + 2 => { + let code = u32::try_from(reply.u64()?).map_err(io::Error::other)?; + Ok(Wrote::Missing(char::from_u32(code).ok_or_else(|| io::Error::other("a bad missing glyph"))?)) + } + _ => { + let size = (reply.u64()? as u32, reply.u64()? as u32); + let grew = reply.u64()? != 0; + Ok(Wrote::Preview(reply.blob()?, size, grew)) + } + } + } + + /// Moves or scales draw `index` by page-space `t`; the mark. + pub fn place(&mut self, doc: Doc, page: usize, index: usize, t: [f32; 6]) -> io::Result { + self.placing(doc, page, index, t, false, "") + } + + pub fn delete_object(&mut self, doc: Doc, page: usize, index: usize) -> io::Result { + self.placing(doc, page, index, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], true, "") + } + + /// Swaps image `index` for `path`, fitted in its box. + pub fn replace_image(&mut self, doc: Doc, page: usize, index: usize, path: &Path) -> io::Result { + let path = path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))?; + self.placing(doc, page, index, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], false, path) + } + + fn placing(&mut self, doc: Doc, page: usize, index: usize, t: [f32; 6], drop: bool, swap: &str) -> io::Result { + let ask = floats(self.on(&doc)?.u64(page as u64).u64(index as u64), &t); + let reply = self.request(tag::PLACE, &ask.u64(u64::from(drop)).str(swap).bytes())?; + Unpack::new(&reply).str() + } + + /// Draws the image at `path` into page-space `rect`; the mark. + pub fn add_image(&mut self, doc: Doc, page: usize, path: &Path, rect: [f32; 4]) -> io::Result { + let path = path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))?; + let ask = floats(self.on(&doc)?.u64(page as u64).str(path), &rect); + Unpack::new(&self.request(tag::ADD_IMAGE, &ask.bytes())?).str() + } +} diff --git a/crates/ely-pdf-engine/src/edit.rs b/crates/ely-pdf-engine/src/edit.rs new file mode 100644 index 0000000..9ad44a2 --- /dev/null +++ b/crates/ely-pdf-engine/src/edit.rs @@ -0,0 +1,84 @@ +//! Page edits, undo and saving (PDF-02). + +use std::{io, path::Path}; + +use ely_ipc::{Pack, Unpack}; + +use crate::client::{Doc, Worker}; +use crate::proto::tag; + +/// `ask` (the document) then sorted, distinct `pages`. +fn with_pages(ask: Pack, pages: &[usize]) -> Pack { + pages + .iter() + .fold(ask.u64(pages.len() as u64), |ask, page| ask.u64(*page as u64)) +} + +fn utf8(path: &Path) -> io::Result<&str> { + path.to_str() + .ok_or_else(|| io::Error::other("a path that is not UTF-8")) +} + +impl Worker { + /// The history mark: equal marks, equal documents. + pub fn history(&mut self, doc: Doc) -> io::Result { + self.mark(tag::HISTORY, self.whole(&doc)?) + } + + fn mark(&mut self, kind: u8, ask: Pack) -> io::Result { + Unpack::new(&self.request(kind, &ask.bytes())?).str() + } + + /// Undoes, or redoes, one edit. + pub fn undo(&mut self, doc: Doc, redo: bool) -> io::Result { + self.mark(tag::UNDO, self.whole(&doc)?.u64(u64::from(redo))) + } + + pub fn delete_pages(&mut self, doc: Doc, pages: &[usize]) -> io::Result { + self.mark(tag::DELETE, with_pages(self.on(&doc)?, pages)) + } + + /// Moves `pages` before page `to`, keeping their order. + pub fn move_pages(&mut self, doc: Doc, pages: &[usize], to: usize) -> io::Result { + self.mark(tag::MOVE, with_pages(self.on(&doc)?, pages).u64(to as u64)) + } + + /// Turns `pages` clockwise by `by` degrees, a multiple of 90. + pub fn rotate_pages(&mut self, doc: Doc, pages: &[usize], by: i32) -> io::Result { + self.mark(tag::ROTATE, with_pages(self.on(&doc)?, pages).i64(i64::from(by))) + } + + /// Crops by margins as seen: top, right, bottom, left points. + pub fn crop_pages(&mut self, doc: Doc, pages: &[usize], margins: [f32; 4]) -> io::Result { + let ask = margins + .iter() + .fold(with_pages(self.on(&doc)?, pages), |ask, side| ask.u64(u64::from(side.to_bits()))); + self.mark(tag::CROP, ask) + } + + /// A blank page of `size` points at `at`. + pub fn insert_blank(&mut self, doc: Doc, at: usize, (w, h): (f32, f32)) -> io::Result { + let ask = self.on(&doc)?.u64(at as u64); + self.mark(tag::BLANK, ask.u64(u64::from(w.to_bits())).u64(u64::from(h.to_bits()))) + } + + /// Inserts every page of `path`; answers count and mark. + pub fn insert_file(&mut self, doc: Doc, at: usize, path: &Path) -> io::Result<(usize, String)> { + let ask = self.on(&doc)?.u64(at as u64).str(utf8(path)?).bytes(); + let reply = self.request(tag::INSERT, &ask)?; + let mut reply = Unpack::new(&reply); + Ok((reply.u64()? as usize, reply.str()?)) + } + + /// Writes only `pages` to `path`; the document stays. + pub fn extract(&mut self, doc: Doc, pages: &[usize], path: &Path) -> io::Result<()> { + let ask = with_pages(self.on(&doc)?, pages).str(utf8(path)?).bytes(); + self.request(tag::EXTRACT, &ask).map(|_| ()) + } + + /// Writes the document, compacted, to `path`. + pub fn save(&mut self, doc: Doc, path: &Path) -> io::Result<()> { + let ask = self.whole(&doc)?.str(utf8(path)?).bytes(); + self.request(tag::SAVE, &ask).map(|_| ()) + } +} diff --git a/crates/ely-pdf-engine/src/lib.rs b/crates/ely-pdf-engine/src/lib.rs new file mode 100644 index 0000000..1b63f9e --- /dev/null +++ b/crates/ely-pdf-engine/src/lib.rs @@ -0,0 +1,26 @@ +//! MuPDF in its own process: protocol and client (PDF-01). + +mod client; +mod content; +mod edit; +mod marks; +mod ocr; +mod output; +mod redact; +mod secure; +mod sign; +mod structure; +pub mod proto; +#[cfg(any(test, feature = "test-support"))] +pub mod sample; + +pub use client::{Doc, Opened, Tile, Worker, is_stale}; +pub use content::{Drawn, TextBlock, Writing, Wrote}; +pub use ocr::{OcrLine, tessdata}; +pub use output::Optimizing; +pub use redact::{Cleaning, Reach, Redacted, Region}; +pub use secure::Lock; +pub use sign::{Digest, Signature, Signed, Signing}; +pub use structure::{Attachment, BookmarkChange, Link, LinkChange, Properties, Target}; +pub use marks::{Annot, Field, FieldKind, Marks, NewAnnot, NewField, Shape}; +pub use proto::{Bookmark, Char}; diff --git a/crates/ely-pdf-engine/src/marks.rs b/crates/ely-pdf-engine/src/marks.rs new file mode 100644 index 0000000..969d16d --- /dev/null +++ b/crates/ely-pdf-engine/src/marks.rs @@ -0,0 +1,267 @@ +//! Annotations and form fields: read, add, change (PDF-05, PDF-06). + +use std::io; + +use ely_ipc::Unpack; +use serde_json::{Value, json}; + +use crate::client::{Doc, Worker}; +use crate::proto::tag; + +/// An annotation as the page holds it. +#[derive(Clone, Debug, PartialEq)] +pub struct Annot { + /// The object number; stays through edits. + pub id: u64, + /// The PDF subtype: Highlight, Text, Ink and so on. + pub kind: String, + pub rect: [f32; 4], + pub rgb: Option, + pub contents: String, + pub author: String, + pub icon: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldKind { + Text, + Checkbox, + Radio, + Choice, + List, + Signature, + Button, +} + +/// One widget of a form field. +#[derive(Clone, Debug, PartialEq)] +pub struct Field { + pub id: u64, + pub kind: FieldKind, + pub name: String, + pub rect: [f32; 4], + pub value: String, + /// A button's on state. + pub on: String, + pub checked: bool, + pub readonly: bool, + /// Choice values, and what each shows. + pub options: Vec, + pub labels: Vec, +} + +/// What a page holds besides content. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Marks { + pub annots: Vec, + pub fields: Vec, +} + +/// A new annotation's shape, in page points. +#[derive(Clone, Debug, PartialEq)] +pub enum Shape { + Highlight(Vec<[f32; 4]>), + Underline(Vec<[f32; 4]>), + StrikeOut(Vec<[f32; 4]>), + Note((f32, f32)), + Square([f32; 4]), + Circle([f32; 4]), + Line([f32; 4]), + Ink(Vec>), + Stamp([f32; 4], String), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct NewAnnot { + pub page: usize, + pub shape: Shape, + pub rgb: Option, + pub contents: String, + pub author: String, +} + +/// A new field; radios of one name form a group. +#[derive(Clone, Debug, PartialEq)] +pub struct NewField { + pub kind: FieldKind, + pub name: String, + pub rect: [f32; 4], + pub options: Vec, +} + +impl Field { + /// The last part of the name, which a rename sets. + pub fn leaf(&self) -> &str { + self.name.rfind('.').map_or(self.name.as_str(), |dot| &self.name[dot + 1..]) + } +} + +impl FieldKind { + fn name(self) -> &'static str { + match self { + FieldKind::Text => "text", + FieldKind::Checkbox => "checkbox", + FieldKind::Radio => "radio", + FieldKind::Choice => "choice", + FieldKind::List => "list", + FieldKind::Signature => "signature", + FieldKind::Button => "button", + } + } +} + +fn spec(annot: &NewAnnot) -> Value { + let (kind, geometry) = match &annot.shape { + Shape::Highlight(rects) => ("highlight", json!({ "rects": rects })), + Shape::Underline(rects) => ("underline", json!({ "rects": rects })), + Shape::StrikeOut(rects) => ("strikeout", json!({ "rects": rects })), + Shape::Note(at) => ("note", json!({ "at": [at.0, at.1] })), + Shape::Square(rect) => ("square", json!({ "rect": rect })), + Shape::Circle(rect) => ("circle", json!({ "rect": rect })), + Shape::Line(line) => ("line", json!({ "line": line })), + Shape::Ink(strokes) => { + let flat: Vec> = strokes.iter().map(|s| s.iter().flat_map(|p| [p.0, p.1]).collect()).collect(); + ("ink", json!({ "strokes": flat })) + } + Shape::Stamp(rect, icon) => ("stamp", json!({ "rect": rect, "icon": icon })), + }; + let mut spec = json!({ "page": annot.page, "kind": kind, "contents": annot.contents, "author": annot.author }); + if let Some(rgb) = annot.rgb { + spec["color"] = json!(rgb); + } + spec.as_object_mut().expect("an object").extend(geometry.as_object().expect("an object").clone()); + spec +} + +fn bad(what: &str, value: &Value) -> io::Error { + io::Error::other(format!("a bad {what} {value}")) +} + +fn rect(value: &Value) -> io::Result<[f32; 4]> { + let list = value.as_array().filter(|list| list.len() == 4).ok_or_else(|| bad("rectangle", value))?; + let mut out = [0f32; 4]; + for (at, side) in list.iter().enumerate() { + out[at] = side.as_f64().ok_or_else(|| bad("rectangle", value))? as f32; + } + Ok(out) +} + +fn text(value: &Value, key: &str) -> io::Result { + value[key].as_str().map(str::to_string).ok_or_else(|| bad(key, value)) +} + +fn texts(value: &Value, key: &str) -> io::Result> { + let list = value[key].as_array().ok_or_else(|| bad(key, value))?; + list.iter().map(|item| item.as_str().map(str::to_string).ok_or_else(|| bad(key, value))).collect() +} + +fn annot(value: &Value) -> io::Result { + let color = value["color"].as_i64().ok_or_else(|| bad("annotation", value))?; + Ok(Annot { + id: value["id"].as_u64().ok_or_else(|| bad("annotation", value))?, + kind: text(value, "kind")?, + rect: rect(&value["rect"])?, + rgb: u32::try_from(color).ok(), + contents: text(value, "contents")?, + author: text(value, "author")?, + icon: text(value, "icon")?, + }) +} + +fn field(value: &Value) -> io::Result { + let kind = match value["kind"].as_str() { + Some("text") => FieldKind::Text, + Some("checkbox") => FieldKind::Checkbox, + Some("radio") => FieldKind::Radio, + Some("choice") => FieldKind::Choice, + Some("list") => FieldKind::List, + Some("signature") => FieldKind::Signature, + Some("button") => FieldKind::Button, + _ => return Err(bad("field", value)), + }; + let flag = |key: &str| value[key].as_bool().ok_or_else(|| bad(key, value)); + Ok(Field { + id: value["id"].as_u64().ok_or_else(|| bad("field", value))?, + kind, + name: text(value, "name")?, + rect: rect(&value["rect"])?, + value: text(value, "value")?, + on: text(value, "on")?, + checked: flag("checked")?, + readonly: flag("readonly")?, + options: texts(value, "options")?, + labels: texts(value, "labels")?, + }) +} + +impl Worker { + pub fn marks(&mut self, doc: Doc, page: usize) -> io::Result { + let reply = self.request(tag::ANNOTS, &self.on(&doc)?.u64(page as u64).bytes())?; + let value: Value = serde_json::from_slice(&reply).map_err(io::Error::other)?; + let list = |key: &str| value[key].as_array().ok_or_else(|| bad(key, &value)); + Ok(Marks { + annots: list("annots")?.iter().map(annot).collect::>()?, + fields: list("fields")?.iter().map(field).collect::>()?, + }) + } + + /// Every field name in the form, widgets or not. + pub fn field_names(&mut self, doc: Doc) -> io::Result> { + let reply = self.request(tag::FIELD_NAMES, &self.whole(&doc)?.bytes())?; + let value: Value = serde_json::from_slice(&reply).map_err(io::Error::other)?; + let list = value.as_array().ok_or_else(|| bad("name list", &value))?; + list.iter().map(|name| name.as_str().map(str::to_string).ok_or_else(|| bad("field name", name))).collect() + } + + /// Adds `annots` as one step; the mark. + pub fn annotate(&mut self, doc: Doc, annots: &[NewAnnot]) -> io::Result { + let specs = Value::Array(annots.iter().map(spec).collect()); + Unpack::new(&self.request(tag::ANNOTATE, &self.on(&doc)?.str(&specs.to_string()).bytes())?).str() + } + + fn on_object(&mut self, kind: u8, doc: Doc, page: usize, id: u64, value: &str) -> io::Result { + let ask = self.on(&doc)?.u64(page as u64).u64(id).str(value); + Unpack::new(&self.request(kind, &ask.bytes())?).str() + } + + pub fn change_annot(&mut self, doc: Doc, page: usize, id: u64, rgb: Option, contents: Option<&str>) -> io::Result { + let mut change = json!({}); + if let Some(rgb) = rgb { + change["color"] = json!(rgb); + } + if let Some(contents) = contents { + change["contents"] = json!(contents); + } + self.on_object(tag::CHANGE_ANNOT, doc, page, id, &change.to_string()) + } + + pub fn delete_annot(&mut self, doc: Doc, page: usize, id: u64) -> io::Result { + self.on_object(tag::DELETE_ANNOT, doc, page, id, "annot") + } + + pub fn delete_field(&mut self, doc: Doc, page: usize, id: u64) -> io::Result { + self.on_object(tag::DELETE_ANNOT, doc, page, id, "field") + } + + /// Sets a value; buttons take their on state or Off. + pub fn fill(&mut self, doc: Doc, page: usize, id: u64, value: &str) -> io::Result { + self.on_object(tag::FILL, doc, page, id, value) + } + + pub fn change_field(&mut self, doc: Doc, page: usize, id: u64, name: Option<&str>, options: Option<&[String]>) -> io::Result { + let mut change = json!({}); + if let Some(name) = name { + change["name"] = json!(name); + } + if let Some(options) = options { + change["options"] = json!(options); + } + self.on_object(tag::CHANGE_FIELD, doc, page, id, &change.to_string()) + } + + pub fn add_field(&mut self, doc: Doc, page: usize, field: &NewField) -> io::Result { + let spec = json!({ "kind": field.kind.name(), "name": field.name, "rect": field.rect, "options": field.options }); + let ask = self.on(&doc)?.u64(page as u64).str(&spec.to_string()); + Unpack::new(&self.request(tag::ADD_FIELD, &ask.bytes())?).str() + } +} diff --git a/crates/ely-pdf-engine/src/ocr.rs b/crates/ely-pdf-engine/src/ocr.rs new file mode 100644 index 0000000..3c21ff0 --- /dev/null +++ b/crates/ely-pdf-engine/src/ocr.rs @@ -0,0 +1,91 @@ +//! OCR: read pages, then lay their text in hidden (PDF-08). + +use std::{ + io, + path::{Path, PathBuf}, +}; + +use ely_ipc::Unpack; +use serde_json::{Value, json}; + +use crate::client::{Doc, Worker}; +use crate::proto::tag; + +/// A line Tesseract read, in page points. +#[derive(Clone, Debug, PartialEq)] +pub struct OcrLine { + pub rect: [f32; 4], + pub text: String, +} + +/// Beside the app, in its macOS bundle, or this build's. +fn beside(dir: &Path) -> PathBuf { + [dir.join("tessdata"), dir.join("../Resources/tessdata")] + .into_iter() + .find(|found| found.is_dir()) + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../engines/tessdata")) +} + +/// The language data: `ELY_TESSDATA`, the app's, or this build's. +pub fn tessdata() -> io::Result { + let found = match std::env::var_os("ELY_TESSDATA") { + Some(dir) => PathBuf::from(dir), + None => { + let exe = std::env::current_exe()?; + beside(exe.parent().unwrap_or(Path::new("."))) + } + }; + match found.join("eng.traineddata").is_file() { + true => Ok(found), + false => Err(io::Error::new( + io::ErrorKind::NotFound, + format!("no OCR data in {} (run scripts/fetch-tessdata.sh or set ELY_TESSDATA)", found.display()), + )), + } +} + +fn line(value: &Value) -> io::Result { + let bad = || io::Error::other(format!("a bad OCR line {value}")); + let list = value["rect"].as_array().filter(|list| list.len() == 4).ok_or_else(bad)?; + let mut rect = [0f32; 4]; + for (at, side) in list.iter().enumerate() { + rect[at] = side.as_f64().ok_or_else(bad)? as f32; + } + Ok(OcrLine { rect, text: value["text"].as_str().ok_or_else(bad)?.to_string() }) +} + +impl Worker { + /// Reads `page` in `langs` (as "eng+chi_sim") at `dpi`. + pub fn ocr_page(&mut self, doc: Doc, page: usize, langs: &str, data: &Path, dpi: f32) -> io::Result> { + let data = data.to_str().ok_or_else(|| io::Error::other("a data path that is not UTF-8"))?; + let ask = self.on(&doc)?.u64(page as u64).str(langs).str(data).u64(u64::from(dpi.to_bits())); + let reply: Value = serde_json::from_slice(&self.request(tag::OCR_PAGE, &ask.bytes())?).map_err(io::Error::other)?; + reply.as_array().ok_or_else(|| io::Error::other("an OCR reply that is not a list"))?.iter().map(line).collect() + } + + /// Lays `pages` in hidden text, one step; the mark. + pub fn text_layer(&mut self, doc: Doc, pages: &[(usize, Vec)], replace: bool) -> io::Result { + let spec: Vec = (pages.iter()) + .map(|(page, lines)| json!({ "page": page, "lines": lines.iter().map(|l| json!({ "rect": l.rect, "text": l.text })).collect::>() })) + .collect(); + let ask = self.on(&doc)?.str(&Value::Array(spec).to_string()).u64(u64::from(replace)); + Unpack::new(&self.request(tag::TEXT_LAYER, &ask.bytes())?).str() + } +} + +#[cfg(test)] +mod tests { + use super::beside; + + #[test] + fn a_bundle_finds_its_own_data() { + let app = tempfile::tempdir().expect("a temp dir"); + let (exe, data) = (app.path().join("Contents/MacOS"), app.path().join("Contents/Resources/tessdata")); + std::fs::create_dir_all(&exe).expect("MacOS"); + assert!(beside(&exe).ends_with("engines/tessdata")); + std::fs::create_dir_all(&data).expect("tessdata"); + assert_eq!(beside(&exe), exe.join("../Resources/tessdata")); + std::fs::create_dir(exe.join("tessdata")).expect("beside"); + assert_eq!(beside(&exe), exe.join("tessdata")); + } +} diff --git a/crates/ely-pdf-engine/src/output.rs b/crates/ely-pdf-engine/src/output.rs new file mode 100644 index 0000000..ee49034 --- /dev/null +++ b/crates/ely-pdf-engine/src/output.rs @@ -0,0 +1,60 @@ +//! Page images, text, optimized copies, repair reports (PDF-13, PDF-14). + +use std::{io, path::Path}; + +use ely_ipc::Unpack; + +use crate::client::{Doc, Worker}; +use crate::proto::tag; + +/// How an optimized copy is written. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Optimizing { + pub compress: bool, + /// Images above `from` dpi go to `to`; 0 keeps them. + pub from: u32, + pub to: u32, +} + +fn utf8(path: &Path) -> io::Result<&str> { + path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8")) +} + +impl Worker { + /// Page `page` as a PNG at `dpi`, white as printed. + pub fn export_image(&mut self, doc: Doc, page: usize, dpi: f32, path: &Path) -> io::Result<()> { + let ask = self.on(&doc)?.u64(page as u64).u64(u64::from(dpi.to_bits())).str(utf8(path)?); + self.request(tag::EXPORT_IMAGE, &ask.bytes()).map(|_| ()) + } + + /// Every page's text, UTF-8; form feeds part the pages. + pub fn export_text(&mut self, doc: Doc, path: &Path) -> io::Result<()> { + self.request(tag::EXPORT_TEXT, &self.whole(&doc)?.str(utf8(path)?).bytes()).map(|_| ()) + } + + pub fn optimize(&mut self, doc: Doc, out: &Path, how: Optimizing) -> io::Result<()> { + let ask = self.whole(&doc)?.str(utf8(out)?).u64(u64::from(how.compress)).u64(u64::from(how.from)).u64(u64::from(how.to)); + self.request(tag::OPTIMIZE, &ask.bytes()).map(|_| ()) + } + + /// The document as it is now, written to print. + pub fn print_copy(&mut self, doc: Doc, out: &Path) -> io::Result<()> { + self.request(tag::PRINT_COPY, &self.whole(&doc)?.str(utf8(out)?).bytes()).map(|_| ()) + } + + /// Decodes every picture; true if any failed. + pub fn check_pictures(&mut self, doc: Doc) -> io::Result { + let reply = self.request(tag::CHECK_PICTURES, &self.whole(&doc)?.bytes())?; + Ok(Unpack::new(&reply).u64()? != 0) + } + + /// MuPDF's warnings if the file was repaired to open. + pub fn repair_report(&mut self, doc: Doc) -> io::Result> { + let reply = self.request(tag::REPAIR_REPORT, &self.whole(&doc)?.bytes())?; + let mut reply = Unpack::new(&reply); + Ok(match reply.u64()? { + 0 => None, + _ => Some(reply.str()?), + }) + } +} diff --git a/crates/ely-pdf-engine/src/proto.rs b/crates/ely-pdf-engine/src/proto.rs new file mode 100644 index 0000000..455fe6e --- /dev/null +++ b/crates/ely-pdf-engine/src/proto.rs @@ -0,0 +1,209 @@ +//! What the app asks the PDF worker, and the answers. + +use std::io; + +use serde_json::Value; + +/// Request tags; replies are OK or ERR. +pub mod tag { + pub const OPEN: u8 = 1; + pub const UNLOCK: u8 = 2; + pub const PAGES: u8 = 3; + pub const BOUNDS: u8 = 4; + pub const RENDER: u8 = 5; + pub const TEXT: u8 = 6; + pub const SEARCH: u8 = 7; + pub const OUTLINE: u8 = 8; + pub const CLOSE: u8 = 9; + /// The worker's build stamp, checked on spawn. + pub const HELLO: u8 = 10; + /// Edits answer with the history mark after them. + pub const HISTORY: u8 = 11; + pub const UNDO: u8 = 12; + pub const DELETE: u8 = 13; + pub const MOVE: u8 = 14; + pub const ROTATE: u8 = 15; + pub const CROP: u8 = 16; + pub const BLANK: u8 = 17; + pub const INSERT: u8 = 18; + pub const EXTRACT: u8 = 19; + pub const SAVE: u8 = 20; + pub const TEXT_BLOCKS: u8 = 21; + pub const OBJECTS: u8 = 22; + /// Writes text; with a scale, only previews it. + pub const PUT_TEXT: u8 = 23; + pub const PLACE: u8 = 24; + pub const ADD_IMAGE: u8 = 25; + /// A page's annotations and fields, as JSON. + pub const ANNOTS: u8 = 26; + pub const ANNOTATE: u8 = 27; + pub const CHANGE_ANNOT: u8 = 28; + pub const DELETE_ANNOT: u8 = 29; + pub const ADD_FIELD: u8 = 30; + pub const FILL: u8 = 31; + pub const CHANGE_FIELD: u8 = 32; + /// Every field name in the form. + pub const FIELD_NAMES: u8 = 33; + /// Reads one page's lines; changes nothing. + pub const OCR_PAGE: u8 = 34; + pub const TEXT_LAYER: u8 = 35; + pub const OUTLINE_EDIT: u8 = 36; + pub const LINKS: u8 = 37; + pub const LINK_EDIT: u8 = 38; + pub const METADATA: u8 = 39; + pub const SET_METADATA: u8 = 40; + pub const ATTACHMENTS: u8 = 41; + /// An attachment's bytes. + pub const ATTACHMENT: u8 = 42; + pub const ATTACH: u8 = 43; + pub const DETACH: u8 = 44; + /// Writes a new file with regions removed, then checks it. + pub const REDACT: u8 = 45; + pub const CLEAN_SCAN: u8 = 46; + pub const CLEAN: u8 = 47; + pub const PERMISSIONS: u8 = 48; + /// Tries an owner password; a wrong one changes nothing. + pub const OWNER: u8 = 49; + pub const PROTECT: u8 = 50; + /// A file's signature fields and their checks, as JSON. + pub const SIGNATURES: u8 = 51; + /// Writes a signed copy; the document is untouched. + pub const SIGN: u8 = 52; + /// How many fields hold a signature; nothing checked. + pub const SIGNED: u8 = 53; + pub const EXPORT_IMAGE: u8 = 54; + pub const EXPORT_TEXT: u8 = 55; + /// Writes a copy compressed and downsampled. + pub const OPTIMIZE: u8 = 56; + /// Writes a copy to print; needs leave to print. + pub const PRINT_COPY: u8 = 57; + /// What MuPDF warned when it repaired the file. + pub const REPAIR_REPORT: u8 = 58; + /// Decodes every picture; answers whether any failed. + pub const CHECK_PICTURES: u8 = 59; + pub const OK: u8 = 100; + pub const ERR: u8 = 101; +} + +/// What a document allows, as Ely's bits. +pub mod may { + pub const PRINT: u32 = 1; + pub const COPY: u32 = 2; + pub const EDIT: u32 = 4; + pub const ANNOTATE: u32 = 8; + pub const FILL: u32 = 16; + pub const ASSEMBLE: u32 = 32; + /// Opened with the owner password, or not protected. + pub const OWNER: u32 = 64; + pub const NAMES: [(u32, &str); 7] = [ + (PRINT, "print"), + (COPY, "copy"), + (EDIT, "edit"), + (ANNOTATE, "annotate"), + (FILL, "fill"), + (ASSEMBLE, "assemble"), + (OWNER, "owner"), + ]; + /// A refusal's error text starts with this. + pub const REFUSED: &str = "restricted:"; +} + +/// The stamp of the sources this was built from. +pub const BUILD: &str = env!("ELY_PDF_BUILD"); + +/// One character on a page, in reading order. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Char { + /// Which text line it is on, counted over the page. + pub line: i32, + pub c: char, + /// x0, y0, x1, y1 in page points. + pub rect: [f32; 4], +} + +/// Bytes a character takes in a TEXT reply. +pub const CHAR_BYTES: usize = 24; + +/// Parses a TEXT reply: line, code point, four box floats. +pub fn chars(bytes: &[u8]) -> io::Result> { + if !bytes.len().is_multiple_of(CHAR_BYTES) { + return Err(io::Error::other("a text reply of odd length")); + } + let word = + |at: &[u8], n: usize| <[u8; 4]>::try_from(&at[n * 4..n * 4 + 4]).expect("four bytes"); + bytes + .chunks_exact(CHAR_BYTES) + .map(|one| { + let code = u32::from_ne_bytes(word(one, 1)); + Ok(Char { + line: i32::from_ne_bytes(word(one, 0)), + c: char::from_u32(code) + .ok_or_else(|| io::Error::other(format!("no character {code}")))?, + rect: [2, 3, 4, 5].map(|n| f32::from_ne_bytes(word(one, n))), + }) + }) + .collect() +} + +/// Parses a SEARCH reply: per hit, count then quads. +pub fn hits(bytes: &[u8]) -> io::Result>> { + let short = || io::Error::other("a short search reply"); + let (mut hits, mut at) = (Vec::new(), 0); + let word = |at: &mut usize| -> io::Result<[u8; 4]> { + let four = bytes.get(*at..*at + 4).ok_or_else(short)?; + *at += 4; + Ok(four.try_into().expect("four bytes")) + }; + while at < bytes.len() { + let count = usize::try_from(i32::from_ne_bytes(word(&mut at)?)).map_err(|_| short())?; + let mut quads = Vec::with_capacity(count); + for _ in 0..count { + let mut quad = [0f32; 8]; + for value in &mut quad { + *value = f32::from_ne_bytes(word(&mut at)?); + } + quads.push(quad); + } + hits.push(quads); + } + Ok(hits) +} + +/// One bookmark and those under it. +#[derive(Clone, Debug, PartialEq)] +pub struct Bookmark { + pub title: String, + /// Its page from zero; none for outside links. + pub page: Option, + /// Where on that page, in points from the top. + pub y: f32, + pub down: Vec, +} + +fn bookmark(value: &Value) -> io::Result { + let bad = || io::Error::other(format!("a bad bookmark {value}")); + let page = value["page"].as_i64().ok_or_else(bad)?; + Ok(Bookmark { + title: value["title"].as_str().ok_or_else(bad)?.to_string(), + page: usize::try_from(page).ok(), + y: value["y"].as_f64().ok_or_else(bad)? as f32, + down: bookmarks(&value["down"])?, + }) +} + +fn bookmarks(value: &Value) -> io::Result> { + let list = value + .as_array() + .ok_or_else(|| io::Error::other(format!("no bookmark list in {value}")))?; + list.iter().map(bookmark).collect() +} + +/// Parses an OUTLINE reply. +pub fn outline(json: &str) -> io::Result> { + bookmarks(&serde_json::from_str(json).map_err(io::Error::other)?) +} + +/// A request for whatever state the document is in. +pub const ANY: u64 = u64::MAX; +/// How a refusal for a changed document begins. +pub const STALE: &str = "stale: "; diff --git a/crates/ely-pdf-engine/src/redact.rs b/crates/ely-pdf-engine/src/redact.rs new file mode 100644 index 0000000..925af94 --- /dev/null +++ b/crates/ely-pdf-engine/src/redact.rs @@ -0,0 +1,105 @@ +//! Removal and cleaning into new files (PDF-09, PDF-10). + +use std::{io, path::Path}; + +use serde_json::{Value, json}; + +use crate::client::{Doc, Worker}; +use crate::proto::tag; + +/// A region to remove, in page points. +#[derive(Clone, Debug, PartialEq)] +pub struct Region { + pub page: usize, + pub rect: [f32; 4], +} + +/// The written file's check: zeros, and one version. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Redacted { + pub regions: usize, + /// Characters still inside a region. + pub text: usize, + /// Pixels inside a region that are not black. + pub light: usize, + /// Drawings still inside a region. + pub objects: usize, + pub versions: usize, +} + +/// What cleaning reaches, or what is left after. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Reach { + /// Info entries, plus one for an XMP stream. + pub meta: usize, + pub annots: usize, + pub files: usize, + /// Pages with hidden text or layers. + pub hidden: usize, + pub scripts: usize, + pub hidden_annots: usize, +} + +/// What to clean. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Cleaning { + pub meta: bool, + pub annots: bool, + pub files: bool, + /// Invisible text, scripts and hidden annotations. + pub hidden: bool, +} + +impl Cleaning { + fn mask(self) -> u64 { + u64::from(self.meta) | u64::from(self.annots) << 1 | u64::from(self.files) << 2 | u64::from(self.hidden) << 3 + } +} + +fn count(value: &Value, key: &str) -> io::Result { + let n = value[key].as_u64().ok_or_else(|| io::Error::other(format!("a bad {key} in {value}")))?; + usize::try_from(n).map_err(io::Error::other) +} + +fn reach(bytes: &[u8]) -> io::Result { + let value: Value = serde_json::from_slice(bytes).map_err(io::Error::other)?; + Ok(Reach { + meta: count(&value, "meta")?, + annots: count(&value, "annots")?, + files: count(&value, "files")?, + hidden: count(&value, "hidden")?, + scripts: count(&value, "scripts")?, + hidden_annots: count(&value, "hidden_annots")?, + }) +} + +fn utf8(path: &Path) -> io::Result<&str> { + path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8")) +} + +impl Worker { + /// Writes `out` without `regions`; an unclean result is an error. + pub fn redact(&mut self, doc: Doc, regions: &[Region], out: &Path) -> io::Result { + let spec: Vec = regions.iter().map(|r| json!({ "page": r.page, "rect": r.rect })).collect(); + let ask = self.on(&doc)?.str(&Value::Array(spec).to_string()).str(utf8(out)?); + let value: Value = serde_json::from_slice(&self.request(tag::REDACT, &ask.bytes())?).map_err(io::Error::other)?; + Ok(Redacted { + regions: count(&value, "regions")?, + text: count(&value, "text")?, + light: count(&value, "light")?, + objects: count(&value, "objects")?, + versions: count(&value, "versions")?, + }) + } + + /// Counts what cleaning reaches; `scratch` holds a passing copy. + pub fn clean_scan(&mut self, doc: Doc, scratch: &Path) -> io::Result { + reach(&self.request(tag::CLEAN_SCAN, &self.whole(&doc)?.str(utf8(scratch)?).bytes())?) + } + + /// Writes `out` cleaned; what is left in it. + pub fn clean(&mut self, doc: Doc, what: Cleaning, out: &Path) -> io::Result { + let ask = self.whole(&doc)?.u64(what.mask()).str(utf8(out)?); + reach(&self.request(tag::CLEAN, &ask.bytes())?) + } +} diff --git a/crates/ely-pdf-engine/src/sample.rs b/crates/ely-pdf-engine/src/sample.rs new file mode 100644 index 0000000..3734bd2 --- /dev/null +++ b/crates/ely-pdf-engine/src/sample.rs @@ -0,0 +1,263 @@ +//! Small PDFs written by hand, for tests. + +/// A bookmark: title, page, and those under it. +pub type Mark<'a> = (&'a str, usize, &'a [(&'a str, usize)]); + +/// Links, form fields and page labels to add. +#[derive(Default)] +pub struct Extras<'a> { + /// Link annotations: from page, to page. + pub links: &'a [(usize, usize)], + /// A text field on each of these pages. + pub fields: &'a [usize], + /// Page labels: first page, style name. + pub labels: &'a [(usize, &'a str)], + /// Fields leave out their optional /P. + pub loose: bool, + /// Every page's /UserUnit, if not one. + pub unit: Option, + /// Field names start with this, else "field". + pub prefix: Option<&'a str>, + /// The form's look: red 24 pt; font key, DA token. + pub look: Option<(&'a str, &'a str)>, + /// The form's default alignment, /Q. + pub align: Option, + /// Page one: a form of this text at 300, 400. + pub stamp: Option<&'a str>, + /// Page one: a 2x2 image, 100x50 at 72, 500. + pub picture: bool, + /// Fields hang under one parent of this name. + pub parent: Option<&'a str>, + /// A parent and child field with no widget. + pub bare: Option<(&'a str, &'a str)>, + /// Page one: a file annotation, name and bytes, no /Size. + pub attached: Option<(&'a str, &'a [u8])>, +} + +/// A PDF of `pages` (width, height, lines), with bookmarks. +pub fn pdf(pages: &[(f32, f32, &[&str])], marks: &[Mark]) -> Vec { + pdf_with(pages, marks, &Extras::default()) +} + +/// A PDF as `pdf` makes, with `extras`. +pub fn pdf_with(pages: &[(f32, f32, &[&str])], marks: &[Mark], extras: &Extras) -> Vec { + let mut objs: Vec> = Vec::new(); + let page_ids: Vec = (0..pages.len()).map(|n| 4 + n * 2).collect(); + // Links, then fields, after the outline objects. + let outline_objs: usize = marks.iter().map(|(_, _, down)| 1 + down.len()).sum(); + let first_extra = 4 + pages.len() * 2 + 1 + outline_objs; + let field_ids: Vec = (0..extras.fields.len()).map(|n| first_extra + extras.links.len() + n).collect(); + let mut catalog = String::from("<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R"); + // The parent field comes last of all. + let parent_id = first_extra + extras.links.len() + extras.fields.len() + usize::from(extras.stamp.is_some()) + usize::from(extras.picture); + let bare_id = parent_id + usize::from(extras.parent.is_some()); + let attach_id = bare_id + 2 * usize::from(extras.bare.is_some()); + if !field_ids.is_empty() || extras.bare.is_some() { + let mut refs: Vec = match extras.parent { + Some(_) => vec![format!("{parent_id} 0 R")], + None => field_ids.iter().map(|id| format!("{id} 0 R")).collect(), + }; + refs.extend(extras.bare.map(|_| format!("{bare_id} 0 R"))); + let font = 4 + pages.len() * 2; + let look = extras.look.map_or_else(String::new, |(key, token)| format!(" /DA (/{token} 24 Tf 1 0 0 rg) /DR << /Font << /{key} {font} 0 R >> >>")); + let align = extras.align.map_or_else(String::new, |q| format!(" /Q {q}")); + catalog.push_str(&format!(" /AcroForm << /Fields [{}]{look}{align} >>", refs.join(" "))); + } + if !extras.labels.is_empty() { + let nums: Vec = extras.labels.iter().map(|(at, style)| format!("{at} << /S /{style} >>")).collect(); + catalog.push_str(&format!(" /PageLabels << /Nums [{}] >>", nums.join(" "))); + } + objs.push(format!("{catalog} >>").into_bytes()); + let kids: Vec = page_ids.iter().map(|id| format!("{id} 0 R")).collect(); + objs.push( + format!( + "<< /Type /Pages /Kids [{}] /Count {} >>", + kids.join(" "), + pages.len() + ) + .into_bytes(), + ); + // Outline objects follow the pages. + let mut id = 4 + pages.len() * 2 + 1; + let tops: Vec = (marks.iter()) + .map(|(_, _, down)| { + let top = id; + id += 1 + down.len(); + top + }) + .collect(); + objs.push(match (tops.first(), tops.last()) { + (Some(first), Some(last)) => format!( + "<< /Type /Outlines /First {first} 0 R /Last {last} 0 R /Count {} >>", + tops.len() + ) + .into_bytes(), + _ => b"<< /Type /Outlines /Count 0 >>".to_vec(), + }); + for (n, (w, h, lines)) in pages.iter().enumerate() { + let mut stream = String::from("BT /F1 18 Tf 72 0 Td\n"); + for (at, line) in lines.iter().enumerate() { + let y = h - 72.0 - at as f32 * 24.0; + stream.push_str(&format!("1 0 0 1 72 {y} Tm ({line}) Tj\n")); + } + stream.push_str("ET"); + let font = 4 + pages.len() * 2; + let (drawn, xobjects) = match n { + 0 => xobjects(extras, first_extra + extras.links.len() + extras.fields.len()), + _ => (String::new(), String::new()), + }; + stream.push_str(&drawn); + let links = (extras.links.iter().enumerate()).filter(|(_, (from, _))| *from == n).map(|(at, _)| first_extra + at); + let fields = (extras.fields.iter().enumerate()).filter(|(_, page)| **page == n).map(|(at, _)| field_ids[at]); + let attached = extras.attached.filter(|_| n == 0).map(|_| attach_id); + let annots: Vec = links.chain(fields).chain(attached).map(|id| format!("{id} 0 R")).collect(); + let unit = extras.unit.map_or_else(String::new, |unit| format!(" /UserUnit {unit}")); + objs.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}]{unit} /Contents {} 0 R /Resources << /Font << /F1 {font} 0 R >>{xobjects} >> /Annots [{}] >>", page_ids[n] + 1, annots.join(" ")).into_bytes()); + objs.push( + format!( + "<< /Length {} >>\nstream\n{stream}\nendstream", + stream.len() + ) + .into_bytes(), + ); + } + objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec()); + // Top marks link to each other; children hang under them. + for (at, (title, page, down)) in marks.iter().enumerate() { + let top = tops[at]; + let mut dict = format!( + "<< /Title ({title}) /Parent 3 0 R /Dest [{} 0 R /XYZ 0 700 0]", + page_ids[*page] + ); + if at > 0 { + dict.push_str(&format!(" /Prev {} 0 R", tops[at - 1])); + } + if at + 1 < tops.len() { + dict.push_str(&format!(" /Next {} 0 R", tops[at + 1])); + } + if !down.is_empty() { + dict.push_str(&format!( + " /First {} 0 R /Last {} 0 R /Count {}", + top + 1, + top + down.len(), + down.len() + )); + } + objs.push(format!("{dict} >>").into_bytes()); + for (n, (title, page)) in down.iter().enumerate() { + let mut dict = format!( + "<< /Title ({title}) /Parent {top} 0 R /Dest [{} 0 R /XYZ 0 500 0]", + page_ids[*page] + ); + if n > 0 { + dict.push_str(&format!(" /Prev {} 0 R", top + n)); + } + if n + 1 < down.len() { + dict.push_str(&format!(" /Next {} 0 R", top + n + 2)); + } + objs.push(format!("{dict} >>").into_bytes()); + } + } + for (_, to) in extras.links { + objs.push(format!("<< /Type /Annot /Subtype /Link /Rect [72 72 200 100] /Border [0 0 0] /Dest [{} 0 R /Fit] >>", page_ids[*to]).into_bytes()); + } + for (n, page) in extras.fields.iter().enumerate() { + let owner = if extras.loose { String::new() } else { format!(" /P {} 0 R", page_ids[*page]) }; + let name = extras.prefix.unwrap_or("field"); + let parent = extras.parent.map_or_else(String::new, |_| format!(" /Parent {parent_id} 0 R")); + objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}{n}) /V (value{n}) /Rect [72 120 300 140]{owner}{parent} >>").into_bytes()); + } + if let Some(text) = extras.stamp { + let font = 4 + pages.len() * 2; + let content = format!("BT /F1 12 Tf 0 10 Td ({text}) Tj ET"); + objs.push(format!("<< /Type /XObject /Subtype /Form /BBox [0 0 200 40] /Resources << /Font << /F1 {font} 0 R >> >> /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes()); + } + if extras.picture { + let mut image = b"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 12 >>\nstream\n".to_vec(); + image.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]); + image.extend_from_slice(b"\nendstream"); + objs.push(image); + } + if let Some(name) = extras.parent { + let kids: Vec = field_ids.iter().map(|id| format!("{id} 0 R")).collect(); + objs.push(format!("<< /T ({name}) /Kids [{}] >>", kids.join(" ")).into_bytes()); + } + if let Some((parent, child)) = extras.bare { + objs.push(format!("<< /T ({parent}) /Kids [{} 0 R] >>", bare_id + 1).into_bytes()); + objs.push(format!("<< /T ({child}) /FT /Tx /V (Alice) /Parent {bare_id} 0 R >>").into_bytes()); + } + if let Some((name, bytes)) = extras.attached { + objs.push(format!("<< /Type /Annot /Subtype /FileAttachment /Rect [72 72 90 90] /FS {} 0 R >>", attach_id + 1).into_bytes()); + objs.push(format!("<< /Type /Filespec /F ({name}) /UF ({name}) /EF << /F {} 0 R >> >>", attach_id + 2).into_bytes()); + let mut file = format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", bytes.len()).into_bytes(); + file.extend_from_slice(bytes); + file.extend_from_slice(b"\nendstream"); + objs.push(file); + } + file(&objs) +} + +/// Page one's form and image draws, and their resources. +fn xobjects(extras: &Extras, first: usize) -> (String, String) { + let (mut drawn, mut names, mut id) = (String::new(), String::new(), first); + if extras.stamp.is_some() { + drawn.push_str("\nq 1 0 0 1 300 400 cm /Fm1 Do Q"); + names.push_str(&format!(" /Fm1 {id} 0 R")); + id += 1; + } + if extras.picture { + drawn.push_str("\nq 100 0 0 50 72 500 cm /Im1 Do Q"); + names.push_str(&format!(" /Im1 {id} 0 R")); + } + match names.is_empty() { + true => (drawn, names), + false => (drawn, format!(" /XObject <<{names} >>")), + } +} + +/// One page of `size` points: a gray picture only. +pub fn scan((w, h): (f32, f32), (pw, ph): (u32, u32), gray: &[u8]) -> Vec { + let content = format!("q {w} 0 0 {h} 0 0 cm /Im1 Do Q"); + let mut image = format!("<< /Type /XObject /Subtype /Image /Width {pw} /Height {ph} /ColorSpace /DeviceGray /BitsPerComponent 8 /Length {} >>\nstream\n", gray.len()).into_bytes(); + image.extend_from_slice(gray); + image.extend_from_slice(b"\nendstream"); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>").into_bytes(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(), + image, + ]; + file(&objs) +} + +/// Numbered objects as a whole PDF file. +fn file(objs: &[Vec]) -> Vec { + let mut body = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (n, obj) in objs.iter().enumerate() { + offsets.push(body.len()); + body.extend_from_slice(format!("{} 0 obj\n", n + 1).as_bytes()); + body.extend_from_slice(obj); + body.extend_from_slice(b"\nendobj\n"); + } + let xref = body.len(); + body.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for offset in offsets { + body.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + body.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + body +} + +/// One Letter page drawing `content`; `objects` are numbered from 5. +pub fn one_page(catalog: &str, page: &str, content: &str, objects: &[String]) -> Vec { + let mut objs: Vec> = vec![ + format!("<< /Type /Catalog /Pages 2 0 R{catalog} >>").into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R{page} >>").into_bytes(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(), + ]; + objs.extend(objects.iter().map(|obj| obj.clone().into_bytes())); + file(&objs) +} diff --git a/crates/ely-pdf-engine/src/secure.rs b/crates/ely-pdf-engine/src/secure.rs new file mode 100644 index 0000000..17e344c --- /dev/null +++ b/crates/ely-pdf-engine/src/secure.rs @@ -0,0 +1,40 @@ +//! Permissions, owner access and protected copies (PDF-11). + +use std::{io, path::Path}; + +use ely_ipc::Unpack; + +use crate::client::{Doc, Worker}; +use crate::proto::tag; + +/// A protected copy's passwords and what it allows. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Lock { + /// Empty opens without asking. + pub user: String, + pub owner: String, + /// `proto::may` bits. + pub allow: u32, +} + +impl Worker { + /// `proto::may` bits for `doc` now. + pub fn permissions(&mut self, doc: Doc) -> io::Result { + let reply = self.request(tag::PERMISSIONS, &self.whole(&doc)?.bytes())?; + Ok(Unpack::new(&reply).u64()? as u32) + } + + /// Tries an owner password; false keeps the old access. + pub fn owner(&mut self, doc: Doc, password: &str) -> io::Result { + let reply = self.request(tag::OWNER, &self.whole(&doc)?.str(password).bytes())?; + Ok(Unpack::new(&reply).u64()? != 0) + } + + /// Writes `out` with `lock`, or with no protection. + pub fn protect(&mut self, doc: Doc, out: &Path, lock: Option<&Lock>) -> io::Result<()> { + let out = out.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))?; + let (user, owner, allow) = lock.map_or(("", "", 0), |lock| (lock.user.as_str(), lock.owner.as_str(), lock.allow)); + let ask = self.whole(&doc)?.str(out).u64(u64::from(lock.is_some())).str(user).str(owner).u64(u64::from(allow)); + self.request(tag::PROTECT, &ask.bytes()).map(|_| ()) + } +} diff --git a/crates/ely-pdf-engine/src/sign.rs b/crates/ely-pdf-engine/src/sign.rs new file mode 100644 index 0000000..79452be --- /dev/null +++ b/crates/ely-pdf-engine/src/sign.rs @@ -0,0 +1,148 @@ +//! Signatures: listed with their checks, and new ones (PDF-12). + +use std::{io, path::Path}; + +use serde_json::Value; + +use crate::client::{Doc, Worker}; +use crate::proto::tag; + +/// Whether the signed bytes are as signed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Digest { + Intact, + Changed, + /// No certificate, or a signature that will not read. + #[default] + Unreadable, +} + +/// One signature field, signed or empty. +#[derive(Clone, Debug, PartialEq)] +pub struct Signature { + /// None for a field drawn on no page. + pub page: Option, + pub object: u32, + pub rect: [f32; 4], + pub name: String, + pub signed: Option, +} + +/// What a signed field says and how it checks. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Signed { + pub digest: Digest, + /// Updates were saved after it. + pub later: bool, + /// Those updates go past what it allows. + pub broken: bool, + pub signer: String, + pub common: String, + /// The PDF date the signer claims. + pub date: String, + pub reason: String, + pub location: String, + /// DER, the signer's first. + pub certs: Vec>, + /// Why it could not be read. + pub error: Option, +} + +/// A new signature: a field, a certificate, words. +#[derive(Clone, Debug, PartialEq)] +pub struct Signing { + pub page: usize, + /// An empty field's object, or a new field at `rect`. + pub object: Option, + pub rect: [f32; 4], + pub name: String, + /// A PKCS #12 file and its password. + pub pfx: String, + pub pfx_password: String, + pub reason: String, + pub location: String, +} + +fn bad(what: &str, value: &Value) -> io::Error { + io::Error::other(format!("a bad {what} in {value}")) +} + +fn text(value: &Value, key: &str) -> io::Result { + value[key].as_str().map(str::to_string).ok_or_else(|| bad(key, value)) +} + +fn hex(value: &Value) -> io::Result> { + let digits = value.as_str().filter(|s| s.len() % 2 == 0).ok_or_else(|| bad("certificate", value))?; + (0..digits.len()).step_by(2).map(|at| u8::from_str_radix(&digits[at..at + 2], 16).map_err(io::Error::other)).collect() +} + +fn signed(value: &Value) -> io::Result { + let digest = match value["digest"].as_str() { + Some("ok") => Digest::Intact, + Some("changed") => Digest::Changed, + Some("no_certificate" | "unreadable") => Digest::Unreadable, + _ => return Err(bad("digest", value)), + }; + if let Some(error) = value["error"].as_str() { + return Ok(Signed { digest, error: Some(error.into()), ..Signed::default() }); + } + let certs = value["certs"].as_array().ok_or_else(|| bad("certs", value))?.iter().map(hex).collect::>()?; + Ok(Signed { + digest, + later: value["later"].as_i64().ok_or_else(|| bad("later", value))? != 0, + broken: value["broken"].as_i64().ok_or_else(|| bad("broken", value))? != 0, + signer: text(value, "signer")?, + common: text(value, "common")?, + date: text(value, "date")?, + reason: text(value, "reason")?, + location: text(value, "location")?, + certs, + error: None, + }) +} + +fn signature(value: &Value) -> io::Result { + let rect = value["rect"].as_array().filter(|r| r.len() == 4).ok_or_else(|| bad("rect", value))?; + let mut out = [0f32; 4]; + for (at, side) in rect.iter().enumerate() { + out[at] = side.as_f64().ok_or_else(|| bad("rect", value))? as f32; + } + Ok(Signature { + page: usize::try_from(value["page"].as_i64().ok_or_else(|| bad("page", value))?).ok(), + object: value["object"].as_u64().and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad("object", value))?, + rect: out, + name: text(value, "name")?, + signed: match value["signed"].as_bool().ok_or_else(|| bad("signed", value))? { + true => Some(signed(value)?), + false => None, + }, + }) +} + +impl Worker { + /// Every signature field in the file at `path`, checked. + pub fn signatures(&mut self, doc: Doc, path: &Path) -> io::Result> { + let path = path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))?; + let reply = self.request(tag::SIGNATURES, &self.whole(&doc)?.str(path).bytes())?; + let value: Value = serde_json::from_slice(&reply).map_err(io::Error::other)?; + value.as_array().ok_or_else(|| bad("list", &value))?.iter().map(signature).collect() + } + + /// Fields holding a signature, counted without checking. + pub fn signed(&mut self, doc: Doc) -> io::Result { + let reply = self.request(tag::SIGNED, &self.whole(&doc)?.bytes())?; + Ok(ely_ipc::Unpack::new(&reply).u64()? as usize) + } + + /// Writes `out` signed by an update; `doc` is untouched. + pub fn sign(&mut self, doc: Doc, out: &Path, how: &Signing) -> io::Result<()> { + let out = out.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))?; + let object = how.object.map_or(-1, i64::from); + let mut ask = self.on(&doc)?.str(out).u64(how.page as u64).i64(object); + for side in how.rect { + ask = ask.u64(u64::from(side.to_bits())); + } + let ask = ask.str(&how.name).str(&how.pfx).str(&how.pfx_password).str(&how.reason).str(&how.location); + self.request(tag::SIGN, &ask.bytes()).map(|_| ()) + } +} diff --git a/crates/ely-pdf-engine/src/structure.rs b/crates/ely-pdf-engine/src/structure.rs new file mode 100644 index 0000000..ad647f7 --- /dev/null +++ b/crates/ely-pdf-engine/src/structure.rs @@ -0,0 +1,211 @@ +//! Bookmarks, links, properties, attachments (PDF-07). + +use std::{io, path::Path}; + +use ely_ipc::Unpack; +use serde_json::{Value, json}; + +use crate::client::{Doc, Worker}; +use crate::proto::tag; + +/// Where a bookmark or link goes. +#[derive(Clone, Debug, PartialEq)] +pub enum Target { + /// A page and a point down it. + Page(usize, f32), + /// An http, https or mailto address. + Web(String), +} + +/// A bookmark change; paths count children from the top. +#[derive(Clone, Debug, PartialEq)] +pub enum BookmarkChange { + /// Before the bookmark at `path`, or last past the end. + Add { path: Vec, title: String, page: usize, y: f32 }, + /// `expect` is its title as read; another refuses. + Edit { path: Vec, expect: String, title: Option, to: Option<(usize, f32)> }, + Delete { path: Vec, expect: String }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Link { + pub rect: [f32; 4], + pub uri: String, + /// The page inside the document, if any. + pub page: Option, + pub y: f32, +} + +/// A link change; `expect` is the address as read. +#[derive(Clone, Debug, PartialEq)] +pub enum LinkChange { + Add { rect: [f32; 4], to: Target }, + Edit { index: usize, expect: String, to: Target }, + Delete { index: usize, expect: String }, +} + +/// The document's properties; none when the file has none. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Properties { + pub title: Option, + pub author: Option, + pub subject: Option, + pub keywords: Option, + pub creator: Option, + pub producer: Option, + pub created: Option, + pub modified: Option, +} + +/// An embedded file; dates in seconds, if known. +#[derive(Clone, Debug, PartialEq)] +pub struct Attachment { + /// Names it for reading and removing. + pub key: String, + pub name: String, + /// The page whose annotation holds it, if any. + pub page: Option, + pub size: u64, + pub created: Option, + pub modified: Option, + pub mime: String, + pub note: String, +} + +fn bad(what: &str, value: &Value) -> io::Error { + io::Error::other(format!("a bad {what} {value}")) +} + +fn target(spec: &mut Value, to: &Target) { + match to { + Target::Page(page, y) => { + spec["page"] = json!(page); + spec["y"] = json!(y); + } + Target::Web(uri) => spec["uri"] = json!(uri), + } +} + +fn parsed(bytes: &[u8]) -> io::Result { + serde_json::from_slice(bytes).map_err(io::Error::other) +} + +impl Worker { + pub fn change_bookmark(&mut self, doc: Doc, change: &BookmarkChange) -> io::Result { + let spec = match change { + BookmarkChange::Add { path, title, page, y } => json!({ "op": "add", "path": path, "title": title, "page": page, "y": y }), + BookmarkChange::Edit { path, expect, title, to } => { + let mut spec = json!({ "op": "edit", "path": path, "expect": expect }); + if let Some(title) = title { + spec["title"] = json!(title); + } + if let Some((page, y)) = to { + target(&mut spec, &Target::Page(*page, *y)); + } + spec + } + BookmarkChange::Delete { path, expect } => json!({ "op": "delete", "path": path, "expect": expect }), + }; + let ask = self.on(&doc)?.str(&spec.to_string()); + Unpack::new(&self.request(tag::OUTLINE_EDIT, &ask.bytes())?).str() + } + + pub fn links(&mut self, doc: Doc, page: usize) -> io::Result> { + let reply = parsed(&self.request(tag::LINKS, &self.on(&doc)?.u64(page as u64).bytes())?)?; + let list = reply.as_array().ok_or_else(|| bad("link list", &reply))?; + list.iter() + .map(|value| { + let rect = value["rect"].as_array().filter(|r| r.len() == 4).ok_or_else(|| bad("link", value))?; + let mut out = [0f32; 4]; + for (at, side) in rect.iter().enumerate() { + out[at] = side.as_f64().ok_or_else(|| bad("link", value))? as f32; + } + Ok(Link { + rect: out, + uri: value["uri"].as_str().ok_or_else(|| bad("link", value))?.to_string(), + page: usize::try_from(value["page"].as_i64().ok_or_else(|| bad("link", value))?).ok(), + y: value["y"].as_f64().ok_or_else(|| bad("link", value))? as f32, + }) + }) + .collect() + } + + pub fn change_link(&mut self, doc: Doc, page: usize, change: &LinkChange) -> io::Result { + let spec = match change { + LinkChange::Add { rect, to } => { + let mut spec = json!({ "op": "add", "rect": rect }); + target(&mut spec, to); + spec + } + LinkChange::Edit { index, expect, to } => { + let mut spec = json!({ "op": "edit", "index": index, "expect": expect }); + target(&mut spec, to); + spec + } + LinkChange::Delete { index, expect } => json!({ "op": "delete", "index": index, "expect": expect }), + }; + let ask = self.on(&doc)?.u64(page as u64).str(&spec.to_string()); + Unpack::new(&self.request(tag::LINK_EDIT, &ask.bytes())?).str() + } + + pub fn properties(&mut self, doc: Doc) -> io::Result { + let reply = parsed(&self.request(tag::METADATA, &self.whole(&doc)?.bytes())?)?; + let field = |key: &str| match &reply[key] { + Value::Null => Ok(None), + Value::String(text) => Ok(Some(text.clone())), + other => Err(bad(key, other)), + }; + Ok(Properties { + title: field("Title")?, + author: field("Author")?, + subject: field("Subject")?, + keywords: field("Keywords")?, + creator: field("Creator")?, + producer: field("Producer")?, + created: field("CreationDate")?, + modified: field("ModDate")?, + }) + } + + /// Sets Title, Author, Subject or Keywords, one step. + pub fn set_properties(&mut self, doc: Doc, changes: &[(&str, &str)]) -> io::Result { + let spec: serde_json::Map = changes.iter().map(|(key, value)| (key.to_string(), json!(value))).collect(); + let ask = self.whole(&doc)?.str(&Value::Object(spec).to_string()); + Unpack::new(&self.request(tag::SET_METADATA, &ask.bytes())?).str() + } + + pub fn attachments(&mut self, doc: Doc) -> io::Result> { + let reply = parsed(&self.request(tag::ATTACHMENTS, &self.whole(&doc)?.bytes())?)?; + let list = reply.as_array().ok_or_else(|| bad("attachment list", &reply))?; + list.iter() + .map(|value| { + let text = |key: &str| value[key].as_str().map(str::to_string).ok_or_else(|| bad(key, value)); + let date = |key: &str| value[key].as_i64().map(|at| (at >= 0).then_some(at)).ok_or_else(|| bad(key, value)); + Ok(Attachment { + key: text("key")?, + name: text("name")?, + page: usize::try_from(value["page"].as_i64().ok_or_else(|| bad("page", value))?).ok(), + size: value["size"].as_u64().ok_or_else(|| bad("size", value))?, + created: date("created")?, + modified: date("modified")?, + mime: text("type")?, + note: text("note")?, + }) + }) + .collect() + } + + pub fn attachment(&mut self, doc: Doc, key: &str) -> io::Result> { + self.request(tag::ATTACHMENT, &self.on(&doc)?.str(key).bytes()) + } + + /// Embeds the file at `path` as `name`; the mark. + pub fn attach(&mut self, doc: Doc, name: &str, path: &Path) -> io::Result { + let path = path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))?; + Unpack::new(&self.request(tag::ATTACH, &self.whole(&doc)?.str(name).str(path).bytes())?).str() + } + + pub fn detach(&mut self, doc: Doc, key: &str) -> io::Result { + Unpack::new(&self.request(tag::DETACH, &self.on(&doc)?.str(key).bytes())?).str() + } +} diff --git a/crates/ely-pdf-engine/tests/content.rs b/crates/ely-pdf-engine/tests/content.rs new file mode 100644 index 0000000..94dd113 --- /dev/null +++ b/crates/ely-pdf-engine/tests/content.rs @@ -0,0 +1,277 @@ +//! Text and object edits against the real worker (PDF-03, PDF-04). + +use std::path::Path; + +use ely_pdf_engine::{ + Doc, Worker, Writing, Wrote, + sample::{Extras, pdf_with}, +}; + +fn worker() -> Worker { + Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker") +} + +/// A Letter page: two lines, a stamp, a picture. +fn opened(dir: &Path) -> (Worker, Doc) { + let path = dir.join("doc.pdf"); + let extras = Extras { stamp: Some("STAMP"), picture: true, ..Extras::default() }; + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes"); + let mut worker = worker(); + let doc = worker.open(&path).expect("opens").doc; + (worker, doc) +} + +fn page_text(worker: &mut Worker, doc: Doc) -> String { + worker.text(doc, 0).expect("reads").iter().map(|c| c.c).collect() +} + +/// Rectangles equal to a hundredth of a point. +fn near(got: [f32; 4], want: [f32; 4]) { + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{got:?} is not {want:?}"); +} + +/// A rewrite of block `block` as `text` in `font`. +fn rewrite(worker: &mut Worker, doc: Doc, block: usize, text: &str, font: &str) -> Writing { + let read = worker.text_blocks(doc, 0).expect("blocks")[block].clone(); + Writing { + block: Some(block), + expected: read.text, + text: text.into(), + font: font.into(), + size: read.size, + rgb: read.rgb, + bx: [read.rect[0], read.rect[1], read.rect[2] - read.rect[0]], + } +} + +#[test] +fn blocks_say_what_they_hold_and_whether_they_edit() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(dir.path()); + let blocks = worker.text_blocks(doc, 0).expect("blocks"); + let hello = blocks.iter().find(|b| b.text.starts_with("Hello")).expect("the page text"); + assert!(hello.editable && hello.size == 18.0, "{hello:?}"); + let stamp = blocks.iter().find(|b| b.text == "STAMP").expect("the stamp text"); + assert!(!stamp.editable, "text inside a form is not the page's own"); + let drawn = worker.objects(doc, 0).expect("objects"); + assert_eq!(drawn.iter().map(|d| d.form).collect::>(), [true, false]); + assert_eq!(drawn[1].rect, [72.0, 242.0, 172.0, 292.0], "the picture, in page space"); +} + +#[test] +fn edited_text_replaces_the_old_glyphs_in_one_step() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(dir.path()); + let before = worker.history(doc).expect("a mark"); + let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello"); + let writing = rewrite(&mut worker, doc, index, "Hi world\nSecond line", "helvetica"); + assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + let now = page_text(&mut worker, doc); + assert!(now.contains("Hi world") && !now.contains("Hello"), "{now}"); + worker.undo(doc, false).expect("undoes"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.history(doc).expect("a mark"), before); + assert!(page_text(&mut worker, doc).contains("Hello world")); +} + +#[test] +fn a_font_without_the_glyph_changes_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(dir.path()); + let before = worker.history(doc).expect("a mark"); + let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello"); + let writing = rewrite(&mut worker, doc, index, "你好", "helvetica"); + assert_eq!(worker.put_text(doc, 0, &writing, 0.0).expect("answers"), Wrote::Missing('你')); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.history(doc).expect("a mark"), before); + assert!(page_text(&mut worker, doc).contains("Hello world"), "the old text stays"); + let chinese = Writing { font: "cjk-sc".into(), ..writing }; + assert!(matches!(worker.put_text(doc, 0, &chinese, 0.0).expect("writes"), Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + assert!(page_text(&mut worker, doc).contains("你好")); +} + +#[test] +fn a_preview_draws_the_change_and_keeps_none_of_it() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(dir.path()); + let before = worker.history(doc).expect("a mark"); + let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello"); + let mut writing = rewrite(&mut worker, doc, index, "A much longer line that will not fit the old box at all", "times"); + writing.bx[2] = 150.0; + let Wrote::Preview(pixels, (w, h), grew) = worker.put_text(doc, 0, &writing, 2.0).expect("previews") else { + panic!("a preview"); + }; + doc = worker.now(doc).expect("the state"); + assert!(grew, "more lines than before"); + assert_eq!(pixels.len(), (w * h * 4) as usize); + assert!(pixels.chunks_exact(4).any(|p| p[0] < 128), "it draws text"); + assert_eq!(worker.history(doc).expect("a mark"), before); + assert!(page_text(&mut worker, doc).contains("Hello world")); +} + +#[test] +fn new_text_goes_where_asked() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(dir.path()); + let writing = Writing { + block: None, + expected: String::new(), + text: "Added".into(), + font: "courier".into(), + size: 12.0, + rgb: 0xff0000, + bx: [100.0, 600.0, 300.0], + }; + assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + let added = worker.text_blocks(doc, 0).expect("blocks").into_iter().find(|b| b.text == "Added").expect("added"); + assert!((added.rect[0] - 100.0).abs() < 1.0 && (added.rect[1] - 600.0).abs() < 3.0, "{added:?}"); + assert_eq!(added.rgb, 0xff0000); +} + +#[test] +fn objects_move_scale_swap_and_go() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(dir.path()); + worker.place(doc, 0, 1, [1.0, 0.0, 0.0, 1.0, 50.0, 20.0]).expect("moves"); + doc = worker.now(doc).expect("the state"); + near(worker.objects(doc, 0).expect("objects")[1].rect, [122.0, 262.0, 222.0, 312.0]); + // Twice the size about its top left. + worker.place(doc, 0, 1, [2.0, 0.0, 0.0, 2.0, -122.0, -262.0]).expect("scales"); + doc = worker.now(doc).expect("the state"); + near(worker.objects(doc, 0).expect("objects")[1].rect, [122.0, 262.0, 322.0, 362.0]); + let square = dir.path().join("square.pnm"); + std::fs::write(&square, b"P6\n2 2\n255\n\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff").expect("writes"); + worker.replace_image(doc, 0, 1, &square).expect("replaces"); + doc = worker.now(doc).expect("the state"); + let swapped = worker.objects(doc, 0).expect("objects")[1].rect; + near(swapped, [172.0, 262.0, 272.0, 362.0]); + worker.delete_object(doc, 0, 0).expect("deletes the stamp"); + doc = worker.now(doc).expect("the state"); + assert!(!page_text(&mut worker, doc).contains("STAMP")); + assert_eq!(worker.objects(doc, 0).expect("objects").len(), 1); + worker.add_image(doc, 0, &square, [400.0, 100.0, 450.0, 150.0]).expect("adds"); + doc = worker.now(doc).expect("the state"); + near(worker.objects(doc, 0).expect("objects").last().expect("added").rect, [400.0, 100.0, 450.0, 150.0]); + for _ in 0..5 { + worker.undo(doc, false).expect("undoes"); + doc = worker.now(doc).expect("the state"); + } + near(worker.objects(doc, 0).expect("objects")[1].rect, [72.0, 242.0, 172.0, 292.0]); + assert!(page_text(&mut worker, doc).contains("STAMP")); +} + +#[test] +fn the_documents_own_font_writes_and_edits_edit_again() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(dir.path()); + let hello = |worker: &mut Worker, doc: Doc, start: &str| { + worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with(start)).expect("the block") + }; + let index = hello(&mut worker, doc, "Hello"); + let first = rewrite(&mut worker, doc, index, "Hi world\nSecond line", "original"); + assert!(matches!(worker.put_text(doc, 0, &first, 0.0).expect("writes"), Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + let again = hello(&mut worker, doc, "Hi"); + assert!(worker.text_blocks(doc, 0).expect("blocks")[again].editable, "new text is the page's own"); + let second = rewrite(&mut worker, doc, again, "Yo world\nSecond line", "original"); + assert!(matches!(worker.put_text(doc, 0, &second, 0.0).expect("writes again"), Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + let now = page_text(&mut worker, doc); + assert!(now.contains("Yo world") && !now.contains("Hi world"), "{now}"); +} + +#[test] +fn a_font_added_in_a_preview_or_undone_is_added_again() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(dir.path()); + let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello"); + let writing = rewrite(&mut worker, doc, index, "你好", "cjk-sc"); + assert!(matches!(worker.put_text(doc, 0, &writing, 2.0).expect("previews"), Wrote::Preview(..))); + doc = worker.now(doc).expect("the state"); + assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + worker.undo(doc, false).expect("undoes"); + doc = worker.now(doc).expect("the state"); + assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes again"), Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + let out = dir.path().join("out.pdf"); + worker.save(doc, &out).expect("saves"); + let saved = worker.open(&out).expect("reopens").doc; + assert!(page_text(&mut worker, saved).contains("你好"), "the font is in the file"); +} + +#[test] +fn previews_and_refusals_keep_the_redo() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(dir.path()); + let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello"); + let first = rewrite(&mut worker, doc, index, "Hi world\nSecond line", "helvetica"); + assert!(matches!(worker.put_text(doc, 0, &first, 0.0).expect("writes"), Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + worker.undo(doc, false).expect("undoes"); + doc = worker.now(doc).expect("the state"); + let preview = rewrite(&mut worker, doc, index, "Yo world\nSecond line", "times"); + assert!(matches!(worker.put_text(doc, 0, &preview, 2.0).expect("previews"), Wrote::Preview(..))); + doc = worker.now(doc).expect("the state"); + let refused = Writing { text: "你好".into(), font: "helvetica".into(), ..preview }; + assert!(matches!(worker.put_text(doc, 0, &refused, 0.0).expect("answers"), Wrote::Missing(_))); + doc = worker.now(doc).expect("the state"); + worker.undo(doc, true).expect("the edit still redoes"); + doc = worker.now(doc).expect("the state"); + assert!(page_text(&mut worker, doc).contains("Hi world")); +} + +#[test] +fn bigger_glyphs_count_as_a_new_layout() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(dir.path()); + let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello"); + let same = rewrite(&mut worker, doc, index, "Hello world\nSecond line", "original"); + let Wrote::Preview(_, _, grew) = worker.put_text(doc, 0, &same, 2.0).expect("previews") else { panic!("a preview") }; + doc = worker.now(doc).expect("the state"); + assert!(!grew, "the same text in the same place"); + let big = Writing { text: "Hi".into(), size: 72.0, ..same }; + let Wrote::Preview(_, _, grew) = worker.put_text(doc, 0, &big, 2.0).expect("previews") else { panic!("a preview") }; + assert!(grew, "fewer lines, much bigger glyphs"); +} + +#[test] +fn runs_on_one_baseline_still_wrap_onto_new_lines() { + let dir = tempfile::tempdir().expect("a temp dir"); + let content = "BT /F1 18 Tf 1 0 0 1 72 720 Tm (Hello) Tj 1 0 0 1 160 720 Tm (world) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (n, obj) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", n + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let path = dir.path().join("runs.pdf"); + std::fs::write(&path, bytes).expect("writes"); + let mut worker = worker(); + let mut doc = worker.open(&path).expect("opens").doc; + let block = worker.text_blocks(doc, 0).expect("blocks")[0].clone(); + let mut writing = rewrite(&mut worker, doc, 0, "One two three four five", "helvetica"); + writing.expected = block.text; + writing.bx[2] = 50.0; + assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + let mut tops: Vec = worker.text(doc, 0).expect("reads").iter().map(|c| c.rect[1].round() as i32).collect(); + tops.dedup(); + assert!(tops.len() >= 3, "each wrapped line has its own baseline: {tops:?}"); +} diff --git a/crates/ely-pdf-engine/tests/edit.rs b/crates/ely-pdf-engine/tests/edit.rs new file mode 100644 index 0000000..ec2c1e3 --- /dev/null +++ b/crates/ely-pdf-engine/tests/edit.rs @@ -0,0 +1,304 @@ +//! Page edits, undo and saving against the real worker (PDF-02). + +use std::path::Path; + +use ely_pdf_engine::{ + Doc, Worker, + sample::{Extras, pdf, pdf_with}, +}; + +fn worker() -> Worker { + Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker") +} + +fn open(worker: &mut Worker, dir: &Path, name: &str, bytes: &[u8]) -> Doc { + let path = dir.join(name); + std::fs::write(&path, bytes).expect("writes"); + worker.open(&path).expect("opens").doc +} + +/// Each page's text. +fn texts(worker: &mut Worker, doc: Doc) -> Vec { + let count = worker.pages(doc).expect("counts"); + (0..count) + .map(|page| worker.text(doc, page).expect("reads").iter().map(|c| c.c).collect()) + .collect() +} + +/// Bookmark titles with their pages. +fn marks(worker: &mut Worker, doc: Doc) -> Vec<(String, Option)> { + worker.outline(doc).expect("the outline").into_iter().map(|m| (m.title, m.page)).collect() +} + +const LETTER: (f32, f32) = (612.0, 792.0); + +fn three() -> Vec { + let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"]), (612.0, 792.0, &["three"])]; + let extras = Extras { links: &[(0, 1), (2, 1), (2, 0)], fields: &[1, 2], labels: &[(0, "r"), (1, "D")], ..Extras::default() }; + pdf_with(pages, &[("One", 0, &[]), ("Two", 1, &[]), ("Three", 2, &[])], &extras) +} + +#[test] +fn a_delete_drops_what_pointed_there_and_undoes() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut worker = worker(); + let mut doc = open(&mut worker, dir.path(), "three.pdf", &three()); + let before = worker.history(doc).expect("a mark"); + let after = worker.delete_pages(doc, &[1]).expect("deletes"); + doc = worker.now(doc).expect("the state"); + assert_ne!(before, after); + assert_eq!(texts(&mut worker, doc), ["one", "three"]); + let pointing = marks(&mut worker, doc); + assert_eq!(pointing, [("One".into(), Some(0)), ("Two".into(), None), ("Three".into(), Some(1))]); + let out = dir.path().join("saved.pdf"); + worker.save(doc, &out).expect("saves"); + let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned(); + assert!(saved.contains("(three)") && !saved.contains("(two)"), "the deleted page is gone from the file"); + assert_eq!(saved.matches("/Link").count(), 1, "only the link to page one stays"); + assert!(saved.contains("field1") && !saved.contains("field0"), "the field on the deleted page goes"); + let labels = saved.split("/Nums").nth(1).expect("labels").replace(char::is_whitespace, ""); + assert!(labels.starts_with("[0<>1<>]"), "{labels}"); + assert!(!dir.path().join("saved.pdf.raw").exists(), "the scratch copy goes"); + assert_eq!(worker.undo(doc, false).expect("undoes"), before); + doc = worker.now(doc).expect("the state"); + assert_eq!(texts(&mut worker, doc), ["one", "two", "three"]); + assert_eq!(marks(&mut worker, doc)[1], ("Two".into(), Some(1))); + assert_eq!(worker.undo(doc, true).expect("redoes"), after); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.pages(doc).expect("counts"), 2); +} + +#[test] +fn moves_turns_and_crops_are_steps() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut worker = worker(); + let mut doc = open(&mut worker, dir.path(), "three.pdf", &three()); + worker.move_pages(doc, &[2], 0).expect("moves"); + doc = worker.now(doc).expect("the state"); + assert_eq!(texts(&mut worker, doc), ["three", "one", "two"]); + assert_eq!(marks(&mut worker, doc)[2], ("Three".into(), Some(0)), "bookmarks follow their page"); + worker.move_pages(doc, &[0, 1], 3).expect("moves to the end"); + doc = worker.now(doc).expect("the state"); + assert_eq!(texts(&mut worker, doc), ["two", "three", "one"]); + worker.rotate_pages(doc, &[1], 90).expect("turns"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.bounds(doc, 1, 1).expect("bounds"), [[0.0, 0.0, 792.0, 612.0]]); + // Turned a quarter, the text's left edge is on top. + let top = |worker: &mut Worker, doc: Doc| worker.text(doc, 1).expect("reads")[0].rect[1]; + let was = top(&mut worker, doc); + worker.crop_pages(doc, &[1], [50.0, 0.0, 0.0, 0.0]).expect("crops"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.bounds(doc, 1, 1).expect("bounds"), [[0.0, 0.0, 792.0, 562.0]]); + assert!((top(&mut worker, doc) - (was - 50.0)).abs() < 0.5, "the seen top was cut"); + worker.crop_pages(doc, &[1], [0.0; 4]).expect("uncrops"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.bounds(doc, 1, 1).expect("bounds"), [[0.0, 0.0, 792.0, 612.0]]); + for _ in 0..5 { + worker.undo(doc, false).expect("undoes"); + doc = worker.now(doc).expect("the state"); + } + assert_eq!(texts(&mut worker, doc), ["one", "two", "three"]); + assert_eq!(worker.bounds(doc, 0, 3).expect("bounds"), [[0.0, 0.0, LETTER.0, LETTER.1]; 3]); + assert!(worker.undo(doc, false).is_err(), "nothing left to undo"); +} + +#[test] +fn pages_come_in_blank_or_from_a_file() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut worker = worker(); + let mut doc = open(&mut worker, dir.path(), "three.pdf", &three()); + worker.insert_blank(doc, 1, (300.0, 400.0)).expect("inserts a blank page"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.bounds(doc, 1, 1).expect("bounds"), [[0.0, 0.0, 300.0, 400.0]]); + let other = dir.path().join("other.pdf"); + std::fs::write(&other, pdf(&[(612.0, 792.0, &["x"]), (612.0, 792.0, &["y"])], &[])).expect("writes"); + let (count, _) = worker.insert_file(doc, 4, &other).expect("inserts a file"); + doc = worker.now(doc).expect("the state"); + assert_eq!(count, 2); + assert_eq!(texts(&mut worker, doc), ["one", "", "two", "three", "x", "y"]); + worker.undo(doc, false).expect("undoes"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.pages(doc).expect("counts"), 4); +} + +#[test] +fn an_extract_writes_some_pages_and_leaves_the_document() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut worker = worker(); + let mut doc = open(&mut worker, dir.path(), "three.pdf", &three()); + worker.rotate_pages(doc, &[0], 90).expect("turns"); + doc = worker.now(doc).expect("the state"); + let mark = worker.history(doc).expect("a mark"); + let out = dir.path().join("part.pdf"); + worker.extract(doc, &[0, 2], &out).expect("extracts"); + assert_eq!(worker.history(doc).expect("a mark"), mark); + assert_eq!(texts(&mut worker, doc), ["one", "two", "three"]); + assert!(worker.undo(doc, true).is_err(), "the extract is not a redo"); + let part = worker.open(&out).expect("opens the part").doc; + assert_eq!(texts(&mut worker, part), ["one", "three"]); + assert_eq!(worker.bounds(part, 0, 1).expect("bounds"), [[0.0, 0.0, 792.0, 612.0]], "with its edits"); + worker.undo(doc, false).expect("the turn undoes"); +} + +#[test] +fn bad_edits_are_refused_and_change_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut worker = worker(); + let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["big"]), (100.0, 100.0, &["small"])]; + let doc = open(&mut worker, dir.path(), "two.pdf", &pdf(pages, &[])); + let mark = worker.history(doc).expect("a mark"); + assert!(worker.delete_pages(doc, &[0, 1]).is_err(), "a PDF keeps a page"); + assert!(worker.delete_pages(doc, &[1, 0]).is_err(), "pages in order"); + assert!(worker.rotate_pages(doc, &[0], 45).is_err(), "quarter turns only"); + // Fine for the big page, too much for the small. + assert!(worker.crop_pages(doc, &[0, 1], [200.0, 0.0, 0.0, 0.0]).is_err()); + assert_eq!(worker.bounds(doc, 0, 2).expect("bounds"), [[0.0, 0.0, 612.0, 792.0], [0.0, 0.0, 100.0, 100.0]], "rolled back"); + assert_eq!(worker.history(doc).expect("a mark"), mark); + assert!(worker.undo(doc, false).is_err(), "no step was made"); +} + +#[test] +fn an_extract_after_an_undo_keeps_the_redo() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut worker = worker(); + let mut doc = open(&mut worker, dir.path(), "three.pdf", &three()); + let turned = worker.rotate_pages(doc, &[0], 90).expect("turns"); + doc = worker.now(doc).expect("the state"); + worker.save(doc, &dir.path().join("saved.pdf")).expect("saves"); + worker.undo(doc, false).expect("undoes"); + doc = worker.now(doc).expect("the state"); + worker.extract(doc, &[0], &dir.path().join("part.pdf")).expect("extracts"); + assert_eq!(worker.undo(doc, true).expect("redoes"), turned); + doc = worker.now(doc).expect("the state"); + assert_eq!(texts(&mut worker, doc), ["one", "two", "three"]); + assert_eq!(worker.bounds(doc, 0, 1).expect("bounds"), [[0.0, 0.0, 792.0, 612.0]], "the turn is back"); +} + +#[test] +fn inserted_pages_keep_their_links_and_fields() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut worker = worker(); + let mut doc = open(&mut worker, dir.path(), "three.pdf", &three()); + let other = dir.path().join("other.pdf"); + let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["x"]), (612.0, 792.0, &["y"])]; + let extras = Extras { links: &[(0, 1)], fields: &[0], ..Extras::default() }; + std::fs::write(&other, pdf_with(pages, &[], &extras)).expect("writes"); + worker.insert_file(doc, 3, &other).expect("inserts"); + doc = worker.now(doc).expect("the state"); + let saved = |worker: &mut Worker, doc: Doc, name: &str| { + let out = dir.path().join(name); + worker.save(doc, &out).expect("saves"); + String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned() + }; + let merged = saved(&mut worker, doc, "merged.pdf"); + assert_eq!(merged.matches("/Link").count(), 4, "three links of its own, one inserted"); + assert!(merged.contains("(field0 2)") && merged.contains("(value0)"), "a clashing field is renamed, its value kept"); + // The inserted link points at y: deleting y drops it. + worker.delete_pages(doc, &[4]).expect("deletes y"); + doc = worker.now(doc).expect("the state"); + assert_eq!(saved(&mut worker, doc, "less.pdf").matches("/Link").count(), 3); +} + +#[test] +fn a_field_without_its_page_named_still_goes_with_the_page() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut worker = worker(); + let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"])]; + let extras = Extras { fields: &[1], loose: true, ..Extras::default() }; + let mut doc = open(&mut worker, dir.path(), "loose.pdf", &pdf_with(pages, &[], &extras)); + worker.delete_pages(doc, &[1]).expect("deletes"); + doc = worker.now(doc).expect("the state"); + let out = dir.path().join("out.pdf"); + worker.save(doc, &out).expect("saves"); + let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned(); + assert!(!saved.contains("value0"), "the field and its value are gone"); +} + +#[test] +fn crop_margins_are_points_whatever_the_user_unit() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut worker = worker(); + let extras = Extras { unit: Some(2.0), ..Extras::default() }; + let mut doc = open(&mut worker, dir.path(), "big.pdf", &pdf_with(&[(612.0, 792.0, &["x"])], &[], &extras)); + assert_eq!(worker.bounds(doc, 0, 1).expect("bounds"), [[0.0, 0.0, 1224.0, 1584.0]]); + worker.crop_pages(doc, &[0], [72.0, 0.0, 0.0, 0.0]).expect("crops"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.bounds(doc, 0, 1).expect("bounds"), [[0.0, 0.0, 1224.0, 1512.0]]); +} + +/// Runs `step` on its own thread, ten seconds at most. +fn bounded(step: impl FnOnce() -> T + Send + 'static) -> T { + let (done, answer) = std::sync::mpsc::channel(); + std::thread::spawn(move || done.send(step()).expect("the test waits")); + answer.recv_timeout(std::time::Duration::from_secs(10)).expect("done in time") +} + +#[test] +fn long_clashing_field_names_still_get_a_number() { + let dir = tempfile::tempdir().expect("a temp dir"); + let long = "a".repeat(255); + let page: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["x"])]; + let extras = Extras { fields: &[0], prefix: Some(&long), ..Extras::default() }; + let (mine, other) = (dir.path().join("mine.pdf"), dir.path().join("other.pdf")); + std::fs::write(&mine, pdf_with(page, &[], &extras)).expect("writes"); + std::fs::write(&other, pdf_with(page, &[], &extras)).expect("writes"); + let out = dir.path().join("out.pdf"); + let saved = out.clone(); + bounded(move || { + let mut worker = worker(); + let mut doc = worker.open(&mine).expect("opens").doc; + worker.insert_file(doc, 1, &other).expect("inserts"); + doc = worker.now(doc).expect("the state"); + worker.save(doc, &saved).expect("saves"); + }); + let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned(); + assert!(saved.contains(&format!("({long}0 2)")), "the second is numbered"); +} + +/// Red pixels of a page at one pixel a point. +fn red(worker: &mut Worker, doc: Doc, page: usize) -> usize { + let pixels = worker.render(doc, page, (1.0, 0), [0, 0, 612, 792], false).expect("renders").bgra; + pixels.chunks_exact(4).filter(|p| p[2] > 180 && p[1] < 80 && p[0] < 80).count() +} + +#[test] +fn inserted_fields_keep_the_look_of_their_form() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut worker = worker(); + let page: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["x"])]; + let other = dir.path().join("other.pdf"); + std::fs::write(&other, pdf_with(page, &[], &Extras { fields: &[0], look: Some(("Custom", "Custom")), ..Extras::default() })).expect("writes"); + let source = worker.open(&other).expect("opens").doc; + let shown = red(&mut worker, source, 0); + assert!(shown > 100, "the source draws its value red: {shown}"); + // Its own form gives the font name another font. + let mine = Extras { fields: &[0], prefix: Some("mine"), look: Some(("Custom", "Custom")), ..Extras::default() }; + let mut doc = open(&mut worker, dir.path(), "mine.pdf", &pdf_with(page, &[], &mine)); + worker.insert_file(doc, 1, &other).expect("inserts"); + doc = worker.now(doc).expect("the state"); + let out = dir.path().join("out.pdf"); + worker.save(doc, &out).expect("saves"); + let merged = worker.open(&out).expect("opens").doc; + assert_eq!(red(&mut worker, merged, 1), shown, "the same red, the same size"); + let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned(); + assert!(saved.contains("(/Custom_2 24 Tf 1 0 0 rg)"), "the clashing font is renamed where it is used"); +} + +#[test] +fn an_escaped_font_name_and_the_form_alignment_come_along() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut worker = worker(); + let page: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["x"])]; + let other = dir.path().join("other.pdf"); + let theirs = Extras { fields: &[0], look: Some(("Custom", "C#75stom")), align: Some(2), ..Extras::default() }; + std::fs::write(&other, pdf_with(page, &[], &theirs)).expect("writes"); + let mine = Extras { fields: &[0], prefix: Some("mine"), look: Some(("Custom", "Custom")), align: Some(0), ..Extras::default() }; + let mut doc = open(&mut worker, dir.path(), "mine.pdf", &pdf_with(page, &[], &mine)); + worker.insert_file(doc, 1, &other).expect("inserts"); + doc = worker.now(doc).expect("the state"); + let out = dir.path().join("out.pdf"); + worker.save(doc, &out).expect("saves"); + let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned(); + assert!(saved.contains("(/Custom_2 24 Tf 1 0 0 rg)"), "the escaped name is renamed too"); + assert!(saved.contains("/Q 2"), "right-aligned as in its own form"); +} diff --git a/crates/ely-pdf-engine/tests/independent.rs b/crates/ely-pdf-engine/tests/independent.rs new file mode 100644 index 0000000..924e268 --- /dev/null +++ b/crates/ely-pdf-engine/tests/independent.rs @@ -0,0 +1,63 @@ +//! Ely's edits read back by another reader, PDFKit (QA-13). +#![cfg(target_os = "macos")] + +use std::path::Path; + +use ely_pdf_engine::{FieldKind, NewField, Worker, Writing, Wrote, sample::{Extras, pdf_with}}; +use objc2::AllocAnyThread as _; +use objc2_foundation::{NSString, NSURL}; +use objc2_pdf_kit::PDFDocument; + +fn worker() -> Worker { + Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker") +} + +/// Each page's text and widget values, as PDFKit reads them. +fn read_back(path: &Path) -> Vec<(String, Vec)> { + let url = NSURL::fileURLWithPath(&NSString::from_str(path.to_str().expect("UTF-8"))); + let document = unsafe { PDFDocument::initWithURL(PDFDocument::alloc(), &url) }.expect("PDFKit opens it"); + (0..unsafe { document.pageCount() }) + .map(|at| { + let page = unsafe { document.pageAtIndex(at) }.expect("a page"); + let text = unsafe { page.string() }.map(|s| s.to_string()).unwrap_or_default(); + let values = unsafe { page.annotations() }.to_vec().into_iter().filter_map(|a| unsafe { a.widgetStringValue() }.map(|s| s.to_string())).collect(); + (text, values) + }) + .collect() +} + +#[test] +fn text_a_field_and_page_order_read_back_elsewhere() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join("doc.pdf"); + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["First page"]), (612.0, 792.0, &["Second page"])], &[], &Extras::default())).expect("writes"); + let mut worker = worker(); + let mut doc = worker.open(&path).expect("opens").doc; + let block = worker.text_blocks(doc, 0).expect("blocks").into_iter().position(|b| b.text.contains("First page")).expect("the block"); + let writing = Writing { + block: Some(block), + expected: "First page".into(), + text: "Changed words".into(), + font: "helvetica".into(), + size: 12.0, + rgb: 0, + bx: [72.0, 60.0, 400.0], + }; + assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + let field = NewField { kind: FieldKind::Text, name: "name".into(), rect: [72.0, 200.0, 272.0, 220.0], options: Vec::new() }; + worker.add_field(doc, 0, &field).expect("adds a field"); + doc = worker.now(doc).expect("the state"); + let id = worker.marks(doc, 0).expect("marks").fields[0].id; + worker.fill(doc, 0, id, "Ada").expect("fills"); + doc = worker.now(doc).expect("the state"); + worker.move_pages(doc, &[1], 0).expect("moves"); + doc = worker.now(doc).expect("the state"); + let out = dir.path().join("edited.pdf"); + worker.save(doc, &out).expect("saves"); + let pages = read_back(&out); + assert_eq!(pages.len(), 2); + assert!(pages[0].0.contains("Second page"), "{pages:?}"); + assert!(pages[1].0.contains("Changed words") && !pages[1].0.contains("First page"), "{pages:?}"); + assert_eq!(pages[1].1, vec!["Ada".to_string()]); +} diff --git a/crates/ely-pdf-engine/tests/marks.rs b/crates/ely-pdf-engine/tests/marks.rs new file mode 100644 index 0000000..8fcee7a --- /dev/null +++ b/crates/ely-pdf-engine/tests/marks.rs @@ -0,0 +1,274 @@ +//! Annotations and form fields against the real worker (PDF-05, PDF-06). + +use std::path::Path; + +use ely_pdf_engine::{ + Doc, FieldKind, NewAnnot, NewField, Shape, Worker, + sample::{Extras, pdf_with}, +}; + +fn worker() -> Worker { + Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker") +} + +/// A Letter page of two lines and a text field. +fn opened(path: &Path) -> (Worker, Doc) { + let extras = Extras { fields: &[0], ..Extras::default() }; + std::fs::write(path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes"); + let mut worker = worker(); + let doc = worker.open(path).expect("opens").doc; + (worker, doc) +} + +fn note(page: usize, shape: Shape) -> NewAnnot { + NewAnnot { page, shape, rgb: None, contents: String::new(), author: "Ann".into() } +} + +/// Whether page one's pixels `at` hold colour `bgra`. +fn has_color(worker: &mut Worker, doc: Doc, at: [i32; 4], bgra: [u8; 3]) -> bool { + let pixels = worker.render(doc, 0, (1.0, 0), at, false).expect("draws").bgra; + pixels.chunks_exact(4).any(|p| p[..3].iter().zip(bgra).all(|(a, b)| a.abs_diff(b) < 40)) +} + +#[test] +fn every_kind_lands_in_one_step_and_reads_back() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + let hello: Vec<_> = worker.text(doc, 0).expect("text").into_iter().take(5).collect(); + let line = [hello[0].rect[0], hello[0].rect[1], hello[4].rect[2], hello[4].rect[3]]; + let before = worker.history(doc).expect("a mark"); + let mut noted = note(0, Shape::Note((300.0, 300.0))); + noted.contents = "Check this".into(); + let mut marked = note(0, Shape::Highlight(vec![line])); + marked.rgb = Some(0x00ff00); + let all = [ + marked, + note(0, Shape::Underline(vec![line])), + note(0, Shape::StrikeOut(vec![line])), + noted, + note(0, Shape::Square([100.0, 400.0, 200.0, 450.0])), + note(0, Shape::Circle([250.0, 400.0, 350.0, 450.0])), + note(0, Shape::Line([100.0, 500.0, 300.0, 520.0])), + note(0, Shape::Ink(vec![vec![(100.0, 600.0), (150.0, 620.0), (200.0, 600.0)]])), + note(0, Shape::Stamp([350.0, 550.0, 540.0, 600.0], "Approved".into())), + ]; + let after = worker.annotate(doc, &all).expect("annotates"); + doc = worker.now(doc).expect("the state"); + let read = worker.marks(doc, 0).expect("marks").annots; + let kinds: Vec<&str> = read.iter().map(|a| a.kind.as_str()).collect(); + assert_eq!(kinds, ["Highlight", "Underline", "StrikeOut", "Text", "Square", "Circle", "Line", "Ink", "Stamp"]); + assert_eq!((read[0].rgb, read[3].contents.as_str(), read[3].author.as_str()), (Some(0x00ff00), "Check this", "Ann")); + assert_eq!(read[8].icon, "Approved"); + assert!(has_color(&mut worker, doc, [line[0] as i32, line[1] as i32, 20, 10], [0, 255, 0]), "the highlight is drawn"); + assert_eq!(worker.undo(doc, false).expect("undoes"), before, "one step"); + doc = worker.now(doc).expect("the state"); + assert!(worker.marks(doc, 0).expect("marks").annots.is_empty()); + assert_eq!(worker.undo(doc, true).expect("redoes"), after); + doc = worker.now(doc).expect("the state"); + let saved = dir.path().join("saved.pdf"); + worker.save(doc, &saved).expect("saves"); + let mut other = self::worker(); + let reopened = other.open(&saved).expect("opens").doc; + assert_eq!(other.marks(reopened, 0).expect("marks").annots.len(), 9, "kept in the file"); +} + +#[test] +fn a_bad_annotation_changes_nothing_and_keeps_redo() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + worker.annotate(doc, &[note(0, Shape::Square([100.0, 100.0, 200.0, 200.0]))]).expect("annotates"); + doc = worker.now(doc).expect("the state"); + worker.undo(doc, false).expect("undoes"); + doc = worker.now(doc).expect("the state"); + let good = note(0, Shape::Circle([100.0, 100.0, 200.0, 200.0])); + for bad in [ + note(0, Shape::Stamp([100.0, 100.0, 200.0, 200.0], "Hacked".into())), + note(0, Shape::Highlight(Vec::new())), + note(0, Shape::Ink(vec![vec![(1.0, 1.0)]])), + note(3, Shape::Note((10.0, 10.0))), + ] { + assert!(worker.annotate(doc, &[good.clone(), bad.clone()]).is_err(), "{bad:?}"); + } + assert!(worker.marks(doc, 0).expect("marks").annots.is_empty(), "the good one did not land either"); + worker.undo(doc, true).expect("redo survives"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.marks(doc, 0).expect("marks").annots[0].kind, "Square"); +} + +#[test] +fn annotations_change_and_go_by_their_number() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + let two = [note(0, Shape::Note((50.0, 50.0))), note(0, Shape::Square([100.0, 100.0, 200.0, 200.0]))]; + worker.annotate(doc, &two).expect("annotates"); + doc = worker.now(doc).expect("the state"); + let read = worker.marks(doc, 0).expect("marks").annots; + worker.change_annot(doc, 0, read[0].id, Some(0x0000ff), Some("Done")).expect("changes"); + doc = worker.now(doc).expect("the state"); + worker.delete_annot(doc, 0, read[1].id).expect("deletes"); + doc = worker.now(doc).expect("the state"); + let now = worker.marks(doc, 0).expect("marks").annots; + assert_eq!(now.len(), 1); + assert_eq!((now[0].id, now[0].rgb, now[0].contents.as_str()), (read[0].id, Some(0x0000ff), "Done")); + assert!(worker.delete_annot(doc, 0, read[1].id).is_err(), "gone already"); + worker.undo(doc, false).expect("undoes the delete"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.marks(doc, 0).expect("marks").annots[1].id, read[1].id, "the same object back"); +} + +fn field(kind: FieldKind, name: &str, rect: [f32; 4]) -> NewField { + let options = match kind { + FieldKind::Choice => vec!["Red".into(), "Blue".into()], + _ => Vec::new(), + }; + NewField { kind, name: name.into(), rect, options } +} + +#[test] +fn new_fields_fill_and_keep_their_values() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + for new in [ + field(FieldKind::Text, "Name", [72.0, 300.0, 272.0, 320.0]), + field(FieldKind::Checkbox, "Agree", [72.0, 340.0, 86.0, 354.0]), + field(FieldKind::Radio, "Size", [72.0, 370.0, 86.0, 384.0]), + field(FieldKind::Radio, "Size", [100.0, 370.0, 114.0, 384.0]), + field(FieldKind::Choice, "Color", [72.0, 400.0, 200.0, 420.0]), + ] { + worker.add_field(doc, 0, &new).expect("adds"); + doc = worker.now(doc).expect("the state"); + } + assert!(!has_color(&mut worker, doc, [76, 304, 190, 12], [0, 0, 0]), "an empty box"); + let fields = worker.marks(doc, 0).expect("marks").fields; + let names: Vec<(&str, FieldKind)> = fields.iter().map(|f| (f.name.as_str(), f.kind)).collect(); + assert_eq!(names, [ + ("field0", FieldKind::Text), + ("Name", FieldKind::Text), + ("Agree", FieldKind::Checkbox), + ("Size", FieldKind::Radio), + ("Size", FieldKind::Radio), + ("Color", FieldKind::Choice), + ]); + assert_ne!(fields[3].on, fields[4].on, "each radio has its own state"); + assert_eq!(fields[5].options, ["Red", "Blue"]); + worker.fill(doc, 0, fields[0].id, "changed").expect("fills the old field"); + doc = worker.now(doc).expect("the state"); + worker.fill(doc, 0, fields[1].id, "Zoe").expect("fills"); + doc = worker.now(doc).expect("the state"); + worker.fill(doc, 0, fields[2].id, &fields[2].on).expect("checks"); + doc = worker.now(doc).expect("the state"); + worker.fill(doc, 0, fields[3].id, &fields[3].on).expect("picks"); + doc = worker.now(doc).expect("the state"); + worker.fill(doc, 0, fields[4].id, &fields[4].on).expect("picks the other"); + doc = worker.now(doc).expect("the state"); + worker.fill(doc, 0, fields[5].id, "Blue").expect("chooses"); + doc = worker.now(doc).expect("the state"); + assert!(worker.fill(doc, 0, fields[3].id, "Nonsense").is_err(), "a state the button lacks"); + let saved = dir.path().join("saved.pdf"); + worker.save(doc, &saved).expect("saves"); + let mut other = self::worker(); + let reopened = other.open(&saved).expect("opens").doc; + let read = other.marks(reopened, 0).expect("marks").fields; + let values: Vec<(&str, bool)> = read.iter().map(|f| (f.value.as_str(), f.checked)).collect(); + assert_eq!(values[..2], [("changed", false), ("Zoe", false)]); + assert!(read[2].checked && !read[3].checked && read[4].checked, "one radio of the group"); + assert_eq!(read[5].value, "Blue"); + assert!(has_color(&mut other, reopened, [76, 304, 190, 12], [0, 0, 0]), "the typed name is drawn"); +} + +#[test] +fn field_names_stay_unique_and_groups_go_with_their_last_button() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + assert!(worker.add_field(doc, 0, &field(FieldKind::Text, "field0", [72.0, 300.0, 272.0, 320.0])).is_err(), "taken"); + assert!(worker.add_field(doc, 0, &field(FieldKind::Radio, "field0", [72.0, 300.0, 86.0, 314.0])).is_err(), "not a group"); + assert!(worker.add_field(doc, 0, &field(FieldKind::Text, "a.b", [72.0, 300.0, 272.0, 320.0])).is_err(), "a dotted name"); + let mut empty = field(FieldKind::Choice, "Pick", [72.0, 300.0, 272.0, 320.0]); + empty.options.clear(); + assert!(worker.add_field(doc, 0, &empty).is_err(), "a choice of nothing"); + worker.add_field(doc, 0, &field(FieldKind::Radio, "Size", [72.0, 370.0, 86.0, 384.0])).expect("adds"); + doc = worker.now(doc).expect("the state"); + let fields = worker.marks(doc, 0).expect("marks").fields; + assert!(worker.change_field(doc, 0, fields[1].id, Some("field0"), None).is_err(), "renamed onto another"); + worker.change_field(doc, 0, fields[0].id, Some("Title"), None).expect("renames"); + doc = worker.now(doc).expect("the state"); + assert!(worker.change_field(doc, 0, fields[0].id, None, Some(&["x".into()])).is_err(), "text has no options"); + worker.delete_field(doc, 0, fields[1].id).expect("deletes the only button"); + doc = worker.now(doc).expect("the state"); + worker.add_field(doc, 0, &field(FieldKind::Text, "Size", [72.0, 300.0, 272.0, 320.0])).expect("the group is gone"); + doc = worker.now(doc).expect("the state"); + let names: Vec = worker.marks(doc, 0).expect("marks").fields.into_iter().map(|f| f.name).collect(); + assert_eq!(names, ["Title", "Size"]); +} + +#[test] +fn a_choice_takes_new_options_and_drops_a_lost_value() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + worker.add_field(doc, 0, &field(FieldKind::Choice, "Color", [72.0, 400.0, 200.0, 420.0])).expect("adds"); + doc = worker.now(doc).expect("the state"); + let id = worker.marks(doc, 0).expect("marks").fields[1].id; + worker.fill(doc, 0, id, "Red").expect("chooses"); + doc = worker.now(doc).expect("the state"); + worker.change_field(doc, 0, id, None, Some(&["Red".into(), "Green".into()])).expect("keeps red"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.marks(doc, 0).expect("marks").fields[1].value, "Red"); + worker.change_field(doc, 0, id, None, Some(&["Green".into()])).expect("drops red"); + doc = worker.now(doc).expect("the state"); + let read = worker.marks(doc, 0).expect("marks").fields[1].clone(); + assert_eq!((read.value.as_str(), read.options), ("", vec!["Green".to_string()])); +} + +#[test] +fn a_filled_value_stays_drawn_through_undo_and_redo() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + worker.add_field(doc, 0, &field(FieldKind::Text, "Name", [72.0, 300.0, 272.0, 320.0])).expect("adds"); + doc = worker.now(doc).expect("the state"); + let id = worker.marks(doc, 0).expect("marks").fields[1].id; + worker.fill(doc, 0, id, "Zoe").expect("fills"); + doc = worker.now(doc).expect("the state"); + worker.undo(doc, false).expect("undoes"); + doc = worker.now(doc).expect("the state"); + worker.undo(doc, true).expect("redoes before a draw"); + doc = worker.now(doc).expect("the state"); + assert!(has_color(&mut worker, doc, [76, 304, 190, 12], [0, 0, 0]), "drawn"); + worker.undo(doc, false).expect("undoes"); + doc = worker.now(doc).expect("the state"); + assert!(!has_color(&mut worker, doc, [76, 304, 190, 12], [0, 0, 0]), "gone"); + worker.undo(doc, true).expect("redoes"); + doc = worker.now(doc).expect("the state"); + assert!(has_color(&mut worker, doc, [76, 304, 190, 12], [0, 0, 0]), "drawn again"); +} + +#[test] +fn a_nested_field_renames_within_its_parent() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join("doc.pdf"); + let extras = Extras { fields: &[0, 0], parent: Some("Person"), ..Extras::default() }; + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes"); + let mut worker = worker(); + let mut doc = worker.open(&path).expect("opens").doc; + let fields = worker.marks(doc, 0).expect("marks").fields; + let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["Person.field0", "Person.field1"]); + assert!(worker.change_field(doc, 0, fields[0].id, Some("field1"), None).is_err(), "a sibling's name"); + worker.change_field(doc, 0, fields[0].id, Some("First"), None).expect("renames"); + doc = worker.now(doc).expect("the state"); + worker.add_field(doc, 0, &field(FieldKind::Text, "First", [72.0, 300.0, 272.0, 320.0])).expect("a top field of the same leaf"); + doc = worker.now(doc).expect("the state"); + let names: Vec = worker.marks(doc, 0).expect("marks").fields.into_iter().map(|f| f.name).collect(); + assert_eq!(names, ["Person.First", "Person.field1", "First"]); +} + +#[test] +fn every_field_name_is_listed_widget_or_not() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join("doc.pdf"); + let extras = Extras { fields: &[0], bare: Some(("Text1", "Child")), ..Extras::default() }; + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes"); + let mut worker = worker(); + let doc = worker.open(&path).expect("opens").doc; + assert_eq!(worker.field_names(doc).expect("names"), ["field0", "Text1", "Text1.Child"]); + assert!(worker.add_field(doc, 0, &field(FieldKind::Text, "Text1", [72.0, 300.0, 272.0, 320.0])).is_err(), "the bare parent holds it"); +} diff --git a/crates/ely-pdf-engine/tests/ocr.rs b/crates/ely-pdf-engine/tests/ocr.rs new file mode 100644 index 0000000..9560047 --- /dev/null +++ b/crates/ely-pdf-engine/tests/ocr.rs @@ -0,0 +1,152 @@ +//! OCR and the hidden text layer, real worker (PDF-08). + +use std::path::{Path, PathBuf}; + +use ely_pdf_engine::{ + Doc, OcrLine, Worker, Writing, Wrote, + sample::{pdf, scan}, + tessdata, +}; + +fn worker() -> Worker { + Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker") +} + +/// Points a pixel when scanning. +const SCALE: f32 = 200.0 / 72.0; + +/// Page one of `doc`, saved as a gray scan. +fn scan_of(worker: &mut Worker, doc: Doc, out: PathBuf) -> PathBuf { + let (w, h) = ((612.0 * SCALE).ceil() as i32, (792.0 * SCALE).ceil() as i32); + let bgra = worker.render(doc, 0, (SCALE, 0), [0, 0, w, h], false).expect("draws").bgra; + let gray: Vec = bgra.chunks_exact(4).map(|p| ((u16::from(p[0]) + u16::from(p[1]) + u16::from(p[2])) / 3) as u8).collect(); + std::fs::write(&out, scan((612.0, 792.0), (w as u32, h as u32), &gray)).expect("writes"); + out +} + +/// A scan of two English lines, opened. +fn english(dir: &Path) -> (Worker, Doc) { + let source = dir.join("text.pdf"); + std::fs::write(&source, pdf(&[(612.0, 792.0, &["Hello world", "Second line"])], &[])).expect("writes"); + let mut worker = worker(); + let doc = worker.open(&source).expect("opens").doc; + let path = scan_of(&mut worker, doc, dir.join("scan.pdf")); + let doc = worker.open(&path).expect("opens").doc; + (worker, doc) +} + +fn words(worker: &mut Worker, doc: Doc) -> String { + worker.text(doc, 0).expect("text").iter().map(|c| c.c).collect() +} + +#[test] +fn a_scan_reads_and_its_text_layer_finds_without_drawing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = english(dir.path()); + assert_eq!(words(&mut worker, doc), "", "a scan has no text"); + let lines = worker.ocr_page(doc, 0, "eng", &tessdata().expect("data"), 300.0).expect("reads"); + let read: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect(); + assert!(read.iter().any(|l| l.contains("Hello world")) && read.iter().any(|l| l.contains("Second line")), "{read:?}"); + let hello = lines.iter().find(|l| l.text.contains("Hello")).expect("hello"); + assert!((hello.rect[0] - 72.0).abs() < 6.0 && (hello.rect[3] - 76.0).abs() < 8.0, "in page points: {:?}", hello.rect); + let before = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra; + let mark = worker.text_layer(doc, &[(0, lines)], false).expect("lays"); + doc = worker.now(doc).expect("the state"); + assert!(words(&mut worker, doc).contains("Hello world"), "searchable now"); + assert_eq!(worker.search(doc, 0, "second line").expect("finds").len(), 1); + assert!(worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra == before, "nothing drawn"); + worker.undo(doc, false).expect("undoes"); + doc = worker.now(doc).expect("the state"); + assert_eq!(words(&mut worker, doc), "", "one step"); + assert_eq!(worker.undo(doc, true).expect("redoes"), mark); + doc = worker.now(doc).expect("the state"); + let saved = dir.path().join("saved.pdf"); + worker.save(doc, &saved).expect("saves"); + let mut other = self::worker(); + let reopened = other.open(&saved).expect("opens").doc; + assert!(words(&mut other, reopened).contains("Hello world"), "kept in the file"); +} + +#[test] +fn a_new_layer_replaces_the_last_or_adds_beside_it() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = english(dir.path()); + let line = OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "Hello world".into() }; + worker.text_layer(doc, &[(0, vec![line.clone()])], false).expect("lays"); + doc = worker.now(doc).expect("the state"); + worker.text_layer(doc, &[(0, vec![line.clone()])], false).expect("lays again"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.search(doc, 0, "hello").expect("finds").len(), 2, "two layers"); + worker.text_layer(doc, &[(0, vec![line])], true).expect("replaces"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.search(doc, 0, "hello").expect("finds").len(), 1, "old layers gone"); +} + +#[test] +fn a_bad_layer_changes_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = english(dir.path()); + let before = worker.history(doc).expect("a mark"); + let good = (0, vec![OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "fine".into() }]); + for bad in [ + (0, vec![OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "𠀀".into() }]), + (0, vec![OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: String::new() }]), + (5, vec![OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "x".into() }]), + ] { + assert!(worker.text_layer(doc, &[good.clone(), bad.clone()], true).is_err(), "{bad:?}"); + } + assert_eq!((worker.history(doc).expect("a mark"), words(&mut worker, doc)), (before, String::new())); +} + +#[test] +fn chinese_reads_in_both_scripts() { + let dir = tempfile::tempdir().expect("a temp dir"); + let source = dir.path().join("blank.pdf"); + std::fs::write(&source, pdf(&[(612.0, 792.0, &[])], &[])).expect("writes"); + let mut worker = worker(); + let mut doc = worker.open(&source).expect("opens").doc; + for (font, text, at) in [("cjk-sc", "简体中文识别", 100.0), ("cjk-tc", "繁體中文識別", 300.0)] { + let writing = Writing { block: None, expected: String::new(), text: text.into(), font: font.into(), size: 36.0, rgb: 0, bx: [72.0, at, 400.0] }; + assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + } + let path = scan_of(&mut worker, doc, dir.path().join("scan.pdf")); + let doc = worker.open(&path).expect("opens").doc; + let lines = worker.ocr_page(doc, 0, "chi_sim+chi_tra+eng", &tessdata().expect("data"), 300.0).expect("reads"); + let read: String = lines.iter().map(|l| l.text.replace(' ', "")).collect(); + assert!(read.contains("简体中文") && read.contains("繁體中文"), "{read}"); +} + +#[test] +fn a_bad_language_says_so() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = english(dir.path()); + let error = worker.ocr_page(doc, 0, "xyz", &tessdata().expect("data"), 300.0).expect_err("no such data"); + assert!(error.to_string().contains("Tesseract"), "{error}"); + assert!(worker.ocr_page(doc, 0, "eng", &tessdata().expect("data"), 300.0).is_ok(), "the worker goes on"); +} + +#[test] +fn a_language_that_does_not_load_fails_the_read() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = english(dir.path()); + let data = dir.path().join("tessdata"); + std::fs::create_dir(&data).expect("a data dir"); + std::fs::copy(tessdata().expect("data").join("eng.traineddata"), data.join("eng.traineddata")).expect("copies"); + let error = worker.ocr_page(doc, 0, "eng+chi_sim", &data, 300.0).expect_err("chi_sim is missing"); + assert!(error.to_string().contains("1 of 2 languages"), "{error}"); +} + +#[test] +fn text_added_after_a_layer_still_shows() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = english(dir.path()); + let line = OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "Hello world".into() }; + worker.text_layer(doc, &[(0, vec![line])], false).expect("lays"); + doc = worker.now(doc).expect("the state"); + let before = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra; + let writing = Writing { block: None, expected: String::new(), text: "VISIBLE".into(), font: "helvetica".into(), size: 24.0, rgb: 0, bx: [72.0, 600.0, 300.0] }; + assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + assert!(worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra != before, "the new text is drawn"); +} diff --git a/crates/ely-pdf-engine/tests/output.rs b/crates/ely-pdf-engine/tests/output.rs new file mode 100644 index 0000000..a8e60a4 --- /dev/null +++ b/crates/ely-pdf-engine/tests/output.rs @@ -0,0 +1,187 @@ +//! Images, text, optimized copies, printing leave, repaired files (C11.8). + +use std::{io::ErrorKind, path::Path}; + +use ely_pdf_engine::{ + Doc, Lock, Optimizing, Worker, + proto::may, + sample::{Extras, one_page, pdf_with}, +}; + +fn worker() -> Worker { + Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker") +} + +fn opened(path: &Path) -> (Worker, Doc) { + let mut worker = worker(); + let doc = worker.open(path).expect("opens").doc; + (worker, doc) +} + +fn two_pages(dir: &Path) -> std::path::PathBuf { + let path = dir.join("doc.pdf"); + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["First page"]), (612.0, 792.0, &["Second page"])], &[], &Extras::default())).expect("writes"); + path +} + +#[test] +fn a_page_exports_as_a_png_of_its_size() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&two_pages(dir.path())); + let png = dir.path().join("page.png"); + worker.export_image(doc, 1, 144.0, &png).expect("exports"); + let bytes = std::fs::read(&png).expect("reads"); + assert_eq!(&bytes[1..4], b"PNG"); + let size = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().expect("four bytes")); + assert_eq!((size(16), size(20)), (1224, 1584), "twice the points"); + assert!(worker.export_image(doc, 0, 5000.0, &png).is_err(), "an absurd resolution"); +} + +#[test] +fn text_exports_page_by_page() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&two_pages(dir.path())); + let txt = dir.path().join("doc.txt"); + worker.export_text(doc, &txt).expect("exports"); + let text = std::fs::read_to_string(&txt).expect("UTF-8"); + let pages: Vec<&str> = text.split('\u{c}').collect(); + assert_eq!(pages.len(), 2); + assert!(pages[0].contains("First page") && pages[1].contains("Second page"), "{text:?}"); +} + +/// A 600-pixel picture drawn 100 points wide: 432 dpi. +fn heavy(dir: &Path) -> std::path::PathBuf { + let pixels = vec![120u8; 600 * 600 * 3]; + let mut picture = format!("<< /Type /XObject /Subtype /Image /Width 600 /Height 600 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >>\nstream\n", pixels.len()).into_bytes(); + picture.extend_from_slice(&pixels); + picture.extend_from_slice(b"\nendstream"); + let path = dir.join("heavy.pdf"); + let mut bytes = one_page("", " /Resources << /XObject << /Im 5 0 R >> >>", "q 100 0 0 100 72 600 cm /Im Do Q", &["PICTURE".to_string()]); + let at = bytes.windows(7).position(|w| w == b"PICTURE").expect("the slot"); + bytes.splice(at..at + 7, picture); + std::fs::write(&path, &bytes).expect("writes"); + path +} + +#[test] +fn an_optimized_copy_is_smaller_and_its_picture_downsampled() { + let dir = tempfile::tempdir().expect("a temp dir"); + let source = heavy(dir.path()); + let (mut worker, doc) = opened(&source); + let out = dir.path().join("small.pdf"); + worker.optimize(doc, &out, Optimizing { compress: true, from: 300, to: 150 }).expect("optimizes"); + let (before, after) = (std::fs::metadata(&source).expect("size").len(), std::fs::metadata(&out).expect("size").len()); + assert!(after * 10 < before, "{before} -> {after}"); + let mut other = self::worker(); + let read = other.open(&out).expect("opens").doc; + assert_eq!(other.pages(read).expect("pages"), 1); + let text = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned(); + let width: u32 = text.split("/Width ").nth(1).and_then(|rest| rest.split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse().ok()).expect("a width"); + assert!((205..=212).contains(&width), "150 dpi over 100 points, not {width}"); + assert!(worker.optimize(doc, &out, Optimizing { compress: true, from: 150, to: 300 }).is_err(), "up is not down"); +} + +#[test] +fn printing_and_copying_need_leave() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&two_pages(dir.path())); + let limited = dir.path().join("limited.pdf"); + worker.protect(doc, &limited, Some(&Lock { user: String::new(), owner: "owner".into(), allow: may::EDIT })).expect("protects"); + let (mut other, read) = opened(&limited); + for refused in [other.print_copy(read, &dir.path().join("p.pdf")), other.export_text(read, &dir.path().join("t.txt"))] { + assert_eq!(refused.expect_err("refused").kind(), ErrorKind::PermissionDenied); + } + worker.print_copy(doc, &dir.path().join("print.pdf")).expect("an unprotected file prints"); +} + +#[test] +fn a_damaged_file_opens_as_a_repaired_copy_and_stays_as_it_was() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = two_pages(dir.path()); + let mut bytes = std::fs::read(&path).expect("reads"); + // Breaks every cross-reference offset. + let at = bytes.windows(9).rposition(|w| w == b"startxref").expect("the trailer"); + bytes.truncate(at); + bytes.extend_from_slice(b"startxref\n9\n%%EOF\n"); + std::fs::write(&path, &bytes).expect("writes"); + let (mut worker, mut doc) = opened(&path); + let report = worker.repair_report(doc).expect("asks").expect("repaired"); + assert!(!report.trim().is_empty(), "MuPDF says what it found"); + assert_eq!(worker.pages(doc).expect("pages"), 2); + worker.rotate_pages(doc, &[0], 90).expect("edits the copy"); + doc = worker.now(doc).expect("the state"); + worker.save(doc, &dir.path().join("fixed.pdf")).expect("saves the copy"); + assert_eq!(std::fs::read(&path).expect("reads"), bytes, "the damaged original stays"); + let elsewhere = tempfile::tempdir().expect("a temp dir"); + let (mut fresh, clean) = opened(&two_pages(elsewhere.path())); + assert_eq!(fresh.repair_report(clean).expect("asks"), None); +} + +#[test] +fn a_broken_object_with_a_sound_trailer_is_repaired_on_open() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = two_pages(dir.path()); + let mut bytes = std::fs::read(&path).expect("reads"); + // The page tree's offset points nowhere; startxref is sound. + let table = bytes.windows(6).position(|w| w == b"\nxref\n").expect("the table") + 6; + let entries = table + bytes[table..].iter().position(|b| *b == b'\n').expect("a header") + 1; + let at = entries + 2 * 20; + bytes[at..at + 10].copy_from_slice(b"0000000009"); + std::fs::write(&path, &bytes).expect("writes"); + let (mut worker, mut doc) = opened(&path); + assert!(worker.repair_report(doc).expect("asks").is_some(), "found at open, not mid-edit"); + worker.rotate_pages(doc, &[0], 90).expect("edits"); + doc = worker.now(doc).expect("the state"); + worker.save(doc, &dir.path().join("fixed.pdf")).expect("saves"); + assert_eq!(std::fs::read(&path).expect("reads"), bytes); +} + +#[test] +fn a_broken_stream_under_a_sound_table_is_damage_and_fails_exports() { + let dir = tempfile::tempdir().expect("a temp dir"); + let broken = "<< /Length 12 /Filter /FlateDecode >>\nstream\nnot a zlib!!\nendstream".to_string(); + let path = dir.path().join("doc.pdf"); + let bytes = one_page("", " /Contents 5 0 R", "", &[broken]); + std::fs::write(&path, &bytes).expect("writes"); + let (mut worker, mut doc) = opened(&path); + let report = worker.repair_report(doc).expect("asks").expect("damage is reported"); + assert!(!report.trim().is_empty()); + assert!(worker.export_text(doc, &dir.path().join("doc.txt")).is_err(), "partial text is no export"); + worker.rotate_pages(doc, &[0], 90).expect("edits the copy"); + doc = worker.now(doc).expect("the state"); + worker.save(doc, &dir.path().join("fixed.pdf")).expect("saves the copy"); + assert_eq!(std::fs::read(&path).expect("reads"), bytes, "the original stays"); +} + +#[test] +fn a_print_copy_carries_no_lock() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&two_pages(dir.path())); + let locked = dir.path().join("locked.pdf"); + worker.protect(doc, &locked, Some(&Lock { user: "user".into(), owner: "owner".into(), allow: may::PRINT })).expect("protects"); + let (mut other, read) = opened(&locked); + assert!(other.unlock(read, "user").expect("tries")); + let print = dir.path().join("print.pdf"); + other.print_copy(read, &print).expect("writes"); + let mut third = self::worker(); + let opened = third.open(&print).expect("opens"); + assert!(!opened.locked, "the printer needs no password"); + assert_eq!(third.pages(opened.doc).expect("pages"), 2); +} + +#[test] +fn a_broken_picture_shows_when_drawn_or_checked() { + let dir = tempfile::tempdir().expect("a temp dir"); + let picture = "<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length 12 >>\nstream\nnot a zlib!!\nendstream".to_string(); + let path = dir.path().join("doc.pdf"); + let bytes = one_page("", " /Resources << /XObject << /Im 5 0 R >> >>", "q 100 0 0 100 72 600 cm /Im Do Q", &[picture]); + std::fs::write(&path, &bytes).expect("writes"); + let (mut worker, doc) = opened(&path); + assert_eq!(worker.repair_report(doc).expect("asks"), None, "pictures wait to be drawn"); + let tile = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws"); + assert!(tile.damaged, "drawing finds it"); + assert!(worker.repair_report(doc).expect("asks").is_some()); + let (mut other, again) = opened(&path); + assert!(other.check_pictures(again).expect("checks"), "the check finds it undrawn"); + assert_eq!(std::fs::read(&path).expect("reads"), bytes); +} diff --git a/crates/ely-pdf-engine/tests/privacy.rs b/crates/ely-pdf-engine/tests/privacy.rs new file mode 100644 index 0000000..ac2c30c --- /dev/null +++ b/crates/ely-pdf-engine/tests/privacy.rs @@ -0,0 +1,57 @@ +//! Worker records reach the app log; document text never. + +use std::{ + path::Path, + sync::{Mutex, OnceLock}, + time::Duration, +}; + +use ely_pdf_engine::{LinkChange, Target, Worker, sample::pdf}; + +/// Every record the app would log, kept for the test. +struct Kept; + +fn kept() -> &'static Mutex> { + static KEPT: OnceLock>> = OnceLock::new(); + KEPT.get_or_init(Mutex::default) +} + +impl log::Log for Kept { + fn enabled(&self, _: &log::Metadata) -> bool { + true + } + + fn log(&self, record: &log::Record) { + kept().lock().unwrap().push(format!("{} {} {}", record.level(), record.target(), record.args())); + } + + fn flush(&self) {} +} + +#[test] +fn a_broken_stream_keeps_its_text_out_of_the_log() { + log::set_boxed_logger(Box::new(Kept)).unwrap(); + log::set_max_level(log::LevelFilter::Trace); + // The worker logs at debug; the relay filters. + unsafe { std::env::set_var("ELY_LOG", "debug") }; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("broken.pdf"); + // An unknown operator: MuPDF warns, quoting it. + let line = "ZqxPrivate42) Tj ZqxPrivate42 (tail"; + std::fs::write(&path, pdf(&[(612.0, 792.0, &[line])], &[])).unwrap(); + let mut worker = Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).unwrap(); + let opened = worker.open(&path).unwrap(); + let _ = worker.text(opened.doc, 0); + // A refused address holds a credential, unquoted. + let secret = Target::Web("ftp://ana:ZqxPrivate42@files.example/document".into()); + let at = worker.now(opened.doc).unwrap(); + assert!(worker.change_link(at, 0, &LinkChange::Add { rect: [72.0, 300.0, 200.0, 320.0], to: secret }).is_err()); + worker.close(opened.doc).unwrap(); + drop(worker); + std::thread::sleep(Duration::from_millis(500)); + let records = kept().lock().unwrap().clone(); + assert!(records.iter().any(|r| r.contains("native output withheld")), "MuPDF spoke: {records:#?}"); + assert!(records.iter().any(|r| r.contains("ely_pdf_worker") || r.contains("pdf-worker")), "the worker's own records pass"); + let leaked: Vec<_> = records.iter().filter(|r| r.contains("ZqxPrivate42")).collect(); + assert!(leaked.is_empty(), "document text reached the log: {leaked:#?}"); +} diff --git a/crates/ely-pdf-engine/tests/redact.rs b/crates/ely-pdf-engine/tests/redact.rs new file mode 100644 index 0000000..789ada8 --- /dev/null +++ b/crates/ely-pdf-engine/tests/redact.rs @@ -0,0 +1,342 @@ +//! Removal and cleaning, checked in the file (QA-14). + +use std::path::Path; + +use ely_pdf_engine::{ + Cleaning, Doc, NewAnnot, OcrLine, Region, Shape, Worker, + sample::{Extras, pdf_with}, +}; + +fn worker() -> Worker { + Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker") +} + +/// Two lines, a picture at 72..172 across, 242..292 down. +fn opened(path: &Path) -> (Worker, Doc) { + let extras = Extras { picture: true, ..Extras::default() }; + std::fs::write(path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes"); + let mut worker = worker(); + let doc = worker.open(path).expect("opens").doc; + (worker, doc) +} + +fn words(worker: &mut Worker, doc: Doc) -> String { + worker.text(doc, 0).expect("text").iter().map(|c| c.c).collect() +} + +/// The box of the first `n` characters. +fn first(worker: &mut Worker, doc: Doc, n: usize) -> [f32; 4] { + let chars = worker.text(doc, 0).expect("text"); + let (a, b) = (chars[0].rect, chars[n - 1].rect); + [a[0] - 1.0, a[1] - 1.0, b[2] + 0.5, b[3] + 1.0] +} + +fn reopen(path: &Path) -> (Worker, Doc) { + let mut worker = worker(); + let doc = worker.open(path).expect("opens").doc; + (worker, doc) +} + +#[test] +fn removed_text_is_gone_from_the_file_and_the_rest_stays() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + let hello = first(&mut worker, doc, 5); + let line = OcrLine { rect: hello, text: "Hidden".into() }; + worker.text_layer(doc, &[(0, vec![line])], false).expect("an invisible layer over it"); + doc = worker.now(doc).expect("the state"); + let mark = worker.history(doc).expect("a mark"); + let out = dir.path().join("out.pdf"); + let report = worker.redact(doc, &[Region { page: 0, rect: hello }], &out).expect("removes"); + assert_eq!((report.text, report.light, report.versions), (0, 0, 1)); + assert_eq!(worker.history(doc).expect("a mark"), mark, "the open document is untouched"); + assert!(words(&mut worker, doc).contains("Hello")); + let (mut other, written) = reopen(&out); + let left = words(&mut other, written); + assert!(!left.contains("Hello") && !left.contains("Hidden"), "{left}"); + assert!(left.contains("world") && left.contains("Second line"), "{left}"); + let bytes = std::fs::read(&out).expect("reads"); + assert!(!bytes.windows(5).any(|w| w == b"Hello"), "no trace in the bytes"); + assert_eq!(bytes.windows(9).filter(|w| w == b"startxref").count(), 1, "one revision"); +} + +#[test] +fn a_picture_under_a_region_loses_those_pixels() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&dir.path().join("doc.pdf")); + let out = dir.path().join("out.pdf"); + worker.redact(doc, &[Region { page: 0, rect: [72.0, 242.0, 122.0, 292.0] }], &out).expect("removes"); + let (mut other, written) = reopen(&out); + let left = other.render(written, 0, (1.0, 0), [124, 244, 46, 46], false).expect("draws").bgra; + assert!(left.chunks_exact(4).any(|p| p[0] > 100 || p[1] > 100 || p[2] > 100), "the right half keeps its colour"); + let gone = other.render(written, 0, (1.0, 0), [74, 244, 46, 46], false).expect("draws").bgra; + assert!(gone.chunks_exact(4).all(|p| p[0] < 40 && p[1] < 40 && p[2] < 40), "the left half is black"); +} + +#[test] +fn notes_in_a_region_go_and_others_stay() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + let note = |at| NewAnnot { page: 0, shape: Shape::Note(at), rgb: None, contents: "secret".into(), author: String::new() }; + worker.annotate(doc, &[note((80.0, 400.0)), note((400.0, 600.0))]).expect("notes"); + doc = worker.now(doc).expect("the state"); + let out = dir.path().join("out.pdf"); + worker.redact(doc, &[Region { page: 0, rect: [70.0, 390.0, 120.0, 430.0] }], &out).expect("removes"); + let (mut other, written) = reopen(&out); + let kept = other.marks(written, 0).expect("marks").annots; + assert_eq!(kept.len(), 1, "the far note stays"); + assert!(kept[0].rect[0] > 300.0); +} + +#[test] +fn bad_regions_write_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&dir.path().join("doc.pdf")); + let out = dir.path().join("out.pdf"); + assert!(worker.redact(doc, &[], &out).is_err()); + assert!(worker.redact(doc, &[Region { page: 4, rect: [0.0, 0.0, 10.0, 10.0] }], &out).is_err()); + assert!(!out.exists()); +} + +#[test] +fn cleaning_reaches_what_it_counted_and_leaves_none() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + worker.set_properties(doc, &[("Title", "Secret plan"), ("Author", "Zoe")]).expect("sets"); + doc = worker.now(doc).expect("the state"); + let note = NewAnnot { page: 0, shape: Shape::Note((300.0, 300.0)), rgb: None, contents: "x".into(), author: String::new() }; + worker.annotate(doc, &[note]).expect("notes"); + doc = worker.now(doc).expect("the state"); + let file = dir.path().join("a.txt"); + std::fs::write(&file, "attached").expect("writes"); + worker.attach(doc, "a.txt", &file).expect("attaches"); + doc = worker.now(doc).expect("the state"); + worker.text_layer(doc, &[(0, vec![OcrLine { rect: [72.0, 600.0, 200.0, 620.0], text: "invisible".into() }])], false).expect("lays"); + doc = worker.now(doc).expect("the state"); + let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans"); + assert!(reach.meta >= 2 && reach.annots == 1 && reach.files == 1 && reach.hidden == 1, "{reach:?}"); + let out = dir.path().join("out.pdf"); + let all = Cleaning { meta: true, annots: true, files: true, hidden: true }; + let left = worker.clean(doc, all, &out).expect("cleans"); + assert_eq!((left.meta, left.annots, left.files, left.hidden, left.scripts), (0, 0, 0, 0, 0)); + let (mut other, written) = reopen(&out); + assert_eq!(other.properties(written).expect("reads").title, None); + assert!(other.attachments(written).expect("lists").is_empty()); + assert!(!words(&mut other, written).contains("invisible")); + assert!(words(&mut other, written).contains("Hello world"), "seen text stays"); + assert_eq!(worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans"), reach, "the open document is untouched"); +} + +#[test] +fn cleaning_one_kind_leaves_the_others() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + worker.set_properties(doc, &[("Title", "Plan")]).expect("sets"); + doc = worker.now(doc).expect("the state"); + let note = NewAnnot { page: 0, shape: Shape::Note((300.0, 300.0)), rgb: None, contents: "x".into(), author: String::new() }; + worker.annotate(doc, &[note]).expect("notes"); + doc = worker.now(doc).expect("the state"); + let out = dir.path().join("out.pdf"); + let left = worker.clean(doc, Cleaning { annots: true, ..Cleaning::default() }, &out).expect("cleans"); + assert_eq!(left.annots, 0); + assert!(left.meta >= 1, "properties were not asked for"); + assert!(worker.clean(doc, Cleaning::default(), &dir.path().join("none.pdf")).is_err(), "nothing chosen"); +} + +/// Helvetica as object 5, for the hand-made pages. +fn helvetica() -> String { + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into() +} + +fn written(dir: &Path, bytes: Vec) -> (Worker, Doc) { + let path = dir.join("doc.pdf"); + std::fs::write(&path, bytes).expect("writes"); + reopen(&path) +} + +fn holds(path: &Path, needle: &str) -> bool { + std::fs::read(path).expect("reads").windows(needle.len()).any(|w| w == needle.as_bytes()) +} + +#[test] +fn a_shape_touching_a_region_goes_whole() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page("", "", "1 0 0 rg 100 700 200 40 re f", &[])); + let out = dir.path().join("out.pdf"); + let report = worker.redact(doc, &[Region { page: 0, rect: [90.0, 45.0, 180.0, 100.0] }], &out).expect("removes"); + assert_eq!(report.objects, 0); + let (mut other, read) = reopen(&out); + let right = other.render(read, 0, (1.0, 0), [200, 55, 90, 30], false).expect("draws").bgra; + assert!(right.chunks_exact(4).all(|p| p[2] < 200 || p[1] > 200), "no red left past the box"); +} + +#[test] +fn a_field_in_a_region_takes_its_parent_and_value() { + let dir = tempfile::tempdir().expect("a temp dir"); + let objects = [ + "<< /FT /Tx /T (parent) /V (SECRET-FIELD-VALUE) /Kids [6 0 R] >>".to_string(), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [70 700 220 740] /P 3 0 R >>".to_string(), + ]; + let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /AcroForm << /Fields [5 0 R] >>", " /Annots [6 0 R]", "", &objects)); + let out = dir.path().join("out.pdf"); + worker.redact(doc, &[Region { page: 0, rect: [60.0, 40.0, 230.0, 100.0] }], &out).expect("removes"); + assert!(!holds(&out, "SECRET-FIELD-VALUE")); +} + +#[test] +fn hidden_layers_faint_text_and_hidden_notes_clean_out() { + let dir = tempfile::tempdir().expect("a temp dir"); + let content = "BT /F1 12 Tf 72 700 Td (VISIBLE) Tj ET \ + /OC /H BDC BT /F1 12 Tf 72 680 Td (SECRET-OFF-LAYER) Tj ET EMC \ + q /G0 gs BT /F1 12 Tf 72 660 Td (SECRET-FAINT) Tj ET Q"; + let objects = [ + helvetica(), + "<< /Type /OCG /Name (Hidden) >>".to_string(), + "<< /Type /ExtGState /ca 0 >>".to_string(), + "<< /Type /Annot /Subtype /Text /F 2 /Rect [300 300 320 320] /Contents (SECRET-HIDDEN-NOTE) >>".to_string(), + ]; + let catalog = " /OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >>"; + let page = " /Resources << /Font << /F1 5 0 R >> /Properties << /H 6 0 R >> /ExtGState << /G0 7 0 R >> >> /Annots [8 0 R]"; + let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(catalog, page, content, &objects)); + let scratch = dir.path().join("scan.pdf"); + let reach = worker.clean_scan(doc, &scratch).expect("scans"); + assert_eq!((reach.hidden, reach.hidden_annots, reach.annots), (1, 1, 0), "{reach:?}"); + let out = dir.path().join("out.pdf"); + worker.clean(doc, Cleaning { hidden: true, ..Cleaning::default() }, &out).expect("cleans without /Names"); + for secret in ["SECRET-OFF-LAYER", "SECRET-FAINT", "SECRET-HIDDEN-NOTE"] { + assert!(!holds(&out, secret), "{secret}"); + } + assert!(holds(&out, "VISIBLE")); +} + +#[test] +fn metadata_on_pages_cleans_out_too() { + let dir = tempfile::tempdir().expect("a temp dir"); + let xmp = "SECRET-PAGE-METADATA"; + let objects = [format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len())]; + let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page("", " /Metadata 5 0 R", "", &objects)); + let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans"); + assert_eq!(reach.meta, 1); + let out = dir.path().join("out.pdf"); + worker.clean(doc, Cleaning { meta: true, ..Cleaning::default() }, &out).expect("cleans"); + assert!(!holds(&out, "SECRET-PAGE-METADATA")); +} + +#[test] +fn marked_content_says_nothing_after_removal() { + let dir = tempfile::tempdir().expect("a temp dir"); + let content = "/Span << /MCID 0 /ActualText (SECRET-ACTUAL) >> BDC BT /F1 12 Tf 72 700 Td (Hello) Tj ET EMC"; + let objects = [ + helvetica(), + "<< /Type /StructTreeRoot /K [7 0 R] >>".to_string(), + "<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /Alt (SECRET-STRUCT) /E (SECRET-EXPANDED) >>".to_string(), + ]; + let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /StructTreeRoot 6 0 R", " /Resources << /Font << /F1 5 0 R >> >>", content, &objects)); + let out = dir.path().join("out.pdf"); + worker.redact(doc, &[Region { page: 0, rect: [60.0, 80.0, 200.0, 100.0] }], &out).expect("removes"); + for secret in ["SECRET-ACTUAL", "SECRET-STRUCT", "SECRET-EXPANDED", "Hello"] { + assert!(!holds(&out, secret), "{secret}"); + } +} + +#[test] +fn overlapping_regions_both_remove() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&dir.path().join("doc.pdf")); + let hello = first(&mut worker, doc, 5); + let after = [hello[2] - 2.0, hello[1], hello[2] + 60.0, hello[3]]; + let out = dir.path().join("out.pdf"); + worker.redact(doc, &[Region { page: 0, rect: hello }, Region { page: 0, rect: after }], &out).expect("removes"); + let (mut other, read) = reopen(&out); + assert!(!words(&mut other, read).contains("Hello")); +} + +#[test] +fn nested_metadata_and_associated_files_clean_out() { + let dir = tempfile::tempdir().expect("a temp dir"); + let secret = "SECRET-AF-BYTES"; + let objects = [ + "<< /Type /Filespec /F (a.txt) /EF << /F 6 0 R >> >>".to_string(), + format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{secret}\nendstream", secret.len()), + ]; + let page = " /Extra << /Deep << /PieceInfo << /App << /Private (SECRET-PIECE) >> >> >> >>"; + let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /AF [5 0 R]", page, "", &objects)); + let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans"); + assert_eq!((reach.meta, reach.files), (1, 1), "{reach:?}"); + let out = dir.path().join("out.pdf"); + worker.clean(doc, Cleaning { meta: true, files: true, ..Cleaning::default() }, &out).expect("cleans"); + assert!(!holds(&out, secret) && !holds(&out, "SECRET-PIECE") && !holds(&out, "/AF")); +} + +#[test] +fn scripts_on_links_fields_and_chains_clean_out() { + let dir = tempfile::tempdir().expect("a temp dir"); + let objects = [ + "<< /Type /Annot /Subtype /Link /Rect [72 600 200 620] /A << /S /JavaScript /JS (SECRET-LINK-JS) >> >>".to_string(), + "<< /FT /Tx /T (p) /AA << /K << /S /JavaScript /JS (SECRET-FIELD-JS) >> >> /Kids [7 0 R] >>".to_string(), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [72 500 200 520] /P 3 0 R >>".to_string(), + ]; + let catalog = " /AcroForm << /Fields [6 0 R] >> /OpenAction << /S /GoTo /D [3 0 R /Fit] \ + /Next << /S /JavaScript /JS (SECRET-NEXT-JS) /Next << /S /Named /N /LastPage >> >> >>"; + let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(catalog, " /Annots [5 0 R 7 0 R]", "", &objects)); + let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans"); + assert_eq!(reach.scripts, 3, "{reach:?}"); + let out = dir.path().join("out.pdf"); + worker.clean(doc, Cleaning { hidden: true, ..Cleaning::default() }, &out).expect("cleans"); + for secret in ["SECRET-LINK-JS", "SECRET-FIELD-JS", "SECRET-NEXT-JS"] { + assert!(!holds(&out, secret), "{secret}"); + } + assert!(holds(&out, "/LastPage") && holds(&out, "/GoTo"), "what follows a script stays"); +} + +#[test] +fn elements_placed_by_their_kids_say_nothing_after_removal() { + let dir = tempfile::tempdir().expect("a temp dir"); + let content = "/Span << /MCID 0 >> BDC BT /F1 12 Tf 72 700 Td (Hello) Tj ET EMC"; + let objects = [ + helvetica(), + "<< /Type /StructTreeRoot /K [7 0 R] >>".to_string(), + "<< /Type /StructElem /S /P /P 6 0 R /K << /Type /MCR /Pg 3 0 R /MCID 0 >> /ActualText (SECRET-MCR) >>".to_string(), + ]; + let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /StructTreeRoot 6 0 R", " /Resources << /Font << /F1 5 0 R >> >>", content, &objects)); + let out = dir.path().join("out.pdf"); + worker.redact(doc, &[Region { page: 0, rect: [60.0, 80.0, 200.0, 100.0] }], &out).expect("removes"); + assert!(!holds(&out, "SECRET-MCR")); +} + +#[test] +fn a_redacted_page_loses_its_thumbnail() { + let dir = tempfile::tempdir().expect("a temp dir"); + let pixels = "SECRET-THUMB"; + let objects = [format!("<< /Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >>\nstream\n{pixels}\nendstream", pixels.len())]; + let page = " /Resources << /XObject << /Im 5 0 R >> >> /Thumb 5 0 R"; + let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page("", page, "q 100 0 0 50 0 742 cm /Im Do Q", &objects)); + let out = dir.path().join("out.pdf"); + worker.redact(doc, &[Region { page: 0, rect: [0.0, 0.0, 100.0, 50.0] }], &out).expect("removes"); + assert!(!holds(&out, pixels) && !holds(&out, "/Thumb")); +} + +#[test] +fn hidden_text_takes_what_its_marks_say() { + let dir = tempfile::tempdir().expect("a temp dir"); + let content = "/Span << /ActualText (SECRET-HIDDEN-ACTUAL) >> BDC BT /F1 12 Tf 3 Tr 72 700 Td (Unseen) Tj ET EMC"; + let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page("", " /Resources << /Font << /F1 5 0 R >> >>", content, &[helvetica()])); + let out = dir.path().join("out.pdf"); + worker.clean(doc, Cleaning { hidden: true, ..Cleaning::default() }, &out).expect("cleans"); + assert!(!holds(&out, "SECRET-HIDDEN-ACTUAL") && !holds(&out, "Unseen")); +} + +#[test] +fn hidden_links_and_fields_clean_out() { + let dir = tempfile::tempdir().expect("a temp dir"); + let objects = [ + "<< /Type /Annot /Subtype /Link /F 2 /Rect [72 600 200 620] /Contents (SECRET-HIDDEN-LINK) /A << /S /URI /URI (https://x.test/SECRET-HIDDEN-LINK) >> >>".to_string(), + "<< /Type /Annot /Subtype /Widget /F 2 /FT /Tx /T (f) /V (SECRET-HIDDEN-WIDGET) /Rect [72 500 200 520] /P 3 0 R >>".to_string(), + ]; + let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /AcroForm << /Fields [6 0 R] >>", " /Annots [5 0 R 6 0 R]", "", &objects)); + let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans"); + assert_eq!(reach.hidden_annots, 2, "{reach:?}"); + let out = dir.path().join("out.pdf"); + worker.clean(doc, Cleaning { hidden: true, ..Cleaning::default() }, &out).expect("cleans"); + assert!(!holds(&out, "SECRET-HIDDEN-LINK") && !holds(&out, "SECRET-HIDDEN-WIDGET")); +} diff --git a/crates/ely-pdf-engine/tests/secure.rs b/crates/ely-pdf-engine/tests/secure.rs new file mode 100644 index 0000000..7aa44e2 --- /dev/null +++ b/crates/ely-pdf-engine/tests/secure.rs @@ -0,0 +1,118 @@ +//! Protected copies and what they allow (PDF-11). + +use std::{io::ErrorKind, path::Path}; + +use ely_pdf_engine::{ + Doc, Lock, Worker, + proto::may, + sample::{Extras, pdf_with}, +}; + +fn worker() -> Worker { + Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker") +} + +fn plain(dir: &Path) -> (Worker, Doc) { + let path = dir.join("doc.pdf"); + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &Extras::default())).expect("writes"); + let mut worker = worker(); + let doc = worker.open(&path).expect("opens").doc; + (worker, doc) +} + +/// Writes a copy that opens with "user", allowing only `allow`. +fn locked(dir: &Path, allow: u32) -> std::path::PathBuf { + let (mut worker, doc) = plain(dir); + let out = dir.join("locked.pdf"); + let lock = Lock { user: "user".into(), owner: "owner".into(), allow }; + worker.protect(doc, &out, Some(&lock)).expect("protects"); + out +} + +#[test] +fn a_protected_copy_asks_and_holds_its_limits() { + let dir = tempfile::tempdir().expect("a temp dir"); + let out = locked(dir.path(), may::PRINT); + let bytes = std::fs::read(&out).expect("reads"); + assert!(bytes.windows(5).any(|w| w == b"AESV3"), "AES-256"); + let mut worker = worker(); + let opened = worker.open(&out).expect("opens"); + assert!(opened.locked); + assert!(!worker.unlock(opened.doc, "wrong").expect("tries")); + assert!(worker.unlock(opened.doc, "user").expect("tries")); + let allowed = worker.permissions(opened.doc).expect("reads"); + assert_eq!(allowed & (may::PRINT | may::COPY | may::EDIT | may::OWNER), may::PRINT); + let refused = worker.rotate_pages(opened.doc, &[0], 90).expect_err("refused"); + assert_eq!((refused.kind(), refused.to_string()), (ErrorKind::PermissionDenied, "assemble".to_string())); + let text: String = worker.text(opened.doc, 0).expect("still reads").iter().map(|c| c.c).collect(); + assert!(text.contains("Hello")); +} + +#[test] +fn a_wrong_owner_password_keeps_access_and_the_right_one_lifts_limits() { + let dir = tempfile::tempdir().expect("a temp dir"); + let out = locked(dir.path(), 0); + let mut worker = worker(); + let doc = worker.open(&out).expect("opens").doc; + worker.unlock(doc, "user").expect("unlocks"); + assert!(!worker.owner(doc, "nope").expect("tries")); + assert_eq!(worker.pages(doc).expect("still open"), 1); + assert!(worker.text(doc, 0).is_ok()); + assert!(worker.owner(doc, "owner").expect("tries")); + assert_ne!(worker.permissions(doc).expect("reads") & may::OWNER, 0); + worker.rotate_pages(doc, &[0], 90).expect("allowed now"); +} + +#[test] +fn changing_protection_needs_the_owner() { + let dir = tempfile::tempdir().expect("a temp dir"); + let out = locked(dir.path(), may::PRINT | may::COPY | may::EDIT); + let mut worker = worker(); + let doc = worker.open(&out).expect("opens").doc; + worker.unlock(doc, "user").expect("unlocks"); + let bare = dir.path().join("bare.pdf"); + let refused = worker.protect(doc, &bare, None).expect_err("refused"); + assert_eq!(refused.kind(), ErrorKind::PermissionDenied); + assert!(!bare.exists()); + worker.owner(doc, "owner").expect("tries"); + worker.protect(doc, &bare, None).expect("unprotects"); + let mut other = self::worker(); + assert!(!other.open(&bare).expect("opens").locked); +} + +#[test] +fn an_owner_password_is_needed_and_must_differ() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = plain(dir.path()); + let out = dir.path().join("out.pdf"); + for (user, owner) in [("a", ""), ("same", "same")] { + let lock = Lock { user: user.into(), owner: owner.into(), allow: 0 }; + assert!(worker.protect(doc, &out, Some(&lock)).is_err(), "{user}/{owner}"); + assert!(!out.exists()); + } +} + +#[test] +fn a_file_with_no_open_password_still_holds_limits() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = plain(dir.path()); + let out = dir.path().join("out.pdf"); + let lock = Lock { user: String::new(), owner: "owner".into(), allow: may::PRINT }; + worker.protect(doc, &out, Some(&lock)).expect("protects"); + let mut other = self::worker(); + let opened = other.open(&out).expect("opens"); + assert!(!opened.locked); + let refused = other.annotate(opened.doc, &[]).expect_err("refused"); + assert_eq!((refused.kind(), refused.to_string()), (ErrorKind::PermissionDenied, "annotate".to_string())); +} + +#[test] +fn a_wrong_owner_try_on_aes_128_keeps_the_text_readable() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/pdf/aes128.pdf"); + let mut worker = worker(); + let doc = worker.open(&fixture).expect("opens").doc; + assert!(worker.unlock(doc, "user").expect("tries")); + assert!(!worker.owner(doc, "nope").expect("tries")); + let text: String = worker.text(doc, 0).expect("reads").iter().map(|c| c.c).collect(); + assert_eq!(text.trim(), "Hello"); +} diff --git a/crates/ely-pdf-engine/tests/sign.rs b/crates/ely-pdf-engine/tests/sign.rs new file mode 100644 index 0000000..72c00fe --- /dev/null +++ b/crates/ely-pdf-engine/tests/sign.rs @@ -0,0 +1,244 @@ +//! Signing by update and checking what was signed (PDF-12, QA-15). + +use std::{io::ErrorKind, path::{Path, PathBuf}}; + +use ely_pdf_engine::{ + Digest, Doc, Lock, Signing, Worker, + proto::may, + sample::{Extras, one_page, pdf_with}, +}; + +fn worker() -> Worker { + Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker") +} + +fn opened(path: &Path) -> (Worker, Doc) { + let mut worker = worker(); + let doc = worker.open(path).expect("opens").doc; + (worker, doc) +} + +fn plain(dir: &Path) -> PathBuf { + let path = dir.join("doc.pdf"); + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &Extras::default())).expect("writes"); + path +} + +fn signing(object: Option, name: &str, password: &str) -> Signing { + let pfx = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/pdf/signer.p12"); + Signing { + page: 0, + object, + rect: [72.0, 600.0, 272.0, 660.0], + name: name.into(), + pfx: pfx.to_str().expect("UTF-8").into(), + pfx_password: password.into(), + reason: "Approved".into(), + location: "Here".into(), + } +} + +#[test] +fn a_new_signature_checks_and_the_document_stays() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = plain(dir.path()); + let (mut worker, doc) = opened(&path); + let before = worker.history(doc).expect("history"); + let out = dir.path().join("signed.pdf"); + worker.sign(doc, &out, &signing(None, "Signature1", "ely")).expect("signs"); + assert!(worker.signatures(doc, &path).expect("reads").is_empty(), "the open document has none"); + assert_eq!(worker.history(doc).expect("history"), before); + let (mut other, read) = opened(&out); + let found = other.signatures(read, &out).expect("reads"); + assert_eq!(found.len(), 1); + let signed = found[0].signed.as_ref().expect("signed"); + assert_eq!((signed.digest, signed.later, signed.broken), (Digest::Intact, false, false)); + assert_eq!((signed.common.as_str(), signed.reason.as_str(), signed.location.as_str()), ("Ely Test Signer", "Approved", "Here")); + assert!(signed.date.starts_with("D:"), "{}", signed.date); + assert_eq!(found[0].page, Some(0), "found through the page's annotations"); + assert!(signed.certs.first().is_some_and(|der| der.first() == Some(&0x30)), "DER"); +} + +#[test] +fn a_wrong_certificate_password_writes_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&plain(dir.path())); + let out = dir.path().join("signed.pdf"); + assert!(worker.sign(doc, &out, &signing(None, "Signature1", "nope")).is_err()); + assert!(!out.exists()); +} + +#[test] +fn a_changed_byte_shows_as_changed() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&plain(dir.path())); + let out = dir.path().join("signed.pdf"); + worker.sign(doc, &out, &signing(None, "Signature1", "ely")).expect("signs"); + let mut bytes = std::fs::read(&out).expect("reads"); + let at = bytes.windows(5).position(|w| w == b"Hello").expect("the text"); + bytes[at] = b'J'; + std::fs::write(&out, bytes).expect("writes"); + let (mut other, read) = opened(&out); + let signed = other.signatures(read, &out).expect("reads")[0].signed.clone().expect("signed"); + assert_eq!(signed.digest, Digest::Changed); +} + +#[test] +fn a_second_signature_leaves_the_first_intact_and_marked_later() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&plain(dir.path())); + let once = dir.path().join("once.pdf"); + worker.sign(doc, &once, &signing(None, "First", "ely")).expect("signs"); + let (mut other, read) = opened(&once); + let twice = dir.path().join("twice.pdf"); + let mut second = signing(None, "Second", "ely"); + second.rect = [300.0, 600.0, 500.0, 660.0]; + other.sign(read, &twice, &second).expect("signs again"); + let (mut third, last) = opened(&twice); + let found = third.signatures(last, &twice).expect("reads"); + let first = found.iter().find(|s| s.name == "First").and_then(|s| s.signed.clone()).expect("first"); + assert_eq!((first.digest, first.later), (Digest::Intact, true)); + let next = found.iter().find(|s| s.name == "Second").and_then(|s| s.signed.clone()).expect("second"); + assert_eq!((next.digest, next.later), (Digest::Intact, false)); +} + +#[test] +fn an_empty_field_is_signed_in_place() { + let dir = tempfile::tempdir().expect("a temp dir"); + let field = "<< /Type /Annot /Subtype /Widget /FT /Sig /T (Sign here) /Rect [72 600 272 660] /P 3 0 R /F 4 >>".to_string(); + let path = dir.path().join("form.pdf"); + std::fs::write(&path, one_page(" /AcroForm << /Fields [5 0 R] /SigFlags 3 >>", " /Annots [5 0 R]", "", &[field])).expect("writes"); + let (mut worker, doc) = opened(&path); + let empty = worker.signatures(doc, &path).expect("reads"); + assert_eq!((empty.len(), empty[0].signed.is_none(), empty[0].name.as_str()), (1, true, "Sign here")); + let out = dir.path().join("signed.pdf"); + worker.sign(doc, &out, &signing(Some(empty[0].object), "", "ely")).expect("signs"); + let (mut other, read) = opened(&out); + let found = other.signatures(read, &out).expect("reads"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].signed.as_ref().map(|s| s.digest), Some(Digest::Intact)); +} + +#[test] +fn a_new_field_needs_leave_to_change_the_page() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&plain(dir.path())); + let limited = dir.path().join("limited.pdf"); + let lock = Lock { user: String::new(), owner: "owner".into(), allow: may::PRINT | may::FILL }; + worker.protect(doc, &limited, Some(&lock)).expect("protects"); + let (mut other, read) = opened(&limited); + let refused = other.sign(read, &dir.path().join("signed.pdf"), &signing(None, "S", "ely")).expect_err("refused"); + assert_eq!(refused.kind(), ErrorKind::PermissionDenied); +} + +#[test] +fn an_ordinary_save_of_a_signed_file_carries_no_signature() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&plain(dir.path())); + let signed = dir.path().join("signed.pdf"); + worker.sign(doc, &signed, &signing(None, "Signature1", "ely")).expect("signs"); + let (mut other, read) = opened(&signed); + assert_eq!(other.signed(read).expect("counts"), 1); + other.rotate_pages(read, &[0], 90).expect("edits"); + let copy = dir.path().join("copy.pdf"); + other.save(read, ©).expect("saves"); + let (mut third, again) = opened(©); + assert_eq!(third.signed(again).expect("counts"), 0); + let fields = third.signatures(again, ©).expect("reads"); + assert_eq!((fields.len(), fields[0].signed.is_none()), (1, true), "the field stays, empty"); +} + +#[test] +fn the_signer_is_the_certificate_given() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&plain(dir.path())); + let out = dir.path().join("signed.pdf"); + worker.sign(doc, &out, &signing(None, "Signature1", "ely")).expect("signs"); + let (mut other, read) = opened(&out); + let signed = other.signatures(read, &out).expect("reads")[0].signed.clone().expect("signed"); + let der = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/pdf/signer.der")).expect("reads"); + assert_eq!(signed.certs, vec![der]); +} + +#[test] +fn a_typed_signature_writes_in_the_script_face_and_says_what_it_lacks() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&plain(dir.path())); + let writing = |text: &str| ely_pdf_engine::Writing { + block: None, + expected: String::new(), + text: text.into(), + font: "script".into(), + size: 28.0, + rgb: 0, + bx: [72.0, 600.0, 400.0], + }; + assert!(matches!(worker.put_text(doc, 0, &writing("Ada Lovelace"), 0.0).expect("writes"), ely_pdf_engine::Wrote::Done(_))); + doc = worker.now(doc).expect("the state"); + let text: String = worker.text(doc, 0).expect("reads").iter().map(|c| c.c).collect(); + assert!(text.contains("Ada Lovelace"), "{text}"); + let saved = dir.path().join("typed.pdf"); + worker.save(doc, &saved).expect("saves"); + let bytes = std::fs::read(&saved).expect("reads"); + assert!(bytes.windows(10).any(|w| w == b"GreatVibes"), "the script face is embedded"); + assert!(matches!(worker.put_text(doc, 0, &writing("王"), 0.0).expect("asks"), ely_pdf_engine::Wrote::Missing('王'))); +} + +#[test] +fn signatures_are_read_from_the_file_named() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&plain(dir.path())); + let signed = dir.path().join("signed.pdf"); + worker.sign(doc, &signed, &signing(None, "Signature1", "ely")).expect("signs"); + let (mut other, read) = opened(&signed); + let (mark, copy) = (other.history(read).expect("history"), dir.path().join("copy.pdf")); + other.save(read, ©).expect("saves"); + assert_eq!(other.history(read).expect("history"), mark, "saving edits nothing"); + assert!(other.signatures(read, ©).expect("reads")[0].signed.is_none(), "the copy holds none"); + assert!(other.signatures(read, &signed).expect("reads")[0].signed.is_some(), "the signed file still does"); +} + +#[test] +fn a_signature_on_no_page_is_still_listed() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&plain(dir.path())); + let out = dir.path().join("signed.pdf"); + worker.sign(doc, &out, &signing(None, "Signature1", "ely")).expect("signs"); + let mut bytes = std::fs::read(&out).expect("reads"); + // No page lists the widget; the form still does. + let at = bytes.windows(7).rposition(|w| w == b"/Annots").expect("the page's annotations"); + bytes[at + 1] = b'X'; + std::fs::write(&out, bytes).expect("writes"); + let (mut other, read) = opened(&out); + let found = other.signatures(read, &out).expect("reads"); + assert_eq!(found.len(), 1); + assert_eq!((found[0].page, found[0].signed.is_some()), (None, true)); +} + +#[test] +fn a_failed_save_leaves_the_history_whole() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&plain(dir.path())); + assert!(worker.save(doc, &dir.path().join("missing/out.pdf")).is_err()); + let mark = worker.rotate_pages(doc, &[0], 90).expect("rotates"); + doc = worker.now(doc).expect("the state"); + assert_ne!(mark, "0", "the edit is a step of its own"); + worker.undo(doc, false).expect("undoes"); +} + +#[test] +fn a_field_emptied_by_a_save_can_be_signed_again() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&plain(dir.path())); + let signed = dir.path().join("signed.pdf"); + worker.sign(doc, &signed, &signing(None, "Signature1", "ely")).expect("signs"); + let (mut other, read) = opened(&signed); + let copy = dir.path().join("copy.pdf"); + other.save(read, ©).expect("saves"); + let (mut third, again) = opened(©); + let field = third.signatures(again, ©).expect("reads")[0].clone(); + let out = dir.path().join("resigned.pdf"); + third.sign(again, &out, &signing(Some(field.object), "", "ely")).expect("signs the emptied field"); + let found = third.signatures(again, &out).expect("reads"); + assert_eq!(found[0].signed.as_ref().map(|s| s.digest), Some(Digest::Intact)); +} diff --git a/crates/ely-pdf-engine/tests/structure.rs b/crates/ely-pdf-engine/tests/structure.rs new file mode 100644 index 0000000..3944cf0 --- /dev/null +++ b/crates/ely-pdf-engine/tests/structure.rs @@ -0,0 +1,191 @@ +//! Bookmarks, links, properties, attachments against the real worker (PDF-07). + +use std::path::Path; + +use ely_pdf_engine::{ + BookmarkChange, Doc, LinkChange, Target, Worker, + sample::{Extras, pdf_with}, +}; + +fn worker() -> Worker { + Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker") +} + +/// Two pages, bookmarks One (with Sub) and Two, a link. +fn opened(path: &Path) -> (Worker, Doc) { + let pages: [(f32, f32, &[&str]); 2] = [(612.0, 792.0, &["Page one"]), (612.0, 792.0, &["Page two"])]; + let marks = [("One", 0, &[("Sub", 1)][..]), ("Two", 1, &[][..])]; + std::fs::write(path, pdf_with(&pages, &marks, &Extras { links: &[(0, 1)], ..Extras::default() })).expect("writes"); + let mut worker = worker(); + let doc = worker.open(path).expect("opens").doc; + (worker, doc) +} + +/// The outline as indented titles with their pages. +fn tree(worker: &mut Worker, doc: Doc) -> Vec { + fn walk(marks: &[ely_pdf_engine::Bookmark], depth: usize, out: &mut Vec) { + for mark in marks { + out.push(format!("{}{} {}", "-".repeat(depth), mark.title, mark.page.map_or(-1, |p| p as i64))); + walk(&mark.down, depth + 1, out); + } + } + let mut out = Vec::new(); + walk(&worker.outline(doc).expect("outline"), 0, &mut out); + out +} + +#[test] +fn bookmarks_add_rename_retarget_and_go() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + assert_eq!(tree(&mut worker, doc), ["One 0", "-Sub 1", "Two 1"]); + worker.change_bookmark(doc, &BookmarkChange::Add { path: vec![2], title: "Three".into(), page: 1, y: 100.0 }).expect("adds"); + doc = worker.now(doc).expect("the state"); + worker.change_bookmark(doc, &BookmarkChange::Add { path: vec![0, 1], title: "Under".into(), page: 0, y: 0.0 }).expect("adds below"); + doc = worker.now(doc).expect("the state"); + let rename = BookmarkChange::Edit { path: vec![1], expect: "Two".into(), title: Some("Deux".into()), to: Some((0, 50.0)) }; + worker.change_bookmark(doc, &rename).expect("edits"); + doc = worker.now(doc).expect("the state"); + assert_eq!(tree(&mut worker, doc), ["One 0", "-Sub 1", "-Under 0", "Deux 0", "Three 1"]); + assert!(worker.change_bookmark(doc, &BookmarkChange::Delete { path: vec![1], expect: "Two".into() }).is_err(), "renamed since"); + assert!(worker.change_bookmark(doc, &BookmarkChange::Delete { path: vec![9], expect: "One".into() }).is_err(), "no such place"); + worker.change_bookmark(doc, &BookmarkChange::Delete { path: vec![0], expect: "One".into() }).expect("deletes with its children"); + doc = worker.now(doc).expect("the state"); + assert_eq!(tree(&mut worker, doc), ["Deux 0", "Three 1"]); + worker.undo(doc, false).expect("undoes"); + doc = worker.now(doc).expect("the state"); + assert_eq!(tree(&mut worker, doc).len(), 5, "one step"); + let saved = dir.path().join("saved.pdf"); + worker.save(doc, &saved).expect("saves"); + let mut other = self::worker(); + let reopened = other.open(&saved).expect("opens").doc; + assert_eq!(tree(&mut other, reopened), ["One 0", "-Sub 1", "-Under 0", "Deux 0", "Three 1"]); +} + +#[test] +fn links_list_add_retarget_and_go() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + let links = worker.links(doc, 0).expect("links"); + assert_eq!((links.len(), links[0].page), (1, Some(1)), "the sample links to page two"); + let web = Target::Web("https://example.com/".into()); + worker.change_link(doc, 0, &LinkChange::Add { rect: [72.0, 300.0, 200.0, 320.0], to: web.clone() }).expect("adds"); + doc = worker.now(doc).expect("the state"); + let links = worker.links(doc, 0).expect("links"); + assert_eq!((links[1].uri.as_str(), links[1].page), ("https://example.com/", None)); + let first = links[0].uri.clone(); + worker.change_link(doc, 0, &LinkChange::Edit { index: 0, expect: first.clone(), to: Target::Page(0, 200.0) }).expect("retargets"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.links(doc, 0).expect("links")[0].page, Some(0)); + assert!(worker.change_link(doc, 0, &LinkChange::Delete { index: 0, expect: first }).is_err(), "changed since"); + for unsafe_uri in ["javascript:alert(1)", "file:///etc/passwd"] { + let add = LinkChange::Add { rect: [72.0, 400.0, 200.0, 420.0], to: Target::Web(unsafe_uri.into()) }; + assert!(worker.change_link(doc, 0, &add).is_err(), "{unsafe_uri}"); + } + let now = worker.links(doc, 0).expect("links")[1].uri.clone(); + worker.change_link(doc, 0, &LinkChange::Delete { index: 1, expect: now }).expect("deletes"); + doc = worker.now(doc).expect("the state"); + assert_eq!(worker.links(doc, 0).expect("links").len(), 1); +} + +#[test] +fn properties_read_set_and_keep() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + assert_eq!(worker.properties(doc).expect("reads").title, None, "the sample has none"); + worker.set_properties(doc, &[("Title", "Report"), ("Author", "Zoe")]).expect("sets"); + doc = worker.now(doc).expect("the state"); + let read = worker.properties(doc).expect("reads"); + assert_eq!((read.title.as_deref(), read.author.as_deref()), (Some("Report"), Some("Zoe"))); + assert!(worker.set_properties(doc, &[("Title", "x"), ("Producer", "Ely")]).is_err(), "the producer is the producer's"); + assert_eq!(worker.properties(doc).expect("reads").title.as_deref(), Some("Report"), "nothing changed"); + let saved = dir.path().join("saved.pdf"); + worker.save(doc, &saved).expect("saves"); + let mut other = self::worker(); + let reopened = other.open(&saved).expect("opens").doc; + assert_eq!(other.properties(reopened).expect("reads").author.as_deref(), Some("Zoe")); +} + +#[test] +fn attachments_embed_read_and_go() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + assert!(worker.attachments(doc).expect("lists").is_empty()); + let file = dir.path().join("notes.txt"); + std::fs::write(&file, "seven words of notes for the file").expect("writes"); + worker.attach(doc, "notes.txt", &file).expect("attaches"); + doc = worker.now(doc).expect("the state"); + worker.attach(doc, "b.txt", &file).expect("attaches another"); + doc = worker.now(doc).expect("the state"); + let list = worker.attachments(doc).expect("lists"); + let names: Vec<(&str, u64)> = list.iter().map(|a| (a.name.as_str(), a.size)).collect(); + assert_eq!(names, [("b.txt", 33), ("notes.txt", 33)], "by name"); + assert_eq!(worker.attachment(doc, "n:notes.txt").expect("reads"), b"seven words of notes for the file"); + assert!(worker.attach(doc, "notes.txt", &file).is_err(), "the name is taken"); + worker.detach(doc, "n:b.txt").expect("removes"); + doc = worker.now(doc).expect("the state"); + assert!(worker.attachment(doc, "n:b.txt").is_err()); + let saved = dir.path().join("saved.pdf"); + worker.save(doc, &saved).expect("saves"); + let mut other = self::worker(); + let reopened = other.open(&saved).expect("opens").doc; + let kept: Vec = other.attachments(reopened).expect("lists").into_iter().map(|a| a.name).collect(); + assert_eq!(kept, ["notes.txt"]); +} + +#[test] +fn a_file_on_a_page_lists_reads_and_goes_by_its_key() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join("doc.pdf"); + let extras = Extras { attached: Some(("note.txt", b"notes")), ..Extras::default() }; + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes"); + let mut worker = worker(); + let mut doc = worker.open(&path).expect("opens").doc; + let list = worker.attachments(doc).expect("lists"); + assert_eq!(list.len(), 1, "the page's file is an attachment"); + assert_eq!((list[0].name.as_str(), list[0].page, list[0].size), ("note.txt", Some(0), 5), "its size from its bytes"); + assert!(list[0].key.starts_with("a:0:")); + assert_eq!(worker.attachment(doc, &list[0].key).expect("reads"), b"notes"); + worker.detach(doc, &list[0].key).expect("removes"); + doc = worker.now(doc).expect("the state"); + assert!(worker.attachments(doc).expect("lists").is_empty()); + assert!(worker.marks(doc, 0).expect("marks").annots.is_empty(), "with its annotation"); +} + +#[test] +fn reading_an_attachment_leaves_its_bytes_in_the_document() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf")); + let file = dir.path().join("note.txt"); + std::fs::write(&file, "notes").expect("writes"); + worker.attach(doc, "note.txt", &file).expect("attaches"); + doc = worker.now(doc).expect("the state"); + let mark = worker.history(doc).expect("a mark"); + for _ in 0..2 { + assert_eq!(worker.attachment(doc, "n:note.txt").expect("reads"), b"notes"); + } + assert_eq!(worker.history(doc).expect("a mark"), mark, "reading changes nothing"); + let saved = dir.path().join("saved.pdf"); + worker.save(doc, &saved).expect("saves"); + let mut other = self::worker(); + let reopened = other.open(&saved).expect("opens").doc; + assert_eq!(other.attachment(reopened, "n:note.txt").expect("reads"), b"notes", "whole in the file"); +} + +#[test] +fn an_attachment_read_before_a_change_is_refused() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(&dir.path().join("doc.pdf")); + let (old, new) = (dir.path().join("old.txt"), dir.path().join("new.txt")); + std::fs::write(&old, "old report").expect("writes"); + std::fs::write(&new, "new report").expect("writes"); + let first = doc.at(&worker.attach(doc, "report.txt", &old).expect("attaches")).expect("the state"); + let gone = first.at(&worker.detach(first, "n:report.txt").expect("removes")).expect("the state"); + let now = gone.at(&worker.attach(gone, "report.txt", &new).expect("attaches again")).expect("the state"); + // The same key names another file now: refused. + let read = worker.attachment(first, "n:report.txt").expect_err("an older state"); + assert!(ely_pdf_engine::is_stale(&read), "{read}"); + let removed = worker.detach(first, "n:report.txt").expect_err("an older state"); + assert!(ely_pdf_engine::is_stale(&removed), "{removed}"); + assert_eq!(worker.attachment(now, "n:report.txt").expect("reads"), b"new report", "the new file stays"); +} diff --git a/crates/ely-pdf-engine/tests/worker.rs b/crates/ely-pdf-engine/tests/worker.rs new file mode 100644 index 0000000..49e59be --- /dev/null +++ b/crates/ely-pdf-engine/tests/worker.rs @@ -0,0 +1,287 @@ +//! The worker against generated PDFs. + +use std::path::Path; + +use ely_pdf_engine::{Doc, Worker, sample::pdf}; + +fn worker() -> Worker { + Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker") +} + + +fn opened(dir: &Path, bytes: &[u8]) -> (Worker, Doc) { + let path = dir.join("doc.pdf"); + std::fs::write(&path, bytes).expect("writes"); + let mut worker = worker(); + let opened = worker.open(&path).expect("opens"); + assert!(!opened.locked); + (worker, opened.doc) +} + +#[test] +fn pages_measure_render_and_read_their_text() { + let dir = tempfile::tempdir().expect("a temp dir"); + let bytes = pdf( + &[ + (612.0, 792.0, &["Ely Office contract", "Second line"]), + (842.0, 595.0, &["Wide page"]), + ], + &[], + ); + let (mut worker, doc) = opened(dir.path(), &bytes); + assert_eq!(worker.pages(doc).expect("counts"), 2); + assert_eq!( + worker.bounds(doc, 0, 2).expect("bounds"), + [[0.0, 0.0, 612.0, 792.0], [0.0, 0.0, 842.0, 595.0]] + ); + // Page one's top left, twice its size: text is dark. + let tile = worker + .render(doc, 0, (2.0, 0), [0, 0, 600, 300], false) + .expect("renders").bgra; + assert_eq!(tile.len(), 600 * 300 * 4); + let dark = tile.chunks_exact(4).filter(|p| p[0] < 128).count(); + assert!(dark > 100, "text shows: {dark}"); + let night = worker + .render(doc, 0, (2.0, 0), [0, 0, 600, 300], true) + .expect("renders dark").bgra; + let light = night.chunks_exact(4).filter(|p| p[0] > 128).count(); + assert!( + light > 100 && night[0] < 64, + "dark mode turns paper dark and text light" + ); + // A quarter turn makes the page tall side wide. + assert!( + worker + .render(doc, 1, (1.0, 90), [0, 0, 595, 842], false) + .is_ok() + ); + let text: String = worker + .text(doc, 0) + .expect("reads") + .iter() + .map(|c| c.c) + .collect(); + assert!( + text.contains("Ely Office contract") && text.contains("Second line"), + "{text}" + ); + let chars = worker.text(doc, 0).expect("reads"); + assert!( + chars.windows(2).all(|w| w[0].line <= w[1].line), + "reading order" + ); + assert!( + chars[0].rect[1] < chars.last().expect("chars").rect[1], + "first line above" + ); +} + +#[test] +fn search_finds_every_hit_and_bookmarks_nest() { + let dir = tempfile::tempdir().expect("a temp dir"); + let pages: &[(f32, f32, &[&str])] = &[ + (612.0, 792.0, &["alpha beta alpha"]), + (612.0, 792.0, &["no match here"]), + (612.0, 792.0, &["Alpha at last"]), + ]; + let bytes = pdf(pages, &[("Intro", 0, &[("Detail", 1)]), ("End", 2, &[])]); + let (mut worker, doc) = opened(dir.path(), &bytes); + let hits: Vec = (0..3) + .map(|page| worker.search(doc, page, "alpha").expect("searches").len()) + .collect(); + assert_eq!(hits, [2, 0, 1], "case is ignored"); + let quad = worker.search(doc, 0, "beta").expect("searches")[0][0]; + assert!(quad[0] > 72.0 && quad[1] > 0.0, "{quad:?}"); + let marks = worker.outline(doc).expect("outline"); + let flat: Vec<(&str, Option, usize)> = marks + .iter() + .map(|m| (m.title.as_str(), m.page, m.down.len())) + .collect(); + assert_eq!(flat, [("Intro", Some(0), 1), ("End", Some(2), 0)]); + assert_eq!( + (marks[0].down[0].title.as_str(), marks[0].down[0].page), + ("Detail", Some(1)) + ); + worker.close(doc).expect("closes"); + assert!(worker.pages(doc).is_err(), "a closed document is gone"); +} + +#[test] +fn a_broken_file_is_an_error_and_the_worker_lives_on() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join("junk.pdf"); + std::fs::write(&path, b"this is not a pdf at all").expect("writes"); + let mut worker = worker(); + let failed = worker.open(&path).expect_err("not a PDF"); + assert!(!failed.to_string().is_empty()); + assert!(worker.open(&dir.path().join("missing.pdf")).is_err()); + let good = dir.path().join("good.pdf"); + std::fs::write(&good, pdf(&[(612.0, 792.0, &["Still here"])], &[])).expect("writes"); + assert_eq!( + worker.open(&good).map(|o| o.locked).ok(), + Some(false), + "the worker still works" + ); +} + +#[test] +fn a_thousand_pages_show_the_first_in_time() { + let dir = tempfile::tempdir().expect("a temp dir"); + let lines: &[&str] = &["A long report, page after page."]; + let pages: Vec<(f32, f32, &[&str])> = (0..1000).map(|_| (612.0, 792.0, lines)).collect(); + let bytes = pdf(&pages, &[]); + let started = std::time::Instant::now(); + let (mut worker, doc) = opened(dir.path(), &bytes); + assert_eq!(worker.pages(doc).expect("counts"), 1000); + // What the viewer asks before its first frame. + worker.bounds(doc, 0, 100).expect("the first sizes"); + worker.render(doc, 0, (2.0, 0), [0, 0, 512, 512], false).expect("the first tile"); + let took = started.elapsed(); + assert!(took < std::time::Duration::from_secs(3), "PERF-05: {took:?}"); +} + +#[test] +fn every_hit_counts_once_however_many_or_however_split() { + let dir = tempfile::tempdir().expect("a temp dir"); + let busy = ["alpha "; 12].concat(); + let many: Vec<&str> = (0..50).map(|_| busy.as_str()).collect(); + let pages: &[(f32, f32, &[&str])] = &[(1000.0, 1400.0, &many), (612.0, 792.0, &["hello", "world"])]; + let (mut worker, doc) = opened(dir.path(), &pdf(pages, &[])); + assert_eq!(worker.search(doc, 0, "alpha").expect("searches").len(), 600, "no cap on hits"); + let split = worker.search(doc, 1, "hello world").expect("searches"); + assert_eq!(split.len(), 1, "one phrase over two lines"); + assert_eq!(split[0].len(), 2, "with a quad on each line"); +} + +#[cfg(unix)] +#[test] +fn a_worker_from_other_sources_is_refused() { + let dir = tempfile::tempdir().expect("a temp dir"); + // Answers HELLO with someone else's stamp. + let stale = dir.path().join("stale-worker"); + std::fs::write( + &stale, + "#!/usr/bin/env python3\nimport struct, sys\nhead = sys.stdin.buffer.read(13)\nstamp = b'old'\nbody = struct.pack('> 1) } else { crc >> 1 }; + } + } + !crc + }; + let chunk = |kind: &[u8], data: &[u8]| { + let body = [kind, data].concat(); + [&(data.len() as u32).to_be_bytes()[..], &body, &crc(&body).to_be_bytes()].concat() + }; + let mut raw = Vec::new(); + let mut seed = 7u32; + for _ in 0..side { + raw.push(0); + for _ in 0..side * 3 { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + raw.push((seed >> 16) as u8); + } + } + // zlib with stored blocks; Adler-32 at the end. + let mut zlib = vec![0x78, 0x01]; + let blocks: Vec<&[u8]> = raw.chunks(65_535).collect(); + for (n, block) in blocks.iter().enumerate() { + zlib.push(u8::from(n + 1 == blocks.len())); + let len = block.len() as u16; + zlib.extend_from_slice(&len.to_le_bytes()); + zlib.extend_from_slice(&(!len).to_le_bytes()); + zlib.extend_from_slice(block); + } + let (mut a, mut b) = (1u32, 0u32); + for byte in &raw { + a = (a + u32::from(*byte)) % 65_521; + b = (b + a) % 65_521; + } + zlib.extend_from_slice(&((b << 16) | a).to_be_bytes()); + let header = [&side.to_be_bytes()[..], &side.to_be_bytes(), &[8, 2, 0, 0, 0]].concat(); + let png = [&b"\x89PNG\r\n\x1a\n"[..], &chunk(b"IHDR", &header), &chunk(b"IDAT", &zlib), &chunk(b"IEND", &[])].concat(); + std::fs::write(path, png).expect("writes"); +} + +#[cfg(unix)] +#[test] +fn a_failed_open_keeps_no_memory() { + // An image MuPDF opens, then refuses to journal. + let dir = tempfile::tempdir().expect("a temp dir"); + let png = dir.path().join("big.png"); + big_png(&png, 1200); + let mut worker = Worker::spawn().expect("a worker"); + let rss = |pid: u32| -> u64 { + let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid.to_string()]).output().expect("ps"); + String::from_utf8_lossy(&out.stdout).trim().parse().expect("kilobytes") + }; + // MuPDF's 256 MB store fills first; then nothing grows. + for _ in 0..70 { + let refused = worker.open(&png).err().expect("not a PDF"); + assert!(refused.to_string().contains("not a PDF"), "{refused}"); + } + let before = rss(worker.pid()); + for _ in 0..50 { + assert!(worker.open(&png).is_err(), "not a PDF"); + } + let grown = rss(worker.pid()).saturating_sub(before); + assert!(grown < 40_000, "50 more failed opens of a 4 MB image kept {grown} KiB"); +} + +#[test] +fn a_document_belongs_to_its_worker() { + let first = tempfile::tempdir().expect("a temp dir"); + let (old_worker, old) = opened(first.path(), &pdf(&[(300.0, 500.0, &["A"])], &[])); + drop(old_worker); + let second = tempfile::tempdir().expect("a temp dir"); + let (mut worker, doc) = opened(second.path(), &pdf(&[(300.0, 500.0, &["B"])], &[])); + // An old worker's handle never reaches this one's documents. + assert!(worker.close(old).is_err(), "another worker's document closed"); + assert!(worker.pages(old).is_err(), "another worker's document answered"); + assert_eq!(worker.pages(doc).expect("its own document"), 1); +} + +#[test] +fn an_edit_made_for_an_older_state_is_refused() { + let dir = tempfile::tempdir().expect("a temp dir"); + let pages: &[(f32, f32, &[&str])] = &[(300.0, 500.0, &["A"]), (300.0, 500.0, &["B"]), (300.0, 500.0, &["C"])]; + let (mut worker, before) = opened(dir.path(), &pdf(pages, &[])); + let mark = worker.delete_pages(before, &[0]).expect("deletes A"); + let after = before.at(&mark).expect("the new state"); + // Page 1 was B, now C; the old aim fails. + let stale = worker.rotate_pages(before, &[1], 90).expect_err("an older state"); + assert!(ely_pdf_engine::is_stale(&stale), "{stale}"); + assert!(worker.bounds(before, 0, 1).is_err(), "a read for an older state"); + assert_eq!(worker.bounds(after, 1, 1).expect("C")[0], [0.0, 0.0, 300.0, 500.0], "C stayed upright"); + worker.rotate_pages(after, &[1], 90).expect("rotates C"); + // Whole-document requests take any state. + assert_eq!(worker.pages(before).expect("pages"), 2); +} diff --git a/crates/ely-platform/Cargo.toml b/crates/ely-platform/Cargo.toml new file mode 100644 index 0000000..d5d040a --- /dev/null +++ b/crates/ely-platform/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "ely-platform" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +arboard.workspace = true +directories.workspace = true +env_logger.workspace = true +image.workspace = true +log.workspace = true +semver.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +ureq.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +metal.workspace = true +core-foundation.workspace = true +core-video.workspace = true +objc2-app-kit.workspace = true +objc2-av-foundation.workspace = true +objc2-core-foundation.workspace = true +objc2-core-media.workspace = true +objc2-core-video.workspace = true +objc2-foundation.workspace = true +objc2-pdf-kit.workspace = true +objc2.workspace = true +security-framework.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys.workspace = true + +[target.'cfg(unix)'.dependencies] +libc.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +rfd = { workspace = true, features = ["xdg-portal"] } + +[target.'cfg(not(target_os = "linux"))'.dependencies] +rfd.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[target.'cfg(target_os = "macos")'.dev-dependencies] +objc2-foundation.workspace = true + +[lints] +workspace = true + +[[test]] +name = "media" +harness = false diff --git a/crates/ely-platform/src/accessibility.rs b/crates/ely-platform/src/accessibility.rs new file mode 100644 index 0000000..e0600fd --- /dev/null +++ b/crates/ely-platform/src/accessibility.rs @@ -0,0 +1,94 @@ +use crate::{env, settings::Motion}; + +/// `ELY_REDUCE_MOTION` wins, then the setting, then the OS. +pub fn reduce_motion(setting: Motion) -> bool { + let (env, system) = (env::flag("ELY_REDUCE_MOTION"), os_reduce_motion()); + if env.is_none() && setting == Motion::System && system.is_none() { + log::info!("accessibility: no reduce-motion setting found, using full motion"); + } + reduce_motion_from(env, setting, system) +} + +fn reduce_motion_from(env: Option, setting: Motion, system: Option) -> bool { + let set = match setting { + Motion::System => None, + Motion::Reduced => Some(true), + Motion::Full => Some(false), + }; + env.or(set).or(system).unwrap_or(false) +} + +#[cfg(target_os = "macos")] +fn os_reduce_motion() -> Option { + Some(objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion()) +} + +#[cfg(target_os = "linux")] +fn os_reduce_motion() -> Option { + let output = std::process::Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "enable-animations"]) + .output() + .map_err(|error| log::debug!("accessibility: gsettings did not run: {error}")) + .ok()?; + if !output.status.success() { + log::debug!("accessibility: gsettings has no enable-animations key"); + return None; + } + animations_enabled(&String::from_utf8_lossy(&output.stdout)).map(|enabled| !enabled) +} + +#[cfg(windows)] +fn os_reduce_motion() -> Option { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + SPI_GETCLIENTAREAANIMATION, SystemParametersInfoW, + }; + + let mut enabled: i32 = 1; + // SAFETY: a live i32, the size the call expects. + let read = unsafe { + SystemParametersInfoW( + SPI_GETCLIENTAREAANIMATION, + 0, + (&mut enabled as *mut i32).cast(), + 0, + ) + }; + (read != 0).then_some(enabled == 0) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] +fn os_reduce_motion() -> Option { + None +} + +#[cfg(any(target_os = "linux", test))] +fn animations_enabled(gsettings: &str) -> Option { + match gsettings.trim() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::{Motion, animations_enabled, reduce_motion_from}; + + #[test] + fn the_env_wins_then_the_setting_then_the_os_then_full_motion() { + assert!(reduce_motion_from(Some(true), Motion::Full, Some(false))); + assert!(!reduce_motion_from(Some(false), Motion::Reduced, Some(true))); + assert!(reduce_motion_from(None, Motion::Reduced, Some(false))); + assert!(!reduce_motion_from(None, Motion::Full, Some(true))); + assert!(reduce_motion_from(None, Motion::System, Some(true))); + assert!(!reduce_motion_from(None, Motion::System, None)); + } + + #[test] + fn gsettings_answers_parse_and_anything_else_is_no_answer() { + assert_eq!(animations_enabled("true\n"), Some(true)); + assert_eq!(animations_enabled("false\n"), Some(false)); + assert_eq!(animations_enabled("uint32 7\n"), None); + assert_eq!(animations_enabled(""), None); + } +} diff --git a/crates/ely-platform/src/app_id.rs b/crates/ely-platform/src/app_id.rs new file mode 100644 index 0000000..bdc24d4 --- /dev/null +++ b/crates/ely-platform/src/app_id.rs @@ -0,0 +1,101 @@ +/// The eight apps, in PRD order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AppId { + Doc, + Table, + Show, + Pdf, + Mail, + Tree, + Mark, + Agent, +} + +impl AppId { + pub const ALL: [AppId; 8] = [ + AppId::Doc, + AppId::Table, + AppId::Show, + AppId::Pdf, + AppId::Mail, + AppId::Tree, + AppId::Mark, + AppId::Agent, + ]; + + /// The macOS bundle identifier `packaging/macos/bundle.sh` writes. + pub fn bundle_id(self) -> String { + format!("ely.zacharyzhang.office.{}", &self.slug()["ely-".len()..]) + } + + pub fn name(self) -> &'static str { + match self { + AppId::Doc => "ElyDoc", + AppId::Table => "ElyTable", + AppId::Show => "ElyShow", + AppId::Pdf => "ElyPDF", + AppId::Mail => "ElyMail", + AppId::Tree => "ElyTree", + AppId::Mark => "ElyMark", + AppId::Agent => "ElyAgent", + } + } + + pub fn slug(self) -> &'static str { + match self { + AppId::Doc => "ely-doc", + AppId::Table => "ely-table", + AppId::Show => "ely-show", + AppId::Pdf => "ely-pdf", + AppId::Mail => "ely-mail", + AppId::Tree => "ely-tree", + AppId::Mark => "ely-mark", + AppId::Agent => "ely-agent", + } + } +} + +#[cfg(test)] +mod tests { + use super::AppId::{self, *}; + + const IN_PRD_ORDER: [AppId; 8] = AppId::ALL; + + #[test] + fn bundle_ids_match_the_packaging() { + assert_eq!(Doc.bundle_id(), "ely.zacharyzhang.office.doc"); + let script = include_str!("../../../packaging/macos/bundle.sh"); + assert!(script.contains("prefix=ely.zacharyzhang.office\n")); + assert!(script.contains("$prefix.${slug#ely-}")); + } + + #[test] + fn names_follow_the_prd() { + let names: Vec<_> = IN_PRD_ORDER.iter().map(|app| app.name()).collect(); + assert_eq!( + names, + [ + "ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyMail", "ElyTree", "ElyMark", + "ElyAgent" + ] + ); + } + + #[test] + fn slugs_are_the_binary_names() { + let slugs: Vec<_> = IN_PRD_ORDER.iter().map(|app| app.slug()).collect(); + assert_eq!( + slugs, + [ + "ely-doc", + "ely-table", + "ely-show", + "ely-pdf", + "ely-mail", + "ely-tree", + "ely-mark", + "ely-agent" + ] + ); + } +} diff --git a/crates/ely-platform/src/apps.rs b/crates/ely-platform/src/apps.rs new file mode 100644 index 0000000..2dbb45e --- /dev/null +++ b/crates/ely-platform/src/apps.rs @@ -0,0 +1,189 @@ +//! Starting another Ely app installed beside this one. + +use std::{ + io, + path::{Path, PathBuf}, + process::Command, +}; + +use crate::AppId; + +/// Where `app` sits for an install laid out like `exe`'s. +fn beside(exe: &Path, app: AppId) -> Vec { + let file = format!("{}{}", app.slug(), std::env::consts::EXE_SUFFIX); + let Some(dir) = exe.parent() else { + return Vec::new(); + }; + let mut places = vec![dir.join(&file)]; + // macOS: Name.app/Contents/MacOS; Windows: Ely Office\Name. + if let Some(bundle) = dir.parent().and_then(Path::parent) { + let app_dir = format!("{}.app", app.name()); + if let Some(apps) = bundle.parent() { + places.push(apps.join(app_dir).join("Contents/MacOS").join(&file)); + } + } + if let Some(suite) = dir.parent() { + places.push(suite.join(app.name()).join(&file)); + } + places +} + +/// `app`'s binary: beside this one, else wherever macOS has it. +fn find(app: AppId) -> io::Result> { + let exe = std::env::current_exe()?; + let near = beside(&exe, app).into_iter().find(|place| place.is_file()); + Ok(near.or_else(|| registered(app))) +} + +#[cfg(target_os = "macos")] +fn registered(app: AppId) -> Option { + use objc2_app_kit::NSWorkspace; + use objc2_foundation::NSString; + let id = NSString::from_str(&app.bundle_id()); + let url = NSWorkspace::sharedWorkspace().URLForApplicationWithBundleIdentifier(&id)?; + let bundle = PathBuf::from(url.path()?.to_string()); + let binary = bundle.join("Contents/MacOS").join(app.slug()); + binary.is_file().then_some(binary) +} + +#[cfg(not(target_os = "macos"))] +fn registered(_: AppId) -> Option { + None +} + +/// Whether `app` can be started from here. +pub fn installed(app: AppId) -> bool { + matches!(find(app), Ok(Some(_))) +} + +/// The `.app` a macOS binary sits in. +fn bundle_of(binary: &Path) -> Option<&Path> { + let bundle = binary.parent()?.parent()?.parent()?; + (cfg!(target_os = "macos") && bundle.extension().is_some_and(|ext| ext == "app")).then_some(bundle) +} + +/// Starts `app` with `files`; a running one takes them. +pub fn open(app: AppId, files: &[PathBuf]) -> io::Result<()> { + let found = find(app)?.ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, format!("{} is not installed", app.name())) + })?; + // Launch Services hands files to a running app. + if let Some(bundle) = bundle_of(&found) { + let out = Command::new("open").arg("-a").arg(bundle).args(files).output()?; + if !out.status.success() { + let said = String::from_utf8_lossy(&out.stderr).trim().to_owned(); + return Err(io::Error::other(format!("{}: {said}", app.name()))); + } + log::info!("apps: opened {} with {} files", app.name(), files.len()); + return Ok(()); + } + let mut command = Command::new(&found); + command.args(files); + let child = fresh_signals(&mut command).spawn()?; + log::info!("apps: started {} as {}", app.name(), child.id()); + Ok(()) +} + +/// The started app takes signals as from a shell. +#[cfg(unix)] +fn fresh_signals(command: &mut Command) -> &mut Command { + use std::os::unix::process::CommandExt as _; + // Chromium's ignored or blocked signals would carry over. + unsafe { + command.pre_exec(|| { + for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP, libc::SIGQUIT] { + libc::signal(signal, libc::SIG_DFL); + } + let mut none: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut none); + libc::pthread_sigmask(libc::SIG_SETMASK, &none, std::ptr::null_mut()); + Ok(()) + }) + } +} + +#[cfg(not(unix))] +fn fresh_signals(command: &mut Command) -> &mut Command { + command +} + +/// Opens `path` in the default app; a refusal says why. +pub fn open_default(path: &Path) -> io::Result<()> { + let mut command = if cfg!(target_os = "macos") { + Command::new("open") + } else if cfg!(windows) { + let mut start = Command::new("cmd"); + start.args(["/C", "start", ""]); + start + } else { + Command::new("xdg-open") + }; + let out = command.arg(path).output()?; + if !out.status.success() { + let said = String::from_utf8_lossy(&out.stderr).trim().to_owned(); + return Err(io::Error::other(said)); + } + log::info!("apps: the system opened an output"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use crate::AppId; + + #[cfg(target_os = "macos")] + #[test] + fn a_missing_output_says_so() { + let dir = tempfile::tempdir().expect("a temp dir"); + let error = super::open_default(&dir.path().join("gone.txt")).expect_err("no file"); + assert!(error.to_string().contains("does not exist"), "{error}"); + } + + #[test] + fn each_install_layout_names_its_sibling() { + let suffix = std::env::consts::EXE_SUFFIX; + let places = super::beside( + Path::new("/Apps/ElyTree.app/Contents/MacOS/ely-tree"), + AppId::Mark, + ); + assert!(places.contains(&PathBuf::from(format!( + "/Apps/ElyMark.app/Contents/MacOS/ely-mark{suffix}" + )))); + let places = super::beside(Path::new("/target/debug/ely-tree"), AppId::Mark); + assert_eq!( + places[0], + PathBuf::from(format!("/target/debug/ely-mark{suffix}")) + ); + let places = super::beside(Path::new("/Ely Office/ElyTree/ely-tree.exe"), AppId::Mark); + assert!(places.contains(&PathBuf::from(format!( + "/Ely Office/ElyMark/ely-mark{suffix}" + )))); + } +} + +#[cfg(all(test, unix))] +mod signals { + use std::os::unix::process::ExitStatusExt as _; + + /// A starter that ignores and blocks SIGTERM, as Chromium may. + #[test] + fn a_started_app_still_stops_on_sigterm() { + unsafe { + libc::signal(libc::SIGTERM, libc::SIG_IGN); + let mut term: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut term); + libc::sigaddset(&mut term, libc::SIGTERM); + libc::pthread_sigmask(libc::SIG_BLOCK, &term, std::ptr::null_mut()); + } + let mut command = std::process::Command::new("/bin/sleep"); + command.arg("30"); + let mut child = super::fresh_signals(&mut command).spawn().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(200)); + unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }; + let status = child.wait().unwrap(); + unsafe { libc::signal(libc::SIGTERM, libc::SIG_DFL) }; + assert_eq!(status.signal(), Some(libc::SIGTERM), "{status:?}"); + } +} diff --git a/crates/ely-platform/src/clipboard.rs b/crates/ely-platform/src/clipboard.rs new file mode 100644 index 0000000..7161bc6 --- /dev/null +++ b/crates/ely-platform/src/clipboard.rs @@ -0,0 +1,130 @@ +//! The system clipboard: text, HTML and PNG images (LOK-06). + +use std::sync::Mutex; + +use anyhow::{Context, Result}; +use arboard::{Clipboard, Error, ImageData}; + +/// One clipboard payload. +#[derive(Clone, Debug, PartialEq)] +pub enum Clip { + Text(String), + /// Rich text or tables, with a plain fallback. + Html { + html: String, + text: String, + }, + Png(Vec), +} + +/// One handle for the process; Linux serves from it. +static BOARD: Mutex> = Mutex::new(None); + +fn with_board(call: impl FnOnce(&mut Clipboard) -> Result) -> Result { + let mut board = BOARD.lock().expect("the clipboard lock"); + if board.is_none() { + *board = Some(Clipboard::new().context("the system clipboard opens")?); + } + call(board.as_mut().expect("opened above")) +} + +/// Puts `clip` on the system clipboard. +pub fn write(clip: &Clip) -> Result<()> { + with_board(|board| { + match clip { + Clip::Text(text) => board.set_text(text.as_str())?, + Clip::Html { html, text } => board.set_html(html.as_str(), Some(text.as_str()))?, + Clip::Png(png) => { + let image = image::load_from_memory(png) + .context("the copied png decodes")? + .to_rgba8(); + board.set_image(ImageData { + width: image.width() as usize, + height: image.height() as usize, + bytes: image.into_raw().into(), + })? + } + } + log::info!("clipboard: wrote {}", kind(clip)); + Ok(()) + }) +} + +/// The richest payload on the clipboard: HTML, image, then text. +pub fn read() -> Result> { + with_board(|board| { + let text = absent_ok(board.get_text())?; + if let Some(html) = absent_ok(board.get().html())? { + let text = text.unwrap_or_default(); + return Ok(Some(Clip::Html { html, text })); + } + if let Some(image) = absent_ok(board.get_image())? { + let rgba = image::RgbaImage::from_raw( + image.width as u32, + image.height as u32, + image.bytes.into_owned(), + ) + .context("the clipboard image has its size")?; + let mut png = Vec::new(); + rgba.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .context("the clipboard image encodes")?; + return Ok(Some(Clip::Png(png))); + } + Ok(text.map(Clip::Text)) + }) +} + +/// Empty or other-format clipboards read as None. +fn absent_ok(outcome: Result) -> Result> { + match outcome { + Ok(value) => Ok(Some(value)), + Err(Error::ContentNotAvailable) => Ok(None), + Err(error) => Err(error).context("the clipboard reads"), + } +} + +fn kind(clip: &Clip) -> &'static str { + match clip { + Clip::Text(_) => "text", + Clip::Html { .. } => "html", + Clip::Png(_) => "png", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Writes then reads the real system clipboard. + #[test] + fn each_kind_round_trips() { + if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() { + eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard"); + return; + } + let text = Clip::Text("plain 中文".into()); + write(&text).expect("text writes"); + assert_eq!(read().expect("text reads"), Some(text)); + let html = Clip::Html { + html: "
a
".into(), + text: "a".into(), + }; + write(&html).expect("html writes"); + let Some(Clip::Html { html: back, text }) = read().expect("html reads") else { + panic!("html comes back as html"); + }; + assert!(back.contains("a"), "the table survives: {back}"); + assert_eq!(text, "a"); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(3, 2, image::Rgba([0, 0, 255, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("the png encodes"); + write(&Clip::Png(png)).expect("png writes"); + let Some(Clip::Png(back)) = read().expect("png reads") else { + panic!("an image comes back as png"); + }; + let back = image::load_from_memory(&back).expect("decodes").to_rgba8(); + assert_eq!((back.width(), back.height()), (3, 2)); + assert_eq!(back.get_pixel(0, 0).0, [0, 0, 255, 255]); + } +} diff --git a/crates/ely-platform/src/diagnostics.rs b/crates/ely-platform/src/diagnostics.rs new file mode 100644 index 0000000..f6f0b27 --- /dev/null +++ b/crates/ely-platform/src/diagnostics.rs @@ -0,0 +1,201 @@ +//! The diagnostics a user may export: shown first, redactable (§18.2). + +use std::{ + io::{self, Read as _, Seek as _, SeekFrom}, + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; + +use crate::AppId; + +/// Only the newest part of a long log goes out. +const LOG_TAIL: u64 = 4 * 1024 * 1024; +const CRASH_AGE: Duration = Duration::from_secs(7 * 24 * 3600); + +/// One file the bundle would carry. +#[derive(Clone, Debug, PartialEq)] +pub struct Part { + pub path: PathBuf, + pub bytes: u64, +} + +/// `app`'s log and its recent crash reports. +pub fn gather(app: AppId) -> io::Result> { + let mut parts = Vec::new(); + let log = crate::data_dir(app).map_err(io::Error::other)?.join("ely.log"); + parts.extend(log_part(log)?); + for dir in crash_dirs() { + parts.extend(crashes_in(&dir, &crashing(app), SystemTime::now())?); + } + Ok(parts) +} + +/// The log's newest part; only a missing log is nothing. +fn log_part(log: PathBuf) -> io::Result> { + match std::fs::metadata(&log) { + Ok(meta) => Ok(Some(Part { bytes: meta.len().min(LOG_TAIL), path: log })), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(io::Error::new(error.kind(), format!("{}: {error}", log.display()))), + } +} + +/// The processes whose crashes belong to `app`. +fn crashing(app: AppId) -> Vec { + let mut names = vec![app.slug().to_string()]; + match app { + AppId::Doc | AppId::Table | AppId::Show => names.push("ely-lok-worker".into()), + AppId::Pdf => names.push("ely-pdf-worker".into()), + _ => {} + } + names +} + +/// Where the system keeps crash reports. +fn crash_dirs() -> Vec { + let home = directories::BaseDirs::new().map(|base| base.home_dir().to_path_buf()); + match (cfg!(target_os = "macos"), home) { + (true, Some(home)) => vec![home.join("Library/Logs/DiagnosticReports")], + _ => Vec::new(), + } +} + +fn crashes_in(dir: &Path, names: &[String], now: SystemTime) -> io::Result> { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error), + }; + let mut found = Vec::new(); + for entry in entries { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + // `ely-doc-2026-10-08-013420.ips`, helpers with their suffix. + let ours = names.iter().any(|own| name.starts_with(&format!("{own}-")) || name.starts_with(&format!("{own} "))); + let meta = entry.metadata()?; + let fresh = meta.modified().is_ok_and(|at| now.duration_since(at).is_ok_and(|age| age < CRASH_AGE)); + if ours && fresh && meta.is_file() { + found.push(Part { path: entry.path(), bytes: meta.len() }); + } + } + found.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(found) +} + +/// Redaction replaces every path, URL and user name. +#[derive(Clone, Debug, Default)] +pub struct Redact { + pub names: Vec, +} + +impl Redact { + /// This account's names and the set author; unreadable ones fail. + pub fn of_account() -> io::Result { + Self::of(crate::user::account(), crate::settings::Shared::load()) + } + + fn of(account: io::Result, shared: io::Result) -> io::Result { + let mut names = vec![account?, shared?.author]; + names.extend(std::env::var("USER").ok()); + names.extend(std::env::var("USERNAME").ok()); + Ok(Self { names }) + } + + pub fn apply(&self, text: &str) -> String { + let mut out: String = text.split_inclusive('\n').map(path_free).collect(); + // Longest first, so short names never split long ones. + let mut names: Vec<&str> = self.names.iter().map(|name| name.trim()).filter(|name| !name.is_empty()).collect(); + names.sort_by_key(|name| std::cmp::Reverse(name.len())); + for name in names { + out = out.replace(name, ""); + } + out + } +} + +/// Paths and URLs become ``, unquoted ones to line end. +fn path_free(line: &str) -> String { + let chars: Vec = line.chars().collect(); + let mut out = String::with_capacity(line.len()); + let mut at = 0; + while at < chars.len() { + let before = at.checked_sub(1).map(|i| chars[i]); + let opens = before.is_none_or(|c| c.is_whitespace() || "\"'([=:<".contains(c)); + if !(opens && starts_path(&chars[at..])) { + out.push(chars[at]); + at += 1; + continue; + } + // Quoted: up to the quote; else the whole line. + let end = match before.filter(|c| *c == '"' || *c == '\'') { + Some(quote) => closing(&chars, at, quote), + None => chars.len() - usize::from(chars.last() == Some(&'\n')), + }; + out.push_str(""); + at = end; + } + out +} + +/// Where `quote` closes; a backslash escapes in double quotes (JSON). +fn closing(chars: &[char], from: usize, quote: char) -> usize { + let mut at = from; + while at < chars.len() { + match chars[at] { + '\\' if quote == '"' => at += 2, + c if c == quote => return at, + _ => at += 1, + } + } + chars.len() +} + +/// `/x`, `~/x`, `C:\x`, `\\host`, or `scheme://`. +fn starts_path(rest: &[char]) -> bool { + let at = |n: usize| rest.get(n).copied(); + let drive = at(0).is_some_and(|c| c.is_ascii_alphabetic()) && at(1) == Some(':') && matches!(at(2), Some('\\' | '/')); + let scheme = { + let letters = rest.iter().take_while(|c| c.is_ascii_alphanumeric() || **c == '+' || **c == '-').count(); + letters > 1 && rest[letters..].starts_with(&[':', '/', '/']) + }; + let rooted = matches!(at(0), Some('/') | Some('\\')) && at(1).is_some_and(|c| !c.is_whitespace()); + rooted || (at(0) == Some('~') && at(1) == Some('/')) || drive || scheme +} + +/// One text file holding every part, redacted when asked. +pub fn bundle(app: AppId, parts: &[Part], redact: Option<&Redact>) -> io::Result { + let mut text = format!( + "{} {} diagnostics\nsystem: {} {}\n", + app.name(), + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH + ); + for part in parts { + let mut file = std::fs::File::open(&part.path)?; + let length = file.metadata()?.len(); + let skipped = length.saturating_sub(part.bytes); + // A cut line may hold half a path: skip it. + let mut before = [b'\n']; + if skipped > 0 { + file.seek(SeekFrom::Start(skipped - 1))?; + file.read_exact(&mut before)?; + } + file.seek(SeekFrom::Start(skipped))?; + let mut body = Vec::new(); + file.take(part.bytes).read_to_end(&mut body)?; + if before[0] != b'\n' { + let whole = body.iter().position(|b| *b == b'\n').map_or(body.len(), |n| n + 1); + body.drain(..whole); + } + let name = part.path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(); + text.push_str(&format!("\n===== {name} =====\n")); + text.push_str(&String::from_utf8_lossy(&body)); + } + Ok(match redact { + Some(redact) => redact.apply(&text), + None => text, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ely-platform/src/diagnostics/tests.rs b/crates/ely-platform/src/diagnostics/tests.rs new file mode 100644 index 0000000..b58b3b6 --- /dev/null +++ b/crates/ely-platform/src/diagnostics/tests.rs @@ -0,0 +1,88 @@ +use std::time::{Duration, SystemTime}; + +use super::{Part, Redact, bundle, crashes_in, log_part}; +use crate::AppId; + +#[test] +fn only_this_apps_recent_crashes_are_picked() { + let dir = tempfile::tempdir().unwrap(); + for name in ["ely-doc-2026-10-08-1.ips", "ely-lok-worker-2026-10-08-2.ips", "ely-docs-x.ips", "Safari-1.ips"] { + std::fs::write(dir.path().join(name), "report").unwrap(); + } + let names = vec!["ely-doc".to_string(), "ely-lok-worker".to_string()]; + let picked = crashes_in(dir.path(), &names, SystemTime::now()).unwrap(); + let files: Vec<_> = picked.iter().map(|part| part.path.file_name().unwrap().to_string_lossy().into_owned()).collect(); + assert_eq!(files, ["ely-doc-2026-10-08-1.ips", "ely-lok-worker-2026-10-08-2.ips"]); + let later = SystemTime::now() + Duration::from_secs(8 * 24 * 3600); + assert!(crashes_in(dir.path(), &names, later).unwrap().is_empty(), "a week old is left out"); +} + +#[test] +fn redaction_hides_every_path_url_and_name() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("ely.log"); + let lines = [ + "opened /Users/ana/Documents/plan.docx for Ana Lee", + "opened /Volumes/ClientSecret/payroll.pdf", + r"saved C:\Users\ana\pay.xlsx", + "linked 'https://intra.example/q3' and (~/notes.md)", + "signed by Ana", + "opened '/Volumes/ClientSecret/Tax Salary Report.pdf' fine", + r"saved C:\Clients\Tax Salary Report.pdf", + "author: 李", + r#"{"usedImages":[{"path":"/Volumes/ClientSecret/Tax \"Wage Sheet\".pdf"}]}"#, + ]; + std::fs::write(&log, lines.join("\n")).unwrap(); + let parts = [Part { path: log.clone(), bytes: 4096 }]; + // The short account name comes before the full author name. + let redact = Redact { names: vec!["Ana".into(), "Ana Lee".into(), "李".into()] }; + let text = bundle(AppId::Doc, &parts, Some(&redact)).unwrap(); + for secret in ["/Users/ana", "ClientSecret", "payroll", r"C:\Users", "intra.example", "notes.md", "Lee", "Ana", "Salary", "Report", "李", "Wage", "Sheet"] { + assert!(!text.contains(secret), "{secret} stays in:\n{text}"); + } + assert!(text.contains("opened '' fine") && text.contains("linked '' and ("), "{text}"); + assert!(!text.contains(&dir.path().display().to_string()), "the header names only the file"); + let kept = bundle(AppId::Doc, &parts, None).unwrap(); + assert!(kept.contains("/Volumes/ClientSecret/payroll.pdf"), "unredacted when not asked"); +} + +#[cfg(unix)] +#[test] +fn an_unreadable_log_is_an_error_not_nothing() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("ely.log"); + assert_eq!(log_part(log.clone()).unwrap(), None, "no log yet is nothing"); + std::os::unix::fs::symlink(&log, &log).unwrap(); + assert!(log_part(log).is_err(), "a log that cannot be read shows"); +} + +#[test] +fn a_cut_log_starts_at_a_whole_line() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("ely.log"); + let first = "opened '/Volumes/ClientSecret/Tax Salary Report.pdf'\n"; + std::fs::write(&log, format!("{first}later line\n")).unwrap(); + // The cut falls inside the path, on its file name. + let bytes = (first.len() - "Tax Salary Report.pdf'\n".len() + "later line\n".len()) as u64; + let part = Part { path: log, bytes: bytes + "Tax Salary Report.pdf'\n".len() as u64 - 10 }; + let text = bundle(AppId::Doc, &[part], Some(&Redact::default())).unwrap(); + assert!(!text.contains("Report") && text.contains("later line"), "{text}"); +} + +#[test] +fn a_long_log_sends_only_its_newest_part() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("ely.log"); + std::fs::write(&log, "old line\nnew line\n").unwrap(); + let text = bundle(AppId::Doc, &[Part { path: log, bytes: 9 }], None).unwrap(); + assert!(text.ends_with("new line\n") && !text.contains("old line"), "{text}"); +} + +#[test] +fn unreadable_names_stop_the_redaction() { + let broken = || Err(std::io::Error::other("settings: author cannot be 3")); + assert!(Redact::of(Ok("Ana".into()), broken()).is_err(), "no names, no export"); + assert!(Redact::of(Err(std::io::Error::other("no account")), Ok(Default::default())).is_err()); + let names = Redact::of(Ok("Ana".into()), Ok(Default::default())).unwrap().names; + assert!(names.contains(&"Ana".to_string())); +} diff --git a/crates/ely-platform/src/dirs.rs b/crates/ely-platform/src/dirs.rs new file mode 100644 index 0000000..92f1f21 --- /dev/null +++ b/crates/ely-platform/src/dirs.rs @@ -0,0 +1,62 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use directories::BaseDirs; + +use crate::AppId; + +/// Everything an app stores lives here; uninstall removes only this. +pub fn data_dir(app: AppId) -> Result { + Ok(root()?.join(app.slug())) +} + +/// The suite's folder: each app's, and the shared settings. +pub(crate) fn root() -> Result { + let base = BaseDirs::new().context("no home directory")?; + Ok(base.data_local_dir().join("ElyOffice")) +} + +/// Omarchy's active theme directory; `ELY_OMARCHY_DIR` overrides it. +pub fn omarchy_current_dir() -> Option { + let home = BaseDirs::new()?.home_dir().to_path_buf(); + omarchy_dir(&home, crate::env::value("ELY_OMARCHY_DIR")) +} + +/// Omarchy keeps its state under `~/.local/state`, whatever `XDG_STATE_HOME` says. +fn omarchy_dir(home: &Path, over: Option) -> Option { + if let Some(dir) = over { + return Some(PathBuf::from(dir)); + } + let current = home.join(".local/state/omarchy/current"); + current.is_dir().then_some(current) +} + +#[cfg(test)] +mod tests { + use super::{data_dir, omarchy_dir}; + use crate::AppId; + + #[test] + fn each_app_has_its_own_directory_under_elyoffice() { + let doc = data_dir(AppId::Doc).unwrap(); + let mail = data_dir(AppId::Mail).unwrap(); + + assert!(doc.ends_with("ElyOffice/ely-doc"), "{}", doc.display()); + assert!(mail.ends_with("ElyOffice/ely-mail"), "{}", mail.display()); + assert_ne!(doc, mail); + } + + #[test] + fn omarchy_is_found_under_home_and_the_override_wins() { + let home = tempfile::tempdir().unwrap(); + assert_eq!(omarchy_dir(home.path(), None), None); + + let current = home.path().join(".local/state/omarchy/current"); + std::fs::create_dir_all(¤t).unwrap(); + assert_eq!(omarchy_dir(home.path(), None), Some(current)); + assert_eq!( + omarchy_dir(home.path(), Some("/elsewhere".into())), + Some("/elsewhere".into()) + ); + } +} diff --git a/crates/ely-platform/src/env.rs b/crates/ely-platform/src/env.rs new file mode 100644 index 0000000..c75e4f8 --- /dev/null +++ b/crates/ely-platform/src/env.rs @@ -0,0 +1,36 @@ +/// An environment variable's text; none when unset. +pub fn value(name: &str) -> Option { + std::env::var_os(name).map(|value| value.to_string_lossy().into_owned()) +} + +/// A 0 or 1 switch; anything else warns. +pub fn flag(name: &str) -> Option { + flag_from(name, value(name).as_deref()) +} + +fn flag_from(name: &str, value: Option<&str>) -> Option { + match value { + None => None, + Some("1") => Some(true), + Some("0") => Some(false), + Some(other) => { + log::warn!("env: {name}={other} is not 0 or 1, ignored"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::flag_from; + + #[test] + fn only_zero_and_one_are_answers() { + assert_eq!(flag_from("X", Some("1")), Some(true)); + assert_eq!(flag_from("X", Some("0")), Some(false)); + assert_eq!(flag_from("X", None), None); + for other in ["", "true", "false", "off", "yes", " 1", "01"] { + assert_eq!(flag_from("X", Some(other)), None, "{other:?}"); + } + } +} diff --git a/crates/ely-platform/src/git.rs b/crates/ely-platform/src/git.rs new file mode 100644 index 0000000..122261c --- /dev/null +++ b/crates/ely-platform/src/git.rs @@ -0,0 +1,129 @@ +//! Ely's Git: where it is and how it runs (§13.1). + +use std::{ + io, + path::{Path, PathBuf}, + process::Command, +}; + +use crate::AppId; + +/// ElyTree's setting naming a system Git; empty is Ely's own. +pub const PROGRAM: &str = "git"; + +/// The oldest system Git counted as verified (user decision). +pub const LEAST: (u32, u32) = (2, 40); + +/// `ELY_GIT`, ElyTree's setting, else Ely's own Git. +pub fn locate() -> io::Result<(PathBuf, bool)> { + if let Some(path) = std::env::var_os("ELY_GIT") { + return Ok((PathBuf::from(path), false)); + } + let set = crate::settings::Store::app(AppId::Tree)?.read()?; + match set.get(PROGRAM) { + None => {} + Some(serde_json::Value::String(path)) if path.is_empty() => {} + Some(serde_json::Value::String(path)) => return Ok((PathBuf::from(path), false)), + Some(other) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("settings: git cannot be {other}"))), + } + let exe = std::env::current_exe()?; + Ok((own(exe.parent().unwrap_or(Path::new("."))), true)) +} + +/// `git version 2.56.0…` as (2, 56); None for anything else. +pub fn parse_version(text: &str) -> Option<(u32, u32)> { + let number = text.trim().strip_prefix("git version ")?; + let mut parts = number.split(|c: char| !c.is_ascii_digit()); + Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?)) +} + +/// What `git --version` says, once it succeeds. +pub fn version_of(path: &Path) -> io::Result<(String, (u32, u32))> { + let output = Command::new(path).arg("--version").output()?; + if !output.status.success() { + let said = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(io::Error::other(format!("{} --version failed ({}): {said}", path.display(), output.status))); + } + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let number = parse_version(&version).ok_or_else(|| io::Error::other(format!("{} is no Git: {version:?}", path.display())))?; + Ok((version, number)) +} + +/// A system Git's version, if 2.40 or later. +pub fn system_version(path: &Path) -> io::Result { + let (version, number) = version_of(path)?; + if number < LEAST { + let message = format!("{version} at {} is older than {}.{}", path.display(), LEAST.0, LEAST.1); + return Err(io::Error::new(io::ErrorKind::Unsupported, message)); + } + Ok(version) +} + +/// Beside `dir`, in its bundle, in ElyTree's, or built. +fn own(dir: &Path) -> PathBuf { + // ElyTree carries Git; the other apps use its copy. + let tree = format!("../../../{}.app/Contents/Resources/git/bin/git", AppId::Tree.name()); + let built = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../engines/git/bin/git"); + [ + dir.join("git/bin/git"), + dir.join("../Resources/git/bin/git"), + dir.join(tree), + ] + .into_iter() + .find(|path| path.is_file()) + .unwrap_or(built) +} + +/// Git at `git` in `dir`, as Ely runs it. +pub fn command(git: &Path, dir: &Path, args: &[&str]) -> Command { + let mut command = Command::new(git); + command + .args(args) + .current_dir(dir) + // Reads never take the index lock from an outside Git. + .env("GIT_OPTIONAL_LOCKS", "0") + // A path is a path, never a pattern. + .env("GIT_LITERAL_PATHSPECS", "1") + .env("GIT_TERMINAL_PROMPT", "0") + // No terminal for an editor; Git's own message stands. + .env("GIT_EDITOR", "true") + .env("GIT_SEQUENCE_EDITOR", "true"); + command +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + #[test] + fn a_failing_version_query_is_no_version() { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempfile::tempdir().unwrap(); + let fake = dir.path().join("git"); + std::fs::write(&fake, "#!/bin/sh\necho 'git version 2.56.0'\necho 'fatal: version query failed' >&2\nexit 7\n").unwrap(); + std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap(); + let error = super::system_version(&fake).expect_err("a failed run"); + assert!(error.to_string().contains("version query failed"), "{error}"); + assert!(super::version_of(&fake).is_err()); + } + + #[test] + fn versions_read_as_major_and_minor() { + assert_eq!(super::parse_version("git version 2.56.0\n"), Some((2, 56))); + assert_eq!(super::parse_version("git version 2.54.0 (Apple Git-157)"), Some((2, 54))); + assert_eq!(super::parse_version("hello"), None); + } + + use super::own; + + #[test] + fn apps_beside_elytree_use_its_git() { + let apps = tempfile::tempdir().expect("a temp dir"); + let mark = apps.path().join("ElyMark.app/Contents/MacOS"); + let git = apps.path().join("ElyTree.app/Contents/Resources/git/bin"); + std::fs::create_dir_all(&mark).expect("ElyMark"); + assert!(own(&mark).ends_with("engines/git/bin/git")); + std::fs::create_dir_all(&git).expect("ElyTree's Git"); + std::fs::write(git.join("git"), "").expect("git"); + assert_eq!(own(&mark), mark.join("../../../ElyTree.app/Contents/Resources/git/bin/git")); + } +} diff --git a/crates/ely-platform/src/graphics.rs b/crates/ely-platform/src/graphics.rs new file mode 100644 index 0000000..d462ca3 --- /dev/null +++ b/crates/ely-platform/src/graphics.rs @@ -0,0 +1,25 @@ +use anyhow::Result; + +/// Fails when no graphics device exists; probed on macOS only. +pub fn check() -> Result<()> { + #[cfg(target_os = "macos")] + { + let devices = metal::Device::all(); + anyhow::ensure!( + !devices.is_empty() || metal::Device::system_default().is_some(), + "no Metal graphics device was found" + ); + log::info!("graphics: {} Metal device(s)", devices.len()); + } + #[cfg(not(target_os = "macos"))] + log::info!("graphics: the backend is not probed on this OS"); + Ok(()) +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + #[test] + fn this_mac_has_a_metal_device() { + super::check().unwrap(); + } +} diff --git a/crates/ely-platform/src/lib.rs b/crates/ely-platform/src/lib.rs new file mode 100644 index 0000000..ed83d9c --- /dev/null +++ b/crates/ely-platform/src/lib.rs @@ -0,0 +1,23 @@ +pub mod accessibility; +mod app_id; +pub mod apps; +pub mod clipboard; +pub mod diagnostics; +mod dirs; +pub mod env; +pub mod git; +pub mod graphics; +pub mod locale; +pub mod lock; +pub mod logging; +pub mod media; +pub mod print; +pub mod settings; +pub mod startup; +pub mod storage; +pub mod trust; +pub mod update; +pub mod user; + +pub use app_id::AppId; +pub use dirs::{data_dir, omarchy_current_dir}; diff --git a/crates/ely-platform/src/locale.rs b/crates/ely-platform/src/locale.rs new file mode 100644 index 0000000..8e2872c --- /dev/null +++ b/crates/ely-platform/src/locale.rs @@ -0,0 +1,116 @@ +use crate::env; + +/// The locale tag: `ELY_LOCALE`, else the OS one. +pub fn system() -> String { + if let Some(tag) = env::value("ELY_LOCALE") { + return tag; + } + platform() +} + +#[cfg(target_os = "macos")] +fn platform() -> String { + use objc2_foundation::NSLocale; + if let Some(tag) = NSLocale::preferredLanguages() + .firstObject() + .map(|tag| tag.to_string()) + && !tag.is_empty() + { + return tag; + } + NSLocale::currentLocale() + .localeIdentifier() + .to_string() + .replace('_', "-") +} + +#[cfg(target_os = "linux")] +fn platform() -> String { + resolve_env(|key| env::value(key)) +} + +#[cfg(target_os = "windows")] +fn platform() -> String { + use windows_sys::Win32::Globalization::GetUserDefaultLocaleName; + let mut buffer = [0u16; 85]; + let length = unsafe { GetUserDefaultLocaleName(buffer.as_mut_ptr(), buffer.len() as i32) }; + if length == 0 { + log::warn!("locale: GetUserDefaultLocaleName failed"); + return "en".into(); + } + String::from_utf16_lossy(&buffer[..length as usize - 1]) +} + +/// First non-empty variable wins; C/POSIX ends as en. +#[cfg(any(target_os = "linux", test))] +fn resolve_env(get: impl Fn(&str) -> Option) -> String { + for key in ["LC_ALL", "LC_MESSAGES", "LANG"] { + let Some(value) = get(key) else { continue }; + if value.is_empty() { + continue; + } + let tag = value + .split(['.', '@']) + .next() + .unwrap_or(&value) + .replace('_', "-"); + return match tag.as_str() { + "C" | "POSIX" => "en".into(), + _ => tag, + }; + } + "en".into() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::resolve_env; + + fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + move |key| map.get(key).cloned() + } + + #[test] + fn the_first_non_empty_variable_wins() { + assert_eq!(resolve_env(env(&[("LANG", "zh_CN.UTF-8")])), "zh-CN"); + assert_eq!( + resolve_env(env(&[ + ("LC_MESSAGES", "fr_FR.UTF-8"), + ("LANG", "zh_CN.UTF-8") + ])), + "fr-FR" + ); + assert_eq!( + resolve_env(env(&[("LC_ALL", ""), ("LANG", "ja_JP.UTF-8")])), + "ja-JP" + ); + } + + #[test] + fn c_and_posix_resolve_to_en_and_stop() { + assert_eq!( + resolve_env(env(&[("LC_ALL", "C.UTF-8"), ("LANG", "zh_CN.UTF-8")])), + "en" + ); + assert_eq!( + resolve_env(env(&[("LC_MESSAGES", "POSIX"), ("LANG", "zh_CN.UTF-8")])), + "en" + ); + assert_eq!( + resolve_env(env(&[("LC_ALL", "C"), ("LC_MESSAGES", "zh_CN.UTF-8")])), + "en" + ); + } + + #[test] + fn nothing_set_resolves_to_en() { + assert_eq!(resolve_env(env(&[])), "en"); + assert_eq!(resolve_env(env(&[("LANG", "")])), "en"); + } +} diff --git a/crates/ely-platform/src/lock.rs b/crates/ely-platform/src/lock.rs new file mode 100644 index 0000000..32796d3 --- /dev/null +++ b/crates/ely-platform/src/lock.rs @@ -0,0 +1,94 @@ +//! An OS-level exclusive file lock, released with its holder. + +use std::{fs::File, io, path::Path}; + +pub struct FileLock(File); + +impl FileLock { + /// The lock, or None while another process holds it. + pub fn try_take(path: &Path) -> io::Result> { + let file = open(path)?; + match lock(&file, false) { + Ok(()) => Ok(Some(Self(file))), + Err(error) if busy(&error) => Ok(None), + Err(error) => Err(error), + } + } + + /// The lock, once another holder lets go. + pub fn take(path: &Path) -> io::Result { + let file = open(path)?; + lock(&file, true)?; + Ok(Self(file)) + } +} + +fn open(path: &Path) -> io::Result { + std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(path) +} + +#[cfg(unix)] +fn lock(file: &File, wait: bool) -> io::Result<()> { + use std::os::unix::io::AsRawFd; + let how = if wait { libc::LOCK_EX } else { libc::LOCK_EX | libc::LOCK_NB }; + match unsafe { libc::flock(file.as_raw_fd(), how) } { + 0 => Ok(()), + _ => Err(io::Error::last_os_error()), + } +} + +#[cfg(unix)] +fn busy(error: &io::Error) -> bool { + error.kind() == io::ErrorKind::WouldBlock +} + +#[cfg(unix)] +impl Drop for FileLock { + fn drop(&mut self) { + use std::os::unix::io::AsRawFd; + unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) }; + } +} + +#[cfg(windows)] +fn lock(file: &File, wait: bool) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx}; + let flags = if wait { LOCKFILE_EXCLUSIVE_LOCK } else { LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY }; + let mut overlapped = unsafe { std::mem::zeroed() }; + match unsafe { LockFileEx(file.as_raw_handle(), flags, 0, u32::MAX, u32::MAX, &mut overlapped) } { + 0 => Err(io::Error::last_os_error()), + _ => Ok(()), + } +} + +#[cfg(windows)] +fn busy(error: &io::Error) -> bool { + const ERROR_LOCK_VIOLATION: i32 = 33; + error.raw_os_error() == Some(ERROR_LOCK_VIOLATION) +} + +#[cfg(windows)] +impl Drop for FileLock { + fn drop(&mut self) { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::UnlockFileEx; + let mut overlapped = unsafe { std::mem::zeroed() }; + unsafe { UnlockFileEx(self.0.as_raw_handle(), 0, u32::MAX, u32::MAX, &mut overlapped) }; + } +} + +#[cfg(test)] +mod tests { + use super::FileLock; + + #[test] + fn a_held_lock_refuses_a_second_try_until_released() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("held.lock"); + let held = FileLock::take(&path).unwrap(); + assert!(FileLock::try_take(&path).unwrap().is_none(), "flock is per open file"); + drop(held); + assert!(FileLock::try_take(&path).unwrap().is_some()); + } +} diff --git a/crates/ely-platform/src/logging.rs b/crates/ely-platform/src/logging.rs new file mode 100644 index 0000000..c877fc5 --- /dev/null +++ b/crates/ely-platform/src/logging.rs @@ -0,0 +1,98 @@ +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Write}, + panic, + path::{Path, PathBuf}, + sync::OnceLock, +}; + +use anyhow::{Context, Result}; +use env_logger::{Builder, Env, Target}; + +use crate::{AppId, data_dir}; + +static LOG_PATH: OnceLock = OnceLock::new(); + +struct Tee(File); + +impl Write for Tee { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.write_all(buf)?; + io::stderr().write_all(buf)?; + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.0.flush()?; + io::stderr().flush() + } +} + +/// The file logging writes to; none until `init` succeeds. +pub fn path() -> Option<&'static Path> { + LOG_PATH.get().map(PathBuf::as_path) +} + +/// Past this a log keeps only its newer half (§18.3). +const LOG_CAP: u64 = 8 * 1024 * 1024; + +/// Keeps a long log's newer half, from a whole line. +fn trimmed(path: &Path, cap: u64) -> std::io::Result<()> { + let length = match fs::metadata(path) { + Ok(meta) => meta.len(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + if length <= cap { + return Ok(()); + } + let bytes = fs::read(path)?; + let tail = &bytes[(length - cap / 2) as usize..]; + let whole = tail.iter().position(|b| *b == b'\n').map_or(tail.len(), |n| n + 1); + fs::write(path, &tail[whole..]) +} + +/// Logs to stderr and a file. `ELY_LOG` sets level. +pub fn init(app: AppId) -> Result<()> { + init_in(app, &data_dir(app)?) +} + +pub fn init_in(app: AppId, dir: &Path) -> Result<()> { + fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?; + let path = dir.join("ely.log"); + trimmed(&path, LOG_CAP).with_context(|| format!("cannot trim {}", path.display()))?; + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("cannot open {}", path.display()))?; + Builder::from_env(Env::new().filter_or("ELY_LOG", "info")) + .target(Target::Pipe(Box::new(Tee(file)))) + .try_init() + .context("a logger is already set")?; + LOG_PATH.set(path).expect("the logger was set once"); + + let previous = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + log::error!("panic: {info}"); + previous(info); + })); + log::info!("{} {} started", app.name(), env!("CARGO_PKG_VERSION")); + Ok(()) +} + +#[cfg(test)] +mod trim_tests { + #[test] + fn a_long_log_keeps_its_newer_half_from_a_line() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ely.log"); + let lines: String = (0..200).map(|n| format!("line {n:03}\n")).collect(); + std::fs::write(&path, &lines).unwrap(); + super::trimmed(&path, 1000).unwrap(); + let kept = std::fs::read_to_string(&path).unwrap(); + assert!(kept.len() <= 500 && kept.starts_with("line ") && kept.ends_with("line 199\n"), "{kept}"); + super::trimmed(&path, 1000).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), kept, "a short log stays"); + } +} diff --git a/crates/ely-platform/src/media.rs b/crates/ely-platform/src/media.rs new file mode 100644 index 0000000..88489d7 --- /dev/null +++ b/crates/ely-platform/src/media.rs @@ -0,0 +1,142 @@ +//! Plays local audio and video with the system's player (SHOW-10). + +use std::path::Path; + +#[cfg(target_os = "macos")] +mod macos; + +/// What the player knows of its file. +#[derive(Clone, Debug, PartialEq)] +pub enum State { + Loading, + /// Seconds long, and the picture size of a video. + Ready { + seconds: f64, + video: Option<(u32, u32)>, + }, + /// The system cannot play it; the reason is its own. + Failed(String), +} + +/// A picture of the playing video. +#[cfg(target_os = "macos")] +pub type Frame = core_video::pixel_buffer::CVPixelBuffer; +/// No platform here makes frames. +#[cfg(not(target_os = "macos"))] +pub enum Frame {} + +/// One file in the system player, paused at first. +pub struct Player { + /// Paused by a person, not at its end. + held: std::cell::Cell, + #[cfg(target_os = "macos")] + inner: macos::Player, + // Nothing opens a player where none is built. + #[cfg(not(target_os = "macos"))] + inner: std::convert::Infallible, +} + +impl Player { + /// Opens `path`; a file that is not there fails here. + pub fn open(path: &Path) -> anyhow::Result { + if !path.is_file() { + anyhow::bail!("{} is not there", path.display()); + } + log::info!("media: opens a file"); + #[cfg(target_os = "macos")] + return Ok(Player { + held: std::cell::Cell::new(false), + inner: macos::Player::open(path)?, + }); + #[cfg(not(target_os = "macos"))] + anyhow::bail!("playing {} is not built on this platform", path.display()) + } + + pub fn state(&self) -> State { + self.inner.state() + } + + pub fn play(&self) { + self.held.set(false); + self.inner.play() + } + + pub fn pause(&self) { + self.held.set(true); + self.inner.pause() + } + + pub fn playing(&self) -> bool { + self.inner.playing() + } + + /// Where it is, in seconds. + pub fn position(&self) -> f64 { + self.inner.position() + } + + pub fn seek(&self, seconds: f64) { + self.inner.seek(seconds) + } + + /// The picture due now, when a new one is. + pub fn frame(&self) -> Option { + self.inner.frame() + } + + /// It stopped at its end, not by a pause. + pub fn ended(&self) -> bool { + matches!(self.state(), State::Ready { seconds, .. } + if !self.held.get() && !self.playing() && self.position() >= seconds - 0.05) + } + + /// Restarts a loop; true while frames change. + pub fn tick(&self, repeat: bool) -> Result { + if repeat && self.ended() { + self.seek(0.0); + self.play(); + } + match self.state() { + State::Loading => Ok(true), + State::Ready { .. } => Ok(self.playing()), + State::Failed(reason) => Err(reason), + } + } +} + +/// What an uninhabited player answers: nothing, ever. +#[cfg(not(target_os = "macos"))] +trait Never { + fn state(&self) -> State; + fn play(&self); + fn pause(&self); + fn playing(&self) -> bool; + fn position(&self) -> f64; + fn seek(&self, seconds: f64); + fn frame(&self) -> Option; +} + +#[cfg(not(target_os = "macos"))] +impl Never for std::convert::Infallible { + fn state(&self) -> State { + match *self {} + } + fn play(&self) { + match *self {} + } + fn pause(&self) { + match *self {} + } + fn playing(&self) -> bool { + match *self {} + } + fn position(&self) -> f64 { + match *self {} + } + fn seek(&self, _: f64) { + match *self {} + } + fn frame(&self) -> Option { + match *self {} + } +} diff --git a/crates/ely-platform/src/media/macos.rs b/crates/ely-platform/src/media/macos.rs new file mode 100644 index 0000000..cb02519 --- /dev/null +++ b/crates/ely-platform/src/media/macos.rs @@ -0,0 +1,126 @@ +use std::path::Path; + +use objc2::{AllocAnyThread, MainThreadMarker, rc::Retained, runtime::AnyObject}; +use objc2_av_foundation::{ + AVPlayer, AVPlayerItem, AVPlayerItemOutput, AVPlayerItemStatus, AVPlayerItemVideoOutput, +}; +use objc2_core_media::{CMTime, CMTimeFlags}; +use objc2_core_video::kCVPixelBufferPixelFormatTypeKey; +use objc2_foundation::{NSDictionary, NSNumber, NSString, NSURL}; + +use super::State; + +/// Biplanar full-range YCbCr, as GPUI's surfaces draw. +const PIXELS: u32 = u32::from_be_bytes(*b"420f"); + +pub struct Player { + player: Retained, + item: Retained, + output: Retained, +} + +fn seconds(time: CMTime) -> Option { + (time.flags.contains(CMTimeFlags::Valid) && !time.flags.contains(CMTimeFlags::Indefinite)) + .then(|| time.value as f64 / time.timescale as f64) +} + +impl Player { + pub fn open(path: &Path) -> anyhow::Result { + let mtm = MainThreadMarker::new() + .ok_or_else(|| anyhow::anyhow!("media plays on the main thread"))?; + let url = NSURL::fileURLWithPath(&NSString::from_str(&path.to_string_lossy())); + let player = unsafe { AVPlayer::playerWithURL(&url, mtm) }; + let item = unsafe { player.currentItem() } + .ok_or_else(|| anyhow::anyhow!("no player item for {}", path.display()))?; + // The key is a CFString; NSString is its toll-free twin. + let key: &NSString = + unsafe { &*(kCVPixelBufferPixelFormatTypeKey as *const _ as *const NSString) }; + let format = NSNumber::new_u32(PIXELS); + let value: &AnyObject = &format; + let attributes = NSDictionary::::from_slices(&[key], &[value]); + let output = unsafe { + AVPlayerItemVideoOutput::initWithPixelBufferAttributes( + AVPlayerItemVideoOutput::alloc(), + Some(&attributes), + ) + }; + unsafe { item.addOutput(&output as &AVPlayerItemOutput) }; + Ok(Player { + player, + item, + output, + }) + } + + pub fn state(&self) -> State { + match unsafe { self.item.status() } { + AVPlayerItemStatus::ReadyToPlay => { + let size = unsafe { self.item.presentationSize() }; + let video = (size.width > 0.0 && size.height > 0.0) + .then(|| (size.width.round() as u32, size.height.round() as u32)); + State::Ready { + seconds: seconds(unsafe { self.item.duration() }).unwrap_or(0.0), + video, + } + } + AVPlayerItemStatus::Failed => { + let reason = unsafe { self.item.error() } + .map(|error| error.localizedDescription().to_string()) + .unwrap_or_else(|| "the system player gave no reason".into()); + State::Failed(reason) + } + _ => State::Loading, + } + } + + pub fn play(&self) { + unsafe { self.player.play() } + } + + pub fn pause(&self) { + unsafe { self.player.pause() } + } + + pub fn playing(&self) -> bool { + (unsafe { self.player.rate() }) > 0.0 + } + + pub fn position(&self) -> f64 { + seconds(unsafe { self.player.currentTime() }).unwrap_or(0.0) + } + + pub fn seek(&self, to: f64) { + let time = CMTime { + value: (to * 600.0).round() as i64, + timescale: 600, + flags: CMTimeFlags::Valid, + epoch: 0, + }; + let exact = CMTime { + value: 0, + timescale: 1, + flags: CMTimeFlags::Valid, + epoch: 0, + }; + unsafe { + self.player + .seekToTime_toleranceBefore_toleranceAfter(time, exact, exact) + } + } + + pub fn frame(&self) -> Option { + let now = unsafe { self.item.currentTime() }; + if !unsafe { self.output.hasNewPixelBufferForItemTime(now) } { + return None; + } + let buffer = unsafe { + self.output + .copyPixelBufferForItemTime_itemTimeForDisplay(now, std::ptr::null_mut()) + }?; + let raw = Retained::into_raw(buffer); + // The copy hands its one reference over. + Some(unsafe { + ::wrap_under_create_rule(raw.cast()) + }) + } +} diff --git a/crates/ely-platform/src/print.rs b/crates/ely-platform/src/print.rs new file mode 100644 index 0000000..91a5eae --- /dev/null +++ b/crates/ely-platform/src/print.rs @@ -0,0 +1,104 @@ +//! Prints a PDF through the system print panel. + +use std::path::Path; + +/// Shows the print panel for `pdf`; false when cancelled. +#[cfg(target_os = "macos")] +pub fn print_pdf(pdf: &Path) -> anyhow::Result { + use objc2::{AllocAnyThread, MainThreadMarker}; + use objc2_app_kit::{NSPaperOrientation, NSPrintInfo, NSPrintJobSavingURL, NSPrintSaveJob}; + use objc2_foundation::{NSCopying, NSSize, NSString, NSURL}; + use objc2_pdf_kit::{PDFDisplayBox, PDFDocument, PDFPrintScalingMode}; + + let mtm = + MainThreadMarker::new().ok_or_else(|| anyhow::anyhow!("printing needs the main thread"))?; + let url = NSURL::fileURLWithPath(&NSString::from_str(&pdf.to_string_lossy())); + let document = unsafe { PDFDocument::initWithURL(PDFDocument::alloc(), &url) } + .ok_or_else(|| anyhow::anyhow!("{} is no readable PDF", pdf.display()))?; + let sizes: Vec<(f64, f64)> = (0..unsafe { document.pageCount() }) + .filter_map(|n| unsafe { document.pageAtIndex(n) }) + .map(|page| { + let size = unsafe { page.boundsForBox(PDFDisplayBox::MediaBox) }.size; + // A page turned a quarter shows its other side up. + match unsafe { page.rotation() }.rem_euclid(180) == 90 { + true => (size.height, size.width), + false => (size.width, size.height), + } + }) + .collect(); + let (width, height) = paper_for(&sizes).ok_or_else(|| anyhow::anyhow!("{} has no pages", pdf.display()))?; + let paper = NSSize::new(width, height); + if let Some(scale) = crate::env::value("ELY_PRINT_SHARED_SCALE") { + // Tests start from a shared scale the copy must undo. + NSPrintInfo::sharedPrintInfo().setScalingFactor(scale.parse()?); + } + // The PDF already holds the document's paper, margins and scale. + let info = NSPrintInfo::sharedPrintInfo().copy(); + info.setScalingFactor(1.0); + info.setOrientation(match paper.width > paper.height { + true => NSPaperOrientation::Landscape, + false => NSPaperOrientation::Portrait, + }); + info.setPaperSize(paper); + info.setTopMargin(0.0); + info.setBottomMargin(0.0); + info.setLeftMargin(0.0); + info.setRightMargin(0.0); + log::info!("print: paper {}x{} points", paper.width, paper.height); + let operation = unsafe { + document.printOperationForPrintInfo_scalingMode_autoRotate( + Some(&info), + PDFPrintScalingMode::PageScaleNone, + true, + mtm, + ) + } + .ok_or_else(|| anyhow::anyhow!("no print operation for {}", pdf.display()))?; + // Tests read the real job: saved to a file. + let saved = crate::env::value("ELY_PRINT_TO"); + if let Some(to) = &saved { + let info = operation.printInfo(); + info.setJobDisposition(unsafe { NSPrintSaveJob }); + let to = NSURL::fileURLWithPath(&NSString::from_str(to)); + unsafe { info.dictionary().insert(NSPrintJobSavingURL, &to) }; + } + operation.setShowsPrintPanel(saved.is_none()); + operation.setShowsProgressPanel(saved.is_none()); + let printed = operation.runOperation(); + log::info!("print: the panel closed, printed {printed}"); + Ok(printed) +} + +/// One paper holding every page, turned as needed. +fn paper_for(sizes: &[(f64, f64)]) -> Option<(f64, f64)> { + let long = sizes.iter().map(|(w, h)| w.max(*h)).fold(None, |most: Option, side| Some(most.map_or(side, |m| m.max(side))))?; + let short = sizes.iter().map(|(w, h)| w.min(*h)).fold(0.0, f64::max); + // The largest page sets the paper's way up. + let (w, h) = sizes.iter().copied().fold((0.0, 0.0), |big, size| if size.0 * size.1 > big.0 * big.1 { size } else { big }); + // A square page leaves it to the others. + let wide = match w == h { + true => sizes.iter().any(|(w, h)| w > h), + false => w > h, + }; + Some(if wide { (long, short) } else { (short, long) }) +} + +/// Printing needs a system panel this platform build lacks. +#[cfg(not(target_os = "macos"))] +pub fn print_pdf(pdf: &Path) -> anyhow::Result { + anyhow::bail!("printing {} is not built on this platform", pdf.display()) +} + +#[cfg(test)] +mod tests { + use super::paper_for; + + #[test] + fn the_paper_holds_every_page() { + assert_eq!(paper_for(&[(595.0, 420.0)]), Some((595.0, 420.0))); + assert_eq!(paper_for(&[(420.0, 595.0), (842.0, 1191.0)]), Some((842.0, 1191.0))); + // Wide and square pages: neither side may be cut. + assert_eq!(paper_for(&[(1000.0, 200.0), (600.0, 600.0)]), Some((1000.0, 600.0))); + assert_eq!(paper_for(&[]), None); + } +} diff --git a/crates/ely-platform/src/settings.rs b/crates/ely-platform/src/settings.rs new file mode 100644 index 0000000..c5a07dd --- /dev/null +++ b/crates/ely-platform/src/settings.rs @@ -0,0 +1,92 @@ +//! Versioned settings files, shared and per app (§18.1). + +use std::{ + io::{self, Write as _}, + path::{Path, PathBuf}, +}; + +use serde_json::{Map, Value}; + +use crate::{AppId, lock::FileLock}; + +mod shared; +pub use shared::{APPEARANCE, AUTHOR, Appearance, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Shared, word}; + +/// The layout this build reads and writes. +pub const VERSION: u64 = 1; + +/// One settings file; writes keep unknown fields. +#[derive(Clone, Debug)] +pub struct Store { + path: PathBuf, +} + +impl Store { + /// Theme, language and motion, read by every app. + pub fn shared() -> io::Result { + let root = crate::dirs::root().map_err(io::Error::other)?; + Ok(Self::at(root.join("settings.json"))) + } + + /// One app's own preferences. + pub fn app(app: AppId) -> io::Result { + Ok(Self::at(crate::data_dir(app).map_err(io::Error::other)?.join("settings.json"))) + } + + pub fn at(path: PathBuf) -> Self { + Self { path } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// The stored fields; none when the file is absent. + pub fn read(&self) -> io::Result> { + let bytes = match std::fs::read(&self.path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Map::new()), + Err(error) => return Err(error), + }; + let invalid = |why: String| io::Error::new(io::ErrorKind::InvalidData, format!("{}: {why}", self.path.display())); + let Value::Object(fields) = serde_json::from_slice(&bytes).map_err(|error| invalid(error.to_string()))? else { + return Err(invalid("not a JSON object".into())); + }; + match fields.get("version") { + None => Ok(fields), + Some(Value::Number(n)) if n.as_u64().is_some_and(|n| n <= VERSION) => Ok(fields), + Some(Value::Number(n)) => Err(invalid(format!("version {n} is newer than this Ely reads"))), + Some(_) => Err(invalid("the version is not a number".into())), + } + } + + /// Sets `key` under the lock; other fields stay. + pub fn set(&self, key: &str, value: Value) -> io::Result<()> { + self.set_if(key, value, || true).map(|_| ()) + } + + /// Sets `key` if `still` holds once locked. + pub fn set_if(&self, key: &str, value: Value, still: impl FnOnce() -> bool) -> io::Result { + let dir = self.path.parent().ok_or_else(|| io::Error::other("settings have no folder"))?; + std::fs::create_dir_all(dir)?; + let _held = FileLock::take(&dir.join(".settings.lock"))?; + // A newer choice made while we waited wins. + if !still() { + log::info!("settings: {key} overtaken while waiting"); + return Ok(false); + } + // Read under the lock: another app may just have written. + let mut fields = self.read()?; + fields.insert(key.into(), value); + fields.insert("version".into(), VERSION.into()); + let mut temp = tempfile::NamedTempFile::new_in(dir)?; + temp.write_all(&serde_json::to_vec_pretty(&Value::Object(fields))?)?; + temp.as_file().sync_all()?; + temp.persist(&self.path).map_err(|error| error.error)?; + log::info!("settings: {key} saved in {}", self.path.display()); + Ok(true) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ely-platform/src/settings/shared.rs b/crates/ely-platform/src/settings/shared.rs new file mode 100644 index 0000000..4e9d27e --- /dev/null +++ b/crates/ely-platform/src/settings/shared.rs @@ -0,0 +1,122 @@ +//! The fields every app reads from the shared file. + +use std::io; + +use serde_json::{Map, Value}; + +use super::Store; + +/// Light, dark, or as the system is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Appearance { + #[default] + System, + Light, + Dark, +} + +/// The interface language; System follows the OS. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Language { + #[default] + System, + English, + Chinese, +} + +/// Interface motion; System follows the OS setting. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Motion { + #[default] + System, + Reduced, + Full, +} + +pub const APPEARANCE: &str = "appearance"; +pub const LANGUAGE: &str = "language"; +pub const MOTION: &str = "motion"; +pub const OMARCHY_SYNC: &str = "omarchy_sync"; +pub const AUTHOR: &str = "author"; + +/// The shared settings; absent fields take their defaults. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Shared { + pub appearance: Appearance, + pub language: Language, + pub motion: Motion, + pub omarchy_sync: bool, + /// Signs comments and changes; empty means the account name. + pub author: String, +} + +impl Default for Shared { + fn default() -> Self { + Self { + appearance: Appearance::System, + language: Language::System, + motion: Motion::System, + omarchy_sync: true, + author: String::new(), + } + } +} + +impl Appearance { + pub const ALL: [(Self, &'static str); 3] = [(Self::System, "system"), (Self::Light, "light"), (Self::Dark, "dark")]; +} + +impl Language { + pub const ALL: [(Self, &'static str); 3] = [(Self::System, "system"), (Self::English, "en-US"), (Self::Chinese, "zh-CN")]; +} + +impl Motion { + pub const ALL: [(Self, &'static str); 3] = [(Self::System, "system"), (Self::Reduced, "reduce"), (Self::Full, "full")]; +} + +/// The stored word for `choice` among `all`. +pub fn word(all: &[(T, &'static str)], choice: &T) -> &'static str { + all.iter().find(|(each, _)| each == choice).map(|(_, word)| *word).expect("every choice has a word") +} + +impl Shared { + /// The shared file as read now. + pub fn load() -> io::Result { + Self::from_fields(&Store::shared()?.read()?) + } + + /// Known fields, typed; a wrong value is an error. + pub fn from_fields(fields: &Map) -> io::Result { + let defaults = Self::default(); + Ok(Self { + appearance: pick(fields, APPEARANCE, &Appearance::ALL)?.unwrap_or(defaults.appearance), + language: pick(fields, LANGUAGE, &Language::ALL)?.unwrap_or(defaults.language), + motion: pick(fields, MOTION, &Motion::ALL)?.unwrap_or(defaults.motion), + omarchy_sync: match fields.get(OMARCHY_SYNC) { + None => defaults.omarchy_sync, + Some(Value::Bool(on)) => *on, + Some(other) => return Err(wrong(OMARCHY_SYNC, other)), + }, + author: match fields.get(AUTHOR) { + None => defaults.author, + Some(Value::String(name)) => name.trim().to_string(), + Some(other) => return Err(wrong(AUTHOR, other)), + }, + }) + } +} + +fn pick(fields: &Map, key: &str, all: &[(T, &'static str)]) -> io::Result> { + match fields.get(key) { + None => Ok(None), + Some(Value::String(word)) => match all.iter().find(|(_, each)| each == word) { + Some((choice, _)) => Ok(Some(*choice)), + None => Err(wrong(key, &Value::String(word.clone()))), + }, + Some(other) => Err(wrong(key, other)), + } +} + +fn wrong(key: &str, value: &Value) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, format!("settings: {key} cannot be {value}")) +} diff --git a/crates/ely-platform/src/settings/tests.rs b/crates/ely-platform/src/settings/tests.rs new file mode 100644 index 0000000..2de9350 --- /dev/null +++ b/crates/ely-platform/src/settings/tests.rs @@ -0,0 +1,91 @@ +use serde_json::json; + +use super::{Store, VERSION}; + +fn store() -> (tempfile::TempDir, Store) { + let dir = tempfile::tempdir().unwrap(); + let store = Store::at(dir.path().join("ElyOffice").join("settings.json")); + (dir, store) +} + +#[test] +fn an_absent_file_is_empty_and_a_write_makes_it_versioned() { + let (_dir, store) = store(); + assert!(store.read().unwrap().is_empty()); + store.set("appearance", json!("dark")).unwrap(); + let read = store.read().unwrap(); + assert_eq!(read["appearance"], "dark"); + assert_eq!(read["version"], VERSION); +} + +#[test] +fn a_write_keeps_fields_another_app_wrote() { + let (_dir, store) = store(); + std::fs::create_dir_all(store.path().parent().unwrap()).unwrap(); + std::fs::write(store.path(), r#"{"version":1,"later":{"kept":true},"language":"en"}"#).unwrap(); + store.set("language", json!("zh-CN")).unwrap(); + let read = store.read().unwrap(); + assert_eq!(read["language"], "zh-CN"); + assert_eq!(read["later"], json!({"kept": true}), "unknown fields stay"); +} + +#[test] +fn two_writers_at_once_lose_nothing() { + let (_dir, store) = store(); + let writers: Vec<_> = (0..8) + .map(|n| { + let store = store.clone(); + std::thread::spawn(move || store.set(&format!("field{n}"), json!(n)).unwrap()) + }) + .collect(); + for writer in writers { + writer.join().unwrap(); + } + let read = store.read().unwrap(); + assert!((0..8).all(|n| read[&format!("field{n}")] == n), "{read:?}"); +} + +#[test] +fn a_newer_or_broken_file_is_refused_and_left_alone() { + let (_dir, store) = store(); + std::fs::create_dir_all(store.path().parent().unwrap()).unwrap(); + for text in [r#"{"version":2}"#, r#"{"version":"1"}"#, "[1]", "{"] { + std::fs::write(store.path(), text).unwrap(); + assert!(store.read().is_err(), "{text} is refused"); + assert!(store.set("language", json!("en")).is_err(), "{text} is not overwritten"); + assert_eq!(std::fs::read_to_string(store.path()).unwrap(), text); + } +} + +#[test] +fn shared_fields_type_strictly_and_default_when_absent() { + use super::{Appearance, Language, Motion, Shared}; + let fields = |text: &str| serde_json::from_str::>(text).unwrap(); + assert_eq!(Shared::from_fields(&fields("{}")).unwrap(), Shared::default()); + let read = Shared::from_fields(&fields( + r#"{"appearance":"dark","language":"zh-CN","motion":"reduce","omarchy_sync":false,"author":" Ana "}"#, + )) + .unwrap(); + assert_eq!( + (read.appearance, read.language, read.motion, read.omarchy_sync, read.author.as_str()), + (Appearance::Dark, Language::Chinese, Motion::Reduced, false, "Ana") + ); + for bad in [r#"{"appearance":"blue"}"#, r#"{"omarchy_sync":"yes"}"#, r#"{"author":3}"#] { + assert!(Shared::from_fields(&fields(bad)).is_err(), "{bad}"); + } +} + +#[test] +fn a_write_overtaken_while_waiting_leaves_the_file() { + let (_dir, store) = store(); + store.set("author", json!("Ana")).unwrap(); + let held = crate::lock::FileLock::take(&store.path().parent().unwrap().join(".settings.lock")).unwrap(); + let waiting = { + let store = store.clone(); + std::thread::spawn(move || store.set_if("author", json!("Bob"), || false).unwrap()) + }; + std::thread::sleep(std::time::Duration::from_millis(200)); + drop(held); + assert!(!waiting.join().unwrap(), "the overtaken write is skipped"); + assert_eq!(store.read().unwrap()["author"], "Ana"); +} diff --git a/crates/ely-platform/src/startup.rs b/crates/ely-platform/src/startup.rs new file mode 100644 index 0000000..3628ed7 --- /dev/null +++ b/crates/ely-platform/src/startup.rs @@ -0,0 +1,80 @@ +use std::{ + panic, process, + sync::atomic::{AtomicBool, Ordering}, +}; + +use crate::{AppId, logging}; + +static STARTED: AtomicBool = AtomicBool::new(false); + +/// The text a failed start shows, in both languages. +pub fn message(app: AppId, reason: &str, log: &str) -> String { + let name = app.name(); + format!("{name} 无法启动。\n{name} could not start.\n\n{reason}\n\n日志 / Log: {log}") +} + +/// Logs and shows why `app` cannot start, then exits 1. +pub fn fail(app: AppId, reason: &str) -> ! { + log::error!("{} cannot start: {reason}", app.name()); + let log = logging::path().map_or_else( + || "none, logging did not start".to_string(), + |path| path.display().to_string(), + ); + let text = message(app, reason, &log); + eprintln!("{text}"); + if std::env::var_os("ELY_NO_DIALOG").is_none() { + rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title(app.name()) + .set_description(&text) + .show(); + } + process::exit(1) +} + +/// Until `started`, a panic is a failed start. +pub fn guard(app: AppId) { + let previous = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + if STARTED.load(Ordering::Relaxed) { + previous(info); + } else { + fail( + app, + &format!("the app panicked before its first frame: {info}"), + ); + } + })); +} + +/// Ely restores sessions itself; AppKit's "reopen windows?" would block. +#[cfg(target_os = "macos")] +pub fn no_window_restore() { + use objc2_foundation::{NSString, NSUserDefaults}; + let key = NSString::from_str("ApplePersistenceIgnoreState"); + NSUserDefaults::standardUserDefaults().setBool_forKey(true, &key); +} + +#[cfg(not(target_os = "macos"))] +pub fn no_window_restore() {} + +/// Marks the start as done; later panics are ordinary panics. +pub fn started() { + STARTED.store(true, Ordering::Relaxed); +} + +#[cfg(test)] +mod tests { + use super::message; + use crate::AppId; + + #[test] + fn the_message_names_the_app_the_reason_and_the_log_in_both_languages() { + let text = message(AppId::Pdf, "no usable GPU", "/logs/ely.log"); + + assert!(text.contains("ElyPDF 无法启动"), "{text}"); + assert!(text.contains("ElyPDF could not start"), "{text}"); + assert!(text.contains("no usable GPU"), "{text}"); + assert!(text.contains("/logs/ely.log"), "{text}"); + } +} diff --git a/crates/ely-platform/src/storage.rs b/crates/ely-platform/src/storage.rs new file mode 100644 index 0000000..8a07264 --- /dev/null +++ b/crates/ely-platform/src/storage.rs @@ -0,0 +1,166 @@ +//! An app's kept data, and clearing it (§18.3). + +use std::{ + io, + path::{Path, PathBuf}, +}; + +use crate::AppId; + +/// Kept data the user may see and clear. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Log, + /// The web content cache (ElyMark). + Cache, + Recovery, +} + +impl Kind { + pub const ALL: [Kind; 3] = [Kind::Log, Kind::Cache, Kind::Recovery]; + + fn place(self) -> &'static str { + match self { + Kind::Log => "ely.log", + Kind::Cache => "web", + Kind::Recovery => "recovery", + } + } + + /// In use while running, so cleared at the next start. + pub fn at_next_start(self) -> bool { + self != Kind::Log + } + + fn mark(self) -> String { + format!(".clear-{}", self.place()) + } +} + +/// Bytes each kind holds now; a missing one holds none. +pub fn usage(app: AppId) -> io::Result> { + let dir = crate::data_dir(app).map_err(io::Error::other)?; + Kind::ALL.into_iter().map(|kind| Ok((kind, size(&dir.join(kind.place()))?))).collect() +} + +fn size(path: &Path) -> io::Result { + let meta = match std::fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error), + }; + if !meta.is_dir() { + return Ok(meta.len()); + } + std::fs::read_dir(path)?.map(|entry| size(&entry?.path())).sum() +} + +/// Clears `kind` now, or marks it for the next start. +pub fn clear(app: AppId, kind: Kind) -> io::Result<()> { + let dir = crate::data_dir(app).map_err(io::Error::other)?; + if kind.at_next_start() { + std::fs::write(dir.join(kind.mark()), b"")?; + log::info!("storage: {kind:?} clears at the next start"); + return Ok(()); + } + // The logger appends; emptying keeps its handle valid. + std::fs::OpenOptions::new().write(true).open(dir.join(kind.place()))?.set_len(0)?; + log::info!("storage: {kind:?} cleared"); + Ok(()) +} + +/// Whether `kind` waits to be cleared at the next start. +pub fn clearing(app: AppId, kind: Kind) -> io::Result { + let dir = crate::data_dir(app).map_err(io::Error::other)?; + dir.join(kind.mark()).try_exists() +} + +/// At start, before anything opens them: the clears asked for. +pub fn clear_marked(app: AppId) -> io::Result<()> { + clear_marked_in(&crate::data_dir(app).map_err(io::Error::other)?) +} + +fn clear_marked_in(dir: &Path) -> io::Result<()> { + for kind in Kind::ALL.into_iter().filter(|kind| kind.at_next_start()) { + let mark = dir.join(kind.mark()); + if !mark.try_exists()? { + continue; + } + let place: PathBuf = dir.join(kind.place()); + match std::fs::remove_dir_all(&place) { + Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error), + _ => {} + } + std::fs::remove_file(&mark)?; + log::info!("storage: {kind:?} cleared as asked"); + } + Ok(()) +} + +/// Recovery points kept per document, and space for them (§6.2). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + pub keep: usize, + pub megabytes: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { keep: 20, megabytes: 64 } + } +} + +const LIMITS: &str = "recovery"; + +/// `app`'s recovery limits; a bad entry is an error. +pub fn limits(app: AppId) -> io::Result { + limits_of(crate::settings::Store::app(app)?.read()?.get(LIMITS)) +} + +fn limits_of(saved: Option<&serde_json::Value>) -> io::Result { + let Some(saved) = saved else { + return Ok(Limits::default()); + }; + let number = |key: &str, least: u64, most: u64| { + saved.get(key).and_then(serde_json::Value::as_u64).filter(|n| (least..=most).contains(n)).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, format!("settings: recovery {key} cannot be {saved}")) + }) + }; + Ok(Limits { keep: number("keep", 1, 100)? as usize, megabytes: number("megabytes", 1, 4096)? }) +} + +/// Keeps new limits for `app` if `still` holds once locked. +pub fn set_limits(app: AppId, limits: Limits, still: impl FnOnce() -> bool) -> io::Result { + let saved = serde_json::json!({ "keep": limits.keep, "megabytes": limits.megabytes }); + crate::settings::Store::app(app)?.set_if(LIMITS, saved, still) +} + +#[cfg(test)] +mod tests { + use super::{Kind, Limits, clear_marked_in, limits_of, size}; + + #[test] + fn limits_read_strictly() { + assert_eq!(limits_of(None).unwrap(), Limits::default()); + let set = serde_json::json!({"keep": 10, "megabytes": 32}); + assert_eq!(limits_of(Some(&set)).unwrap(), Limits { keep: 10, megabytes: 32 }); + for bad in [serde_json::json!({"keep": 0, "megabytes": 32}), serde_json::json!({"keep": "ten"}), serde_json::json!(5)] { + assert!(limits_of(Some(&bad)).is_err(), "{bad}"); + } + } + + #[test] + fn marked_kinds_go_at_start_and_others_stay() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("web/Cache")).unwrap(); + std::fs::write(dir.path().join("web/Cache/blob"), vec![0u8; 4096]).unwrap(); + std::fs::create_dir_all(dir.path().join("recovery/a")).unwrap(); + std::fs::write(dir.path().join("recovery/a/point"), b"text").unwrap(); + assert_eq!(size(&dir.path().join("web")).unwrap(), 4096); + std::fs::write(dir.path().join(Kind::Cache.mark()), b"").unwrap(); + clear_marked_in(dir.path()).unwrap(); + assert!(!dir.path().join("web").exists(), "the cache went"); + assert!(dir.path().join("recovery/a/point").exists(), "unmarked kinds stay"); + assert!(!dir.path().join(Kind::Cache.mark()).exists(), "the mark is spent"); + } +} diff --git a/crates/ely-platform/src/trust.rs b/crates/ely-platform/src/trust.rs new file mode 100644 index 0000000..3a871b1 --- /dev/null +++ b/crates/ely-platform/src/trust.rs @@ -0,0 +1,87 @@ +//! Whether this system trusts a certificate chain (PDF-12). + +use std::io; + +/// The system's answer for a chain, leaf first. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Trust { + Trusted, + /// The system's own words for why not. + Untrusted(String), +} + +/// Evaluates `chain` (DER, leaf first) against the system's trust settings. +#[cfg(target_os = "macos")] +pub fn check(chain: &[Vec]) -> io::Result { + mac::check(chain, None) +} + +#[cfg(not(target_os = "macos"))] +pub fn check(_chain: &[Vec]) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "certificate trust is not built on this platform", + )) +} + +#[cfg(target_os = "macos")] +mod mac { + use std::io; + + use security_framework::{certificate::SecCertificate, policy::SecPolicy, trust::SecTrust}; + + use super::Trust; + + /// `anchors` replaces the system's roots; tests only. + pub fn check(chain: &[Vec], anchors: Option<&[Vec]>) -> io::Result { + let certs = |ders: &[Vec]| -> io::Result> { + ders.iter() + .map(|der| SecCertificate::from_der(der).map_err(io::Error::other)) + .collect() + }; + let leaf = certs(chain)?; + if leaf.is_empty() { + return Err(io::Error::other("a signature with no certificate")); + } + let mut trust = SecTrust::create_with_certificates(&leaf, &[SecPolicy::create_x509()]) + .map_err(io::Error::other)?; + // No network: revocation is never checked. + trust + .set_network_fetch_allowed(false) + .map_err(io::Error::other)?; + if let Some(anchors) = anchors { + trust + .set_anchor_certificates(&certs(anchors)?) + .map_err(io::Error::other)?; + } + Ok(match trust.evaluate_with_error() { + Ok(()) => Trust::Trusted, + Err(why) => Trust::Untrusted(why.description().to_string()), + }) + } + + #[cfg(test)] + mod tests { + use super::{Trust, check}; + + /// The certificate in tests/fixtures/pdf/signer.p12, self-signed. + fn signer() -> Vec { + include_bytes!("../../../tests/fixtures/pdf/signer.der").to_vec() + } + + #[test] + fn a_self_signed_signer_is_not_trusted_by_the_system() { + assert!( + matches!(check(&[signer()], None).expect("checks"), Trust::Untrusted(why) if !why.is_empty()) + ); + } + + #[test] + fn a_chain_to_a_trusted_root_is_trusted() { + assert_eq!( + check(&[signer()], Some(&[signer()])).expect("checks"), + Trust::Trusted + ); + } + } +} diff --git a/crates/ely-platform/src/update.rs b/crates/ely-platform/src/update.rs new file mode 100644 index 0000000..8e64a3d --- /dev/null +++ b/crates/ely-platform/src/update.rs @@ -0,0 +1,488 @@ +use std::{ + collections::HashMap, + fs::{self, File}, + io::{self, Read, Write}, + net::IpAddr, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, RecvTimeoutError}, + }, + thread, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow, bail, ensure}; +use semver::Version; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use ureq::{ + Agent, Body, + http::{Response, Uri, header::LOCATION}, + unversioned::{ + resolver::DefaultResolver, + transport::{ + Buffers, ConnectionDetails, Connector, DefaultConnector, NextTimeout, Transport, + }, + }, +}; + +const MANIFEST_LIMIT: u64 = 1 << 20; +const DOWNLOAD_LIMIT: u64 = 4 << 30; +const MANIFEST_TIMEOUT: Duration = Duration::from_secs(30); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_REDIRECTS: usize = 5; +const PACKAGE_MANAGED: [&str; 5] = ["/usr", "/opt", "/app", "/snap", "/nix/store"]; + +/// Longest a download may go without a byte. +pub const IDLE_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq)] +pub struct Release { + pub version: Version, + pub url: String, + pub sha256: String, +} + +#[derive(Debug, PartialEq)] +pub enum Check { + Current, + Available(Release), +} + +#[derive(Debug, PartialEq)] +pub enum InstallPolicy { + SelfManaged, + PackageManager, +} + +#[derive(Deserialize)] +struct Manifest { + version: Version, + assets: HashMap, +} + +#[derive(Deserialize)] +struct Asset { + url: String, + sha256: String, +} + +/// The manifest key of this build, such as `macos-aarch64`. +pub fn platform() -> String { + format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) +} + +pub fn install_policy() -> Result { + let exe = std::env::current_exe().context("cannot find the running executable")?; + Ok(policy_for(std::env::consts::OS, &exe)) +} + +fn policy_for(os: &str, exe: &Path) -> InstallPolicy { + let managed = PACKAGE_MANAGED.iter().any(|prefix| exe.starts_with(prefix)); + if os == "linux" && managed { + InstallPolicy::PackageManager + } else { + InstallPolicy::SelfManaged + } +} + +/// Fetches the manifest and compares it with `current`. Never installs. +pub fn check(current: &Version, manifest_url: &str) -> Result { + let host = secure_host(manifest_url)?; + log::info!("update check: asking {host}"); + let result = fetch_manifest(current, manifest_url); + match &result { + Ok(Check::Current) => log::info!("update check: {host} has nothing newer than {current}"), + Ok(Check::Available(release)) => { + log::info!("update check: {host} offers {}", release.version) + } + Err(error) => log::warn!("update check: {host} failed: {error:#}"), + } + result +} + +fn fetch_manifest(current: &Version, manifest_url: &str) -> Result { + let text = fetch(&manifest_agent(), manifest_url)? + .body_mut() + .with_config() + .limit(MANIFEST_LIMIT) + .read_to_string() + .context("cannot read the update manifest")?; + let manifest: Manifest = + serde_json::from_str(&text).context("the update manifest is not valid")?; + let key = platform(); + let Some(asset) = manifest.assets.get(&key) else { + bail!("release {} has no build for {key}", manifest.version); + }; + secure_host(&asset.url)?; + if manifest.version <= *current { + return Ok(Check::Current); + } + Ok(Check::Available(Release { + version: manifest.version, + url: asset.url.clone(), + sha256: asset.sha256.to_lowercase(), + })) +} + +/// Downloads into `dir`, calling `progress(done, total)`. `Ok(None)` means cancelled. +pub fn download( + release: &Release, + dir: &Path, + cancel: &AtomicBool, + idle: Duration, + progress: &mut dyn FnMut(u64, Option), +) -> Result> { + let host = secure_host(&release.url)?; + let name = file_name(&release.url)?; + let part = dir.join(format!("{name}.part")); + let target = dir.join(name); + log::info!("update download: {} from {host}", release.version); + + let outcome = stream(release, &part, cancel, idle, progress); + match outcome { + Ok(true) => { + fs::rename(&part, &target) + .with_context(|| format!("cannot move {} into place", part.display()))?; + log::info!( + "update download: {} saved to {}", + release.version, + target.display() + ); + Ok(Some(target)) + } + Ok(false) => { + log::info!("update download: {} cancelled", release.version); + remove_part(&part)?; + Ok(None) + } + Err(error) => { + log::warn!("update download: {} failed: {error:#}", release.version); + if let Err(cleanup) = remove_part(&part) { + log::warn!("update download: {cleanup:#}"); + } + Err(error) + } + } +} + +/// Checks a user's file against its published hash. +pub fn verify_file(path: &Path, sha256: &str) -> Result<()> { + let mut file = File::open(path).with_context(|| format!("cannot open {}", path.display()))?; + let mut hasher = Sha256::new(); + io::copy(&mut file, &mut hasher).with_context(|| format!("cannot read {}", path.display()))?; + expect_hash(hasher, sha256) +} + +fn stream( + release: &Release, + part: &Path, + cancel: &AtomicBool, + idle: Duration, + progress: &mut dyn FnMut(u64, Option), +) -> Result { + let response = fetch(&download_agent(idle), &release.url)?; + let total = response.body().content_length(); + let (_, body) = response.into_parts(); + let (sender, receiver) = mpsc::sync_channel::>>(4); + thread::spawn(move || { + let mut reader = body.into_with_config().limit(DOWNLOAD_LIMIT).reader(); + loop { + let mut chunk = vec![0u8; 64 * 1024]; + match reader.read(&mut chunk) { + Ok(read) => { + chunk.truncate(read); + if sender.send(Ok(chunk)).is_err() || read == 0 { + return; + } + } + Err(error) => { + let _ = sender.send(Err(error)); + return; + } + } + } + }); + + let mut file = + File::create(part).with_context(|| format!("cannot create {}", part.display()))?; + let mut hasher = Sha256::new(); + let mut done = 0u64; + loop { + if cancel.load(Ordering::Relaxed) { + return Ok(false); + } + match receiver.recv_timeout(Duration::from_millis(100)) { + Ok(Ok(chunk)) if chunk.is_empty() => break, + Ok(Ok(chunk)) => { + hasher.update(&chunk); + file.write_all(&chunk) + .with_context(|| format!("cannot write {}", part.display()))?; + done += chunk.len() as u64; + progress(done, total); + } + Ok(Err(error)) if is_timeout(&error) => { + bail!("the download stalled: no data for {idle:?}") + } + Ok(Err(error)) => return Err(anyhow!(error).context("the download was interrupted")), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => bail!("the download thread ended early"), + } + } + file.sync_all() + .with_context(|| format!("cannot flush {}", part.display()))?; + expect_hash(hasher, &release.sha256)?; + Ok(true) +} + +fn is_timeout(error: &io::Error) -> bool { + let from_ureq = error + .get_ref() + .and_then(|inner| inner.downcast_ref::()) + .is_some_and(|inner| matches!(inner, ureq::Error::Timeout(_))); + from_ureq + || matches!( + error.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) +} + +fn remove_part(part: &Path) -> Result<()> { + match fs::remove_file(part) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("cannot remove {}", part.display())), + } +} + +fn expect_hash(hasher: Sha256, expected: &str) -> Result<()> { + let actual: String = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + ensure!( + actual.eq_ignore_ascii_case(expected), + "the file has sha256 {actual}, the release says {expected}" + ); + Ok(()) +} + +fn file_name(url: &str) -> Result<&str> { + let url = url.split_once(['?', '#']).map_or(url, |(head, _)| head); + let rest = url.split_once("://").map_or(url, |(_, rest)| rest); + let path = rest.split_once('/').map_or("", |(_, path)| path); + let name = path.rsplit_once('/').map_or(path, |(_, name)| name); + ensure!( + !name.is_empty() && name != "." && name != ".." && !name.contains(['\\', ':']), + "{url} names no safe file" + ); + Ok(name) +} + +/// Accepts https, or http to this machine. Returns the host. +fn secure_host(url: &str) -> Result { + let uri: Uri = url.parse().with_context(|| format!("{url} is not a URL"))?; + let authority = uri + .authority() + .with_context(|| format!("{url} has no host"))?; + ensure!( + !authority.as_str().contains('@'), + "{url} carries credentials" + ); + let host = authority.host(); + let local = host == "localhost" + || host + .trim_matches(['[', ']']) + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + ensure!( + uri.scheme_str() == Some("https") || (uri.scheme_str() == Some("http") && local), + "{url} is not https" + ); + Ok(host.to_string()) +} + +/// GETs `url`, following redirects only to places `secure_host` accepts. +fn fetch(agent: &Agent, url: &str) -> Result> { + let mut url = url.to_string(); + for _ in 0..=MAX_REDIRECTS { + secure_host(&url)?; + let response = agent + .get(&url) + .call() + .with_context(|| format!("cannot fetch {url}"))?; + if !response.status().is_redirection() { + return Ok(response); + } + let location = response + .headers() + .get(LOCATION) + .and_then(|value| value.to_str().ok()) + .with_context(|| format!("{url} redirects without a location"))?; + url = if location.starts_with('/') { + let uri: Uri = url.parse()?; + format!( + "{}://{}{location}", + uri.scheme_str().unwrap_or("https"), + uri.authority().map_or("", |a| a.as_str()) + ) + } else { + location.to_string() + }; + } + bail!("too many redirects") +} + +fn manifest_agent() -> Agent { + Agent::config_builder() + .timeout_global(Some(MANIFEST_TIMEOUT)) + .max_redirects(0) + .max_redirects_will_error(false) + .build() + .into() +} + +/// No total timeout; a silent socket times out. +fn download_agent(idle: Duration) -> Agent { + let config = Agent::config_builder() + .timeout_connect(Some(CONNECT_TIMEOUT)) + .timeout_recv_response(Some(CONNECT_TIMEOUT)) + .max_redirects(0) + .max_redirects_will_error(false) + .build(); + Agent::with_parts( + config, + DefaultConnector::new().chain(IdleConnector(idle)), + DefaultResolver::default(), + ) +} + +/// Caps socket waits at `idle`; a stall frees the socket. +#[derive(Debug)] +struct IdleConnector(Duration); + +impl Connector for IdleConnector { + type Out = Idle; + + fn connect( + &self, + _: &ConnectionDetails, + chained: Option, + ) -> Result>, ureq::Error> { + Ok(chained.map(|inner| Idle { + inner, + idle: self.0, + })) + } +} + +#[derive(Debug)] +struct Idle { + inner: T, + idle: Duration, +} + +impl Idle { + fn capped(&self, mut timeout: NextTimeout) -> NextTimeout { + timeout.after = timeout.after.min(self.idle.into()); + timeout + } +} + +impl Transport for Idle { + fn buffers(&mut self) -> &mut dyn Buffers { + self.inner.buffers() + } + + fn transmit_output(&mut self, amount: usize, timeout: NextTimeout) -> Result<(), ureq::Error> { + let timeout = self.capped(timeout); + self.inner.transmit_output(amount, timeout) + } + + fn await_input(&mut self, timeout: NextTimeout) -> Result { + let timeout = self.capped(timeout); + self.inner.await_input(timeout) + } + + fn is_open(&mut self) -> bool { + self.inner.is_open() + } + + fn is_tls(&self) -> bool { + self.inner.is_tls() + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{InstallPolicy, file_name, policy_for, secure_host}; + + #[test] + fn only_a_linux_package_install_defers_to_the_package_manager() { + let usr = Path::new("/usr/bin/ely-doc"); + assert_eq!(policy_for("linux", usr), InstallPolicy::PackageManager); + assert_eq!( + policy_for("linux", Path::new("/app/bin/ely-doc")), + InstallPolicy::PackageManager + ); + assert_eq!( + policy_for("linux", Path::new("/home/me/Apps/ely-doc")), + InstallPolicy::SelfManaged + ); + assert_eq!( + policy_for("linux", Path::new("/tmp/.mount_ElyDoc/usr/bin/ely-doc")), + InstallPolicy::SelfManaged + ); + assert_eq!(policy_for("macos", usr), InstallPolicy::SelfManaged); + assert_eq!( + policy_for("windows", Path::new("C:/Program Files/Ely")), + InstallPolicy::SelfManaged + ); + } + + #[test] + fn plain_http_is_for_this_machine_only() { + for ok in [ + "https://example.com/a.json", + "http://127.0.0.1:8080/a", + "http://localhost/a", + "http://[::1]:9/a", + ] { + secure_host(ok).unwrap(); + } + for bad in [ + "http://example.com/a", + "http://127.0.0.1.evil.com/a", + "http://localhost.evil.com/a", + "http://127.0.0.1:1@192.168.1.5:80/a", + "https://user:pw@example.com/a", + "ftp://x/y", + "file:///etc/passwd", + "/relative", + ] { + assert!(secure_host(bad).is_err(), "{bad}"); + } + } + + #[test] + fn a_download_names_one_file() { + assert_eq!( + file_name("https://h/p/ElyDoc-0.2.0.dmg?sig=1#x").unwrap(), + "ElyDoc-0.2.0.dmg" + ); + for bad in [ + "https://h/p/", + "https://h/..", + "https://h/.", + "https://h", + "https://h/..\\evil.exe", + "https://h/c:evil", + ] { + assert!(file_name(bad).is_err(), "{bad}"); + } + } +} diff --git a/crates/ely-platform/src/user.rs b/crates/ely-platform/src/user.rs new file mode 100644 index 0000000..b5c06bd --- /dev/null +++ b/crates/ely-platform/src/user.rs @@ -0,0 +1,68 @@ +//! The name documents sign new comments and changes with. + +use std::io; + +/// The author name from settings, else the account's. +pub fn name() -> io::Result { + let set = crate::settings::Shared::load()?.author; + if !set.is_empty() { + return Ok(set); + } + account() +} + +/// The account's full name, else its login name. +pub fn account() -> io::Result { + let full = full_name()?; + if !full.trim().is_empty() { + return Ok(full.trim().to_string()); + } + let key = if cfg!(windows) { "USERNAME" } else { "USER" }; + log::info!("user: no full name; the login name signs"); + std::env::var(key).map_err(|error| io::Error::other(format!("no {key}: {error}"))) +} + +#[cfg(target_os = "macos")] +fn full_name() -> io::Result { + Ok(objc2_foundation::NSFullUserName().to_string()) +} + +#[cfg(target_os = "linux")] +fn full_name() -> io::Result { + // The first comma field of the passwd comment. + let entry = unsafe { libc::getpwuid(libc::getuid()) }; + if entry.is_null() { + return Err(io::Error::last_os_error()); + } + let gecos = unsafe { (*entry).pw_gecos }; + if gecos.is_null() { + return Ok(String::new()); + } + let text = unsafe { std::ffi::CStr::from_ptr(gecos) }.to_string_lossy(); + Ok(text.split(',').next().unwrap_or_default().to_string()) +} + +#[cfg(windows)] +fn full_name() -> io::Result { + use windows_sys::Win32::Security::Authentication::Identity::{GetUserNameExW, NameDisplay}; + let mut size = 0u32; + unsafe { GetUserNameExW(NameDisplay, std::ptr::null_mut(), &mut size) }; + // Accounts outside a domain have no display name. + if size == 0 { + return Ok(String::new()); + } + let mut buffer = vec![0u16; size as usize]; + if unsafe { GetUserNameExW(NameDisplay, buffer.as_mut_ptr(), &mut size) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(String::from_utf16_lossy(&buffer[..size as usize])) +} + +#[cfg(test)] +mod tests { + #[test] + fn the_account_has_a_name() { + let name = super::name().expect("a name"); + assert!(!name.is_empty()); + } +} diff --git a/crates/ely-platform/tests/logging.rs b/crates/ely-platform/tests/logging.rs new file mode 100644 index 0000000..11c2a0a --- /dev/null +++ b/crates/ely-platform/tests/logging.rs @@ -0,0 +1,20 @@ +use ely_platform::{AppId, logging}; + +#[test] +fn log_lines_reach_the_app_file_and_a_second_init_fails() { + let dir = tempfile::tempdir().unwrap(); + + logging::init_in(AppId::Doc, dir.path()).unwrap(); + log::info!("hello from the test"); + log::logger().flush(); + + let text = std::fs::read_to_string(dir.path().join("ely.log")).unwrap(); + assert!(text.contains("ElyDoc 0.1.0 started"), "{text}"); + assert!(text.contains("hello from the test"), "{text}"); + + let error = logging::init_in(AppId::Mail, dir.path()).unwrap_err(); + assert!( + format!("{error:#}").contains("a logger is already set"), + "{error:#}" + ); +} diff --git a/crates/ely-platform/tests/media.rs b/crates/ely-platform/tests/media.rs new file mode 100644 index 0000000..e73e0f2 --- /dev/null +++ b/crates/ely-platform/tests/media.rs @@ -0,0 +1,62 @@ +//! The system player against a small H.264/AAC clip. + +#[cfg(target_os = "macos")] +fn main() { + use std::time::{Duration, Instant}; + + use ely_platform::media::{Player, State}; + use objc2_foundation::{NSDate, NSRunLoop}; + + // The player works through the main run loop. + let pump = |until: &dyn Fn() -> bool| { + let end = Instant::now() + Duration::from_secs(10); + while !until() { + assert!(Instant::now() < end, "the player never got there"); + let soon = NSDate::dateWithTimeIntervalSinceNow(0.02); + NSRunLoop::currentRunLoop().runUntilDate(&soon); + } + }; + let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/media/clip.mp4"); + let player = Player::open(&clip).expect("opens"); + pump(&|| player.state() != State::Loading); + let State::Ready { seconds, video } = player.state() else { + panic!("not ready: {:?}", player.state()); + }; + assert!((seconds - 1.0).abs() < 0.2, "{seconds}"); + assert_eq!(video, Some((160, 90))); + player.play(); + let frames = std::cell::Cell::new(0); + pump(&|| { + frames.set(frames.get() + usize::from(player.frame().is_some())); + player.position() > 0.3 && frames.get() > 0 + }); + assert!(player.playing()); + // At its end it stops; a loop starts over. + pump(&|| player.ended()); + assert_eq!(player.tick(false), Ok(false), "done, nothing to draw"); + assert_eq!(player.tick(true), Ok(true), "the loop goes on"); + pump(&|| player.playing() && player.position() < seconds - 0.2); + // A pause near the end holds, loop or not. + player.pause(); + player.seek(seconds - 0.02); + pump(&|| player.position() >= seconds - 0.05); + assert!(!player.ended()); + assert_eq!(player.tick(true), Ok(false)); + assert!(!player.playing(), "the pause holds"); + player.play(); + player.pause(); + assert!(Player::open(std::path::Path::new("/no/such/clip.mp4")).is_err()); + let dir = tempfile::tempdir().expect("a temp dir"); + let junk = dir.path().join("junk.mp4"); + std::fs::write(&junk, b"not a video at all").expect("writes"); + let broken = Player::open(&junk).expect("opens the file"); + pump(&|| broken.tick(false) != Ok(true)); + assert!(matches!(broken.tick(false), Err(reason) if !reason.is_empty())); + println!("media: plays, shows frames, and fails with reasons"); +} + +#[cfg(not(target_os = "macos"))] +fn main() { + println!("SKIP: no system player is built on this platform"); +} diff --git a/crates/ely-platform/tests/packaging.rs b/crates/ely-platform/tests/packaging.rs new file mode 100644 index 0000000..f5e76ca --- /dev/null +++ b/crates/ely-platform/tests/packaging.rs @@ -0,0 +1,24 @@ +use ely_platform::AppId::{self, *}; + +#[test] +fn packaging_table_lists_the_apps_in_prd_order() { + let apps: [AppId; 8] = [Doc, Table, Show, Pdf, Mail, Tree, Mark, Agent]; + let table = include_str!("../../../packaging/apps.tsv"); + let rows: Vec> = table + .lines() + .map(|line| line.split('\t').collect()) + .collect(); + + assert_eq!(rows.len(), apps.len()); + for (row, app) in rows.iter().zip(apps) { + assert_eq!(row[..2], [app.slug(), app.name()]); + assert!( + row[2].ends_with(';'), + "{} has no desktop categories", + app.slug() + ); + // Mail and Agent ship in the second batch. + let batch = if matches!(app, Mail | Agent) { "2" } else { "1" }; + assert_eq!(row[3], batch, "{} is in the wrong batch", app.slug()); + } +} diff --git a/crates/ely-platform/tests/startup.rs b/crates/ely-platform/tests/startup.rs new file mode 100644 index 0000000..4b21f44 --- /dev/null +++ b/crates/ely-platform/tests/startup.rs @@ -0,0 +1,60 @@ +use std::{fs, path::PathBuf, process::Command}; + +use ely_platform::{AppId, logging, startup}; + +const CHILD: &str = "ELY_STARTUP_TEST_DIR"; +const NO_LOG: &str = "ELY_STARTUP_TEST_NO_LOG"; + +fn rerun(test: &str, dir: &std::path::Path, no_log: bool) -> std::process::Output { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", test, "--nocapture"]) + .env(CHILD, dir) + .env("ELY_NO_DIALOG", "1"); + if no_log { + command.env(NO_LOG, "1"); + } + command.output().unwrap() +} + +#[test] +fn a_startup_failure_is_logged_printed_and_exits_with_one() { + const TEST: &str = "a_startup_failure_is_logged_printed_and_exits_with_one"; + if let Some(dir) = std::env::var_os(CHILD) { + logging::init_in(AppId::Doc, &PathBuf::from(dir)).unwrap(); + startup::fail(AppId::Doc, "no usable GPU"); + } + + let dir = tempfile::tempdir().unwrap(); + let output = rerun(TEST, dir.path(), false); + + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + let log_path = dir.path().join("ely.log"); + assert!( + stderr.contains("ElyDoc could not start") && stderr.contains("no usable GPU"), + "{stderr}" + ); + assert!( + stderr.contains(&log_path.display().to_string()), + "the message does not name the real log: {stderr}" + ); + let log = fs::read_to_string(log_path).unwrap(); + assert!(log.contains("ElyDoc cannot start: no usable GPU"), "{log}"); +} + +#[test] +fn without_a_logger_the_message_says_there_is_no_log() { + const TEST: &str = "without_a_logger_the_message_says_there_is_no_log"; + if std::env::var_os(NO_LOG).is_some() { + startup::fail(AppId::Doc, "logging did not start: read-only home"); + } + + let dir = tempfile::tempdir().unwrap(); + let output = rerun(TEST, dir.path(), true); + + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("none, logging did not start"), "{stderr}"); + assert!(!stderr.contains("ely.log"), "{stderr}"); +} diff --git a/crates/ely-platform/tests/update.rs b/crates/ely-platform/tests/update.rs new file mode 100644 index 0000000..b39d8ff --- /dev/null +++ b/crates/ely-platform/tests/update.rs @@ -0,0 +1,425 @@ +use std::{ + fs, + io::{Read, Write}, + net::{TcpListener, TcpStream}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +use ely_platform::{ + AppId, logging, + update::{Check, IDLE_TIMEOUT, Release, check, download, platform, verify_file}, +}; +use semver::Version; +use sha2::{Digest, Sha256}; + +#[derive(Clone)] +enum Reply { + Body(Vec), + Redirect(String), + Stall(Arc), +} + +fn hex_of(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +fn payload() -> Vec { + (0..300_000u32).map(|n| (n % 251) as u8).collect() +} + +fn respond(mut stream: TcpStream, routes: &[(String, Reply)]) { + let mut request = Vec::new(); + let mut byte = [0u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + if stream.read(&mut byte).unwrap() == 0 { + return; + } + request.push(byte[0]); + } + let line = String::from_utf8_lossy(&request); + let path = line.split_whitespace().nth(1).unwrap_or("/").to_string(); + let reply = routes + .iter() + .find(|(route, _)| *route == path) + .map(|(_, reply)| reply.clone()); + let bytes = match reply { + Some(Reply::Body(body)) => { + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + [head.into_bytes(), body].concat() + } + Some(Reply::Redirect(to)) => format!( + "HTTP/1.1 302 Found\r\nLocation: {to}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .into_bytes(), + Some(Reply::Stall(closed)) => { + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\nConnection: close\r\n\r\n", + ); + let _ = stream.write_all(&[7u8; 1000]); + stream + .set_read_timeout(Some(Duration::from_secs(20))) + .unwrap(); + let seen = stream.read(&mut [0u8; 1]); + if matches!(seen, Ok(0)) + || seen.is_err_and(|error| error.kind() == std::io::ErrorKind::ConnectionReset) + { + closed.store(true, Ordering::Relaxed); + } + return; + } + None => { + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec() + } + }; + let _ = stream.write_all(&bytes); +} + +/// Serves routes locally; `make` gets the base URL. +fn serve(make: impl FnOnce(&str) -> Vec<(String, Reply)>) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + let routes = make(&base); + thread::spawn(move || { + for stream in listener.incoming() { + let routes = routes.clone(); + let stream = stream.unwrap(); + thread::spawn(move || respond(stream, &routes)); + } + }); + base +} + +fn manifest(version: &str, key: &str, base: &str, sha: &str) -> Reply { + Reply::Body( + format!(r#"{{"version":"{version}","assets":{{"{key}":{{"url":"{base}/ElyDoc.bin","sha256":"{sha}"}}}}}}"#) + .into_bytes(), + ) +} + +fn release(base: &str, sha: String) -> Release { + Release { + version: Version::new(0, 2, 0), + url: format!("{base}/ElyDoc.bin"), + sha256: sha, + } +} + +fn error_text(result: anyhow::Result) -> String { + format!("{:#}", result.err().expect("an error")) +} + +#[test] +fn a_newer_release_for_this_platform_is_offered_and_an_equal_or_older_one_is_not() { + let sha = hex_of(&payload()).to_uppercase(); + let base = serve(|base| { + vec![( + "/latest.json".into(), + manifest("0.2.0", &platform(), base, &sha), + )] + }); + let url = format!("{base}/latest.json"); + + let found = check(&Version::new(0, 1, 0), &url).unwrap(); + assert_eq!(found, Check::Available(release(&base, sha.to_lowercase()))); + assert_eq!(check(&Version::new(0, 2, 0), &url).unwrap(), Check::Current); + assert_eq!(check(&Version::new(0, 3, 0), &url).unwrap(), Check::Current); +} + +#[test] +fn a_manifest_without_this_platform_a_bad_manifest_and_a_missing_one_all_fail_loudly() { + let base = serve(|base| { + vec![ + ( + "/other.json".into(), + manifest("0.2.0", "plan9-mips", base, "00"), + ), + ("/bad.json".into(), Reply::Body(b"{not json".to_vec())), + ] + }); + let current = Version::new(0, 1, 0); + + assert!( + error_text(check(¤t, &format!("{base}/other.json"))).contains("has no build for") + ); + assert!(error_text(check(¤t, &format!("{base}/bad.json"))).contains("not valid")); + assert!(error_text(check(¤t, &format!("{base}/none.json"))).contains("cannot fetch")); +} + +#[test] +fn plain_http_to_other_hosts_and_credentials_in_the_url_are_refused_before_any_request() { + let current = Version::new(0, 1, 0); + + assert!(error_text(check(¤t, "http://example.com/latest.json")).contains("not https")); + let sneaky = "http://127.0.0.1:1@192.168.31.221:9/latest.json"; + assert!(error_text(check(¤t, sneaky)).contains("carries credentials")); +} + +#[test] +fn a_redirect_is_followed_on_this_machine_and_refused_when_it_leaves_for_plain_http() { + let sha = hex_of(&payload()); + let base = serve(|base| { + vec![ + ( + "/latest.json".into(), + manifest("0.2.0", &platform(), base, &sha), + ), + ("/first".into(), Reply::Redirect("/second".into())), + ("/second".into(), Reply::Redirect("/latest.json".into())), + ( + "/leave".into(), + Reply::Redirect("http://example.com/latest.json".into()), + ), + ] + }); + let current = Version::new(0, 1, 0); + + let followed = check(¤t, &format!("{base}/first")).unwrap(); + assert!(matches!(followed, Check::Available(_)), "{followed:?}"); + assert!(error_text(check(¤t, &format!("{base}/leave"))).contains("not https")); +} + +#[test] +fn a_download_is_verified_reported_and_moved_into_place() { + let body = payload(); + let base = serve(|_| vec![("/ElyDoc.bin".into(), Reply::Body(body.clone()))]); + let dir = tempfile::tempdir().unwrap(); + let mut seen = Vec::new(); + + let path = download( + &release(&base, hex_of(&body)), + dir.path(), + &AtomicBool::new(false), + IDLE_TIMEOUT, + &mut |done, total| { + seen.push((done, total)); + }, + ) + .unwrap() + .expect("not cancelled"); + + assert_eq!(path, dir.path().join("ElyDoc.bin")); + assert_eq!(fs::read(&path).unwrap(), body); + assert!(!dir.path().join("ElyDoc.bin.part").exists()); + assert_eq!( + seen.last().copied(), + Some((body.len() as u64, Some(body.len() as u64))) + ); + assert!( + seen.windows(2).all(|pair| pair[0].0 < pair[1].0), + "progress went backwards" + ); + verify_file(&path, &hex_of(&body)).unwrap(); +} + +#[test] +fn a_wrong_hash_leaves_no_file_behind() { + let body = payload(); + let base = serve(|_| vec![("/ElyDoc.bin".into(), Reply::Body(body.clone()))]); + let dir = tempfile::tempdir().unwrap(); + + let result = download( + &release(&base, hex_of(b"other")), + dir.path(), + &AtomicBool::new(false), + IDLE_TIMEOUT, + &mut |_, _| {}, + ); + + assert!(error_text(result).contains("the release says")); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); +} + +#[cfg(unix)] +#[test] +fn the_download_error_survives_a_failed_cleanup() { + use std::os::unix::fs::PermissionsExt; + + let body = payload(); + let base = serve(|_| vec![("/ElyDoc.bin".into(), Reply::Body(body.clone()))]); + let dir = tempfile::tempdir().unwrap(); + let lock = |mode| fs::set_permissions(dir.path(), fs::Permissions::from_mode(mode)).unwrap(); + let mut locked = false; + + let result = download( + &release(&base, hex_of(b"other")), + dir.path(), + &AtomicBool::new(false), + IDLE_TIMEOUT, + &mut |_, _| { + if !locked { + lock(0o555); + locked = true; + } + }, + ); + + let still_writable = fs::File::create(dir.path().join("probe")).is_ok(); + lock(0o755); + if still_writable { + println!("SKIP: permissions do not bind this user"); + return; + } + assert!(error_text(result).contains("the release says")); +} + +fn closed_within(flag: &AtomicBool, limit: Duration) -> bool { + let started = Instant::now(); + while started.elapsed() < limit { + if flag.load(Ordering::Relaxed) { + return true; + } + thread::sleep(Duration::from_millis(50)); + } + false +} + +#[test] +fn a_stalled_download_fails_and_frees_its_socket() { + let closed = Arc::new(AtomicBool::new(false)); + let base = serve({ + let closed = closed.clone(); + move |_| vec![("/ElyDoc.bin".into(), Reply::Stall(closed))] + }); + let dir = tempfile::tempdir().unwrap(); + + let stalled = download( + &release(&base, hex_of(b"x")), + dir.path(), + &AtomicBool::new(false), + Duration::from_millis(600), + &mut |_, _| {}, + ); + + let text = error_text(stalled); + assert!(text.contains("stalled"), "{text}"); + assert!( + closed_within(&closed, Duration::from_secs(5)), + "the client kept the socket open" + ); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); +} + +#[test] +fn a_cancel_is_answered_at_once_and_the_socket_is_freed_within_the_idle_time() { + let closed = Arc::new(AtomicBool::new(false)); + let base = serve({ + let closed = closed.clone(); + move |_| vec![("/ElyDoc.bin".into(), Reply::Stall(closed))] + }); + let dir = tempfile::tempdir().unwrap(); + let cancel = AtomicBool::new(false); + + let started = Instant::now(); + let outcome = thread::scope(|scope| { + scope.spawn(|| { + thread::sleep(Duration::from_millis(300)); + cancel.store(true, Ordering::Relaxed); + }); + download( + &release(&base, hex_of(b"x")), + dir.path(), + &cancel, + Duration::from_secs(1), + &mut |_, _| {}, + ) + }); + + assert_eq!(outcome.unwrap(), None); + assert!( + started.elapsed() < Duration::from_secs(1), + "the cancel waited for the socket" + ); + assert!( + closed_within(&closed, Duration::from_secs(5)), + "the client kept the socket open" + ); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); +} + +#[test] +fn a_cancelled_download_returns_none_and_leaves_no_file_behind() { + let body = payload(); + let base = serve(|_| vec![("/ElyDoc.bin".into(), Reply::Body(body.clone()))]); + let dir = tempfile::tempdir().unwrap(); + let cancel = AtomicBool::new(false); + + let outcome = download( + &release(&base, hex_of(&body)), + dir.path(), + &cancel, + IDLE_TIMEOUT, + &mut |_, _| { + cancel.store(true, Ordering::Relaxed); + }, + ) + .unwrap(); + + assert_eq!(outcome, None); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); +} + +#[test] +fn a_file_the_user_brought_is_checked_against_its_published_hash() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("ElyDoc.dmg"); + fs::write(&file, b"installer bytes").unwrap(); + + verify_file(&file, &hex_of(b"installer bytes").to_uppercase()).unwrap(); + assert!( + error_text(verify_file(&file, &hex_of(b"tampered bytes"))).contains("the release says") + ); + assert!(error_text(verify_file(&dir.path().join("none"), "00")).contains("cannot open")); +} + +#[test] +fn checks_and_downloads_are_logged_with_their_outcome() { + let logs = tempfile::tempdir().unwrap(); + logging::init_in(AppId::Doc, logs.path()).unwrap(); + let body = payload(); + let sha = hex_of(&body); + let base = serve(|base| { + vec![ + ( + "/latest.json".into(), + manifest("0.2.0", &platform(), base, &sha), + ), + ("/ElyDoc.bin".into(), Reply::Body(body.clone())), + ] + }); + let dir = tempfile::tempdir().unwrap(); + let cancel = AtomicBool::new(false); + + check(&Version::new(0, 1, 0), &format!("{base}/latest.json")).unwrap(); + check(&Version::new(0, 1, 0), &format!("{base}/none.json")).unwrap_err(); + download( + &release(&base, sha), + dir.path(), + &cancel, + IDLE_TIMEOUT, + &mut |_, _| { + cancel.store(true, Ordering::Relaxed); + }, + ) + .unwrap(); + log::logger().flush(); + + let text = fs::read_to_string(logs.path().join("ely.log")).unwrap(); + for line in [ + "update check: 127.0.0.1 offers 0.2.0", + "update check: 127.0.0.1 failed", + "update download: 0.2.0 cancelled", + ] { + assert!(text.contains(line), "no '{line}' in:\n{text}"); + } +} diff --git a/crates/ely-ui/Cargo.toml b/crates/ely-ui/Cargo.toml new file mode 100644 index 0000000..2fab264 --- /dev/null +++ b/crates/ely-ui/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "ely-ui" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +ely-gpui-component.workspace = true +ely-platform.workspace = true +gpui.workspace = true +directories.workspace = true +futures.workspace = true +gpui_platform.workspace = true +log.workspace = true +notify.workspace = true +serde_json.workspace = true + +[features] +test-support = [] + +[dev-dependencies] +ely-gpui-component = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +tempfile.workspace = true + +[lints] +workspace = true + +[[test]] +name = "startup_panic" +harness = false diff --git a/crates/ely-ui/examples/shell/dialogs.rs b/crates/ely-ui/examples/shell/dialogs.rs new file mode 100644 index 0000000..3c88a51 --- /dev/null +++ b/crates/ely-ui/examples/shell/dialogs.rs @@ -0,0 +1,65 @@ +use ely_gpui_component::{ + forms::TextInput, + overlays::{ConfirmDialog, PromptDialog}, +}; +use gpui::{Context, Entity, IntoElement, SharedString}; + +use crate::Gallery; + +pub(crate) enum Overlay { + Rename { field: Entity }, + Delete, +} + +fn rename_check(empty: &str, failed: &str, text: &str) -> Result<(), SharedString> { + match text.trim() { + "" => Err(empty.into()), + "fail" => Err(failed.into()), + _ => Ok(()), + } +} + +pub(crate) fn rename_overlay( + field: Entity, + cx: &mut Context, +) -> gpui::AnyElement { + let this = cx.entity(); + PromptDialog::new( + "rename-dialog", + ely_ui::i18n::text(cx, "gallery.rename.title", &[]), + &field, + { + let this = this.clone(); + move |_, cx| this.update(cx, |this, cx| this.close_dialog(cx)) + }, + ) + .submit(ely_ui::i18n::text(cx, "gallery.rename.submit", &[])) + .check({ + let empty = ely_ui::i18n::text(cx, "gallery.rename.empty", &[]); + let failed = ely_ui::i18n::text(cx, "gallery.rename.failed", &[]); + move |text| rename_check(&empty, &failed, text) + }) + .on_submit(move |text, _, cx| { + this.update(cx, |this, cx| this.rename(text, cx)); + }) + .into_any_element() +} + +pub(crate) fn delete_overlay(title: SharedString, cx: &mut Context) -> gpui::AnyElement { + let this = cx.entity(); + ConfirmDialog::new( + "delete-dialog", + ely_ui::i18n::text(cx, "gallery.delete.title", &[("title", &title)]), + ely_ui::i18n::text(cx, "gallery.delete.message", &[("title", &title)]), + { + let this = this.clone(); + move |_, cx| this.update(cx, |this, cx| this.close_dialog(cx)) + }, + ) + .confirm(ely_ui::i18n::text(cx, "gallery.delete.confirm", &[])) + .destructive() + .on_confirm(move |_, cx| { + this.update(cx, |this, cx| this.delete_confirmed(cx)); + }) + .into_any_element() +} diff --git a/crates/ely-ui/examples/shell/main.rs b/crates/ely-ui/examples/shell/main.rs new file mode 100644 index 0000000..6816d8d --- /dev/null +++ b/crates/ely-ui/examples/shell/main.rs @@ -0,0 +1,479 @@ +//! Every shell region; page two drops the right panel. +mod dialogs; +mod probe; +mod props; + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use dialogs::Overlay; +use ely_gpui_component::{ + feedback::EmptyState, + forms::TextInput, + navigation::{EditorTab, EditorTabs}, + primitives::{FocusRing as _, IconName}, + shell::{StatusBar, StatusBarItem}, + theme::ActiveTheme, +}; +use ely_platform::AppId; +use ely_ui::{LongTask, Root, Shell, TaskState}; +use gpui::{ + App, AppContext as _, Bounds, Context, Entity, FocusHandle, InteractiveElement as _, + IntoElement, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement as _, + Styled, Window, canvas, div, prelude::FluentBuilder as _, px, +}; +use props::Props; + +fn main() { + ely_ui::run(AppId::Doc, gallery); +} + +fn gallery(app: AppId, window: &mut Window, cx: &mut App) -> Entity { + let geometry = Rc::new(RefCell::new(None)); + let view = cx.new(|cx| Gallery::new(window, cx, geometry)); + let root = ely_ui::root(app, Some(view.clone().into()), window, cx); + if std::env::var("ELY_GALLERY_PROBE").as_deref() == Ok("1") { + let next = (root.clone(), view.clone()); + window.on_next_frame(move |window, cx| probe::run(next.0, next.1, 0, window, cx)); + } + root +} + +pub(crate) struct DemoPage { + id: &'static str, + title: SharedString, + format: &'static str, +} + +fn page(id: &'static str, title: &'static str, format: &'static str) -> DemoPage { + DemoPage { + id, + title: title.into(), + format, + } +} + +pub(crate) struct Gallery { + focus: FocusHandle, + page: SharedString, + pages: Vec, + selection: Vec, + dialog: Option, + task: Option>, + beats_began: Arc, + chip: FocusHandle, + until: Option, + frames: u8, + probe_focused: Option, + geometry: Rc>>>, + content: Entity, + nav: Entity