diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34647b6..f7ae5c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,12 @@ on: env: CARGO_TERM_COLOR: always + # CI validates release code paths; the tag workflow pays for production LTO. + CARGO_PROFILE_RELEASE_LTO: "off" + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" jobs: - test: + tests: name: Tests and interoperability runs-on: ubuntu-latest steps: @@ -17,88 +20,72 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - name: Reference tools - run: sudo apt-get update && sudo apt-get install -y zip unzip p7zip-full zstd - - # The window is part of the workspace, so this job builds it too. eframe - # needs these headers on Linux; on Windows and macOS nothing is required - # because the system already ships them. + run: >- + sudo apt-get update && sudo apt-get install -y zip unzip p7zip-full + zstd - name: Window dependencies - run: sudo apt-get install -y libxkbcommon-dev libwayland-dev libxrandr-dev libxi-dev libgl1-mesa-dev libxcursor-dev libxinerama-dev - - run: cargo clippy --workspace --all-targets -- -D warnings + run: >- + sudo apt-get install -y libxkbcommon-dev libxkbcommon-x11-dev + libwayland-dev libxrandr-dev libxi-dev libgl1-mesa-dev + libxcursor-dev libxinerama-dev libfontconfig1-dev + + # The GUI test binary is linked once, on Linux. Platform jobs validate + # their release builds without linking this large GPUI test binary again. + - run: cargo clippy --workspace --bins -- -D warnings - run: cargo test --workspace - - name: Build without the C codecs (pure Rust) - run: cargo build --release --no-default-features - - run: cargo build --release - - name: Interoperability verified by hash - run: bash interop.sh - # The installer was written on Windows: this is the only thing checking - # that it really works on Linux, there and back. - - name: Installer, there and back - run: | - ./install.sh - test -x "$HOME/.local/bin/arca" && test -x "$HOME/.local/bin/arca-gui" - "$HOME/.local/bin/arca" --version - ./install.sh --uninstall - test ! -e "$HOME/.local/bin/arca" && test ! -e "$HOME/.local/bin/arca-gui" + # Interoperability exercises the CLI; there is no reason to build GPUI + # just to obtain arca for this check. + - name: Build CLI without the C codecs + run: cargo build --no-default-features -p arca-cli + - name: Build CLI with the default codecs + run: cargo build -p arca-cli + - name: Interoperability verified by hash + run: ARCA="$GITHUB_WORKSPACE/target/debug/arca" bash interop.sh - windows: - name: Build on Windows - runs-on: windows-latest + build: + name: Build on ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux + os: ubuntu-latest + - name: Windows + os: windows-latest + - name: macOS + os: macos-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - run: cargo build --release - - run: cargo test --workspace - # No continue-on-error: the release has been building this without a net - # since v0.3.0, so letting it slide here only hid a breakage until - # publishing time. It lives outside the workspace, hence the directory. + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - name: Window dependencies + if: matrix.os == 'ubuntu-latest' + run: >- + sudo apt-get update && sudo apt-get install -y + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libxrandr-dev + libxi-dev libgl1-mesa-dev libxcursor-dev libxinerama-dev + libfontconfig1-dev + + - name: Build release binaries + if: matrix.os != 'windows-latest' + run: cargo build --release + + - name: Build Windows release binaries + if: matrix.os == 'windows-latest' + run: cargo build --release --target x86_64-pc-windows-msvc - name: Shell extension + if: matrix.os == 'windows-latest' working-directory: windows/arca-shell run: | cargo build --release cargo test cargo clippy --release --all-targets -- -D warnings - - # The installer used to be compiled for the first time during a release, - # which is where two breakages were found: once a wrong path to the DLL, - # once Pascal that did not compile. Neither was visible until a tag had - # already been pushed. It is built here now, on every push, with the same - # arguments the release uses. - - name: Windows installer - shell: bash - run: | - set -eu - cargo build --release --target x86_64-pc-windows-msvc - cargo build --release --target x86_64-pc-windows-msvc \ - --manifest-path windows/arca-shell/Cargo.toml - # The installer stamps the version into AppxManifest.xml with Inno's - # Pascal, whose strings go through the ANSI code page: lossless only - # while that file stays ASCII. It used to be Spanish with accents, - # and no amount of Pascal made that survive the round trip. - if LC_ALL=C grep -n '[^[:print:][:space:]]' windows/AppxManifest.xml; then - echo "AppxManifest.xml has non-ASCII bytes on those lines"; exit 1 - fi - ISCC="/c/Program Files (x86)/Inno Setup 6/ISCC.exe" - test -f "$ISCC" || choco install innosetup -y --no-progress - BIN='//DBinDir=..\target\x86_64-pc-windows-msvc\release' - SHELL_DIR='//DShellDir=arca-shell\target\x86_64-pc-windows-msvc\release' - "$ISCC" //DVersion=0.0.0 "$BIN" "$SHELL_DIR" windows/arca.iss - test -f dist/arca-setup-0.0.0-x86_64.exe - - macos: - name: Build on macOS - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - run: cargo build --release - - run: cargo test --workspace - - name: Installer, there and back - run: | - ./install.sh - test -x "$HOME/.local/bin/arca" && test -x "$HOME/.local/bin/arca-gui" - "$HOME/.local/bin/arca" --version - ./install.sh --uninstall - test ! -e "$HOME/.local/bin/arca" && test ! -e "$HOME/.local/bin/arca-gui" diff --git a/.gitignore b/.gitignore index 5957ce7..2539ba7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target **/target -Cargo.lock +# Keep reproducible dependency graphs for the workspace. +!Cargo.lock windows/salida/ *.zip *.tar diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9a2e72c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# Arca contribution guide + +This file defines the project-wide rules for agents and contributors. More-specific `AGENTS.md` files override these rules for their directory. + +## Project shape + +Arca is a cross-platform archiver written in Rust. The workspace contains: + +- `arca-core`: shared errors, bounded parsing, limits, and path safety. +- `arca-zip`: ZIP/Zip64 reading and writing, compression, and encryption. +- `arca-tar`: TAR reading and writing. +- `arca-cli`: the `arca` command-line binary. +- `arca-gui`: the desktop window. +- `arca-icons`: desktop file-type icons. +- `arca-drag`: drag-and-drop integration. +- `arca-net`: networking support. +- `windows/arca-shell`: Windows Explorer integration; it is outside the workspace. + +Read `README.md` before making broad changes. Design rationale and project history live in `Claude outputs/CLAUDE.md` and `docs/plans/`. + +## General rules + +- Keep changes focused and minimal. Do not rewrite unrelated code or discard existing working-tree changes. +- Reuse existing helpers, types, and patterns before adding new ones. +- Preserve the safe-Rust boundary: parsers that process untrusted archive bytes must remain free of `unsafe` code. +- Every parser change must include a test for malformed or truncated input and must not panic. +- Keep internal identifiers and source strings ASCII-only. User-facing Spanish text and Markdown documentation must use correct accents. +- Do not add comments to `src/` unless they explain a non-obvious safety or correctness invariant. Build files and CI configuration may include explanatory comments. +- Never publish benchmark numbers without the command needed to reproduce them. + +## Rust workflow + +Run the narrowest relevant checks while iterating, then run the full checks before handing work over: + +```sh +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +cargo build --release --no-default-features +cargo build --release +bash interop.sh +``` + +For Windows-only changes, also run the commands from the relevant directory, including `cargo test` and `cargo clippy --release --all-targets -- -D warnings` in `windows/arca-shell` when applicable. + +## Commits + +Use [Conventional Commits](https://www.conventionalcommits.org/) in **English**. + +Format: + +```text +(): +``` + +Rules: + +- Use a lowercase type and scope; omit the scope when it adds no information. +- Write the subject in English, imperative mood, in sentence case, without a trailing period. +- Keep the subject concise (preferably 72 characters or fewer). +- Use the body to explain **why**, not to restate the diff. Write it in English too. +- Add `!` after the type or scope for a breaking change and explain the migration in the body. +- Do not combine unrelated changes in one commit. + +Allowed types: + +- `feat`: user-visible functionality +- `fix`: bug fix +- `perf`: measurable performance improvement +- `refactor`: behavior-preserving code change +- `test`: tests only +- `docs`: documentation only +- `build`: build system or dependency changes +- `ci`: continuous-integration changes +- `chore`: maintenance that does not fit the categories above +- `revert`: revert a previous commit + +Good examples: + +```text +feat(arca-cli): add archive password command +fix(arca-zip): reject truncated central directory +perf(arca-gui): avoid rebuilding rows during drag selection +docs: document interoperability checks +``` + +Pull request titles should follow the same format. The description should state the user-visible or technical outcome, relevant tests, and any platform limitations. + +## Documentation and releases + +- Put architectural plans in `docs/plans/`. +- Keep `README.md` focused on user-facing usage, supported formats, interoperability, and reproducible checks. +- Keep brand rules in `brand/BRAND.md`. +- Do not tag a release until the tag version matches `Cargo.toml` and CI is green. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7074244 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,9325 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[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 = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[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 = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[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 = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arca-cli" +version = "0.6.6" +dependencies = [ + "arca-core", + "arca-tar", + "arca-zip", + "clap", + "flate2", + "rayon", + "winresource", +] + +[[package]] +name = "arca-core" +version = "0.6.6" +dependencies = [ + "crc32fast", +] + +[[package]] +name = "arca-drag" +version = "0.6.6" +dependencies = [ + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "arca-gui" +version = "0.6.6" +dependencies = [ + "arca-core", + "arca-drag", + "arca-icons", + "arca-net", + "arca-tar", + "arca-zip", + "clipboard-win", + "flate2", + "gpui-component", + "gpui-kit-assets", + "gpui-pre", + "gpui-pre-platform", + "image", + "rayon", + "rfd", + "sha2 0.10.9", + "sys-locale", + "winresource", +] + +[[package]] +name = "arca-icons" +version = "0.6.6" +dependencies = [ + "windows 0.58.0", +] + +[[package]] +name = "arca-net" +version = "0.6.6" +dependencies = [ + "windows 0.58.0", +] + +[[package]] +name = "arca-tar" +version = "0.6.6" +dependencies = [ + "arca-core", +] + +[[package]] +name = "arca-zip" +version = "0.6.6" +dependencies = [ + "aes", + "arca-core", + "crc32fast", + "ctr", + "flate2", + "getrandom 0.2.17", + "hmac", + "libdeflater", + "pbkdf2", + "rayon", + "sha1", + "zstd", +] + +[[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 = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[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-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[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", +] + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "ashpd" +version = "0.13.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "getrandom 0.4.3", + "serde", + "serde_repr", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a8ec73eb862508b7041723c89386365894b4e7d9f6998bf1b8529e5b0ee254" +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.5", +] + +[[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.20", + "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", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base62" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" + +[[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 = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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 = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[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.1", + "polling", + "rustix", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop", + "rustix", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[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 = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[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", + "zeroize", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[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 = "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.1", + "block", + "cocoa-foundation 0.2.1", + "core-foundation 0.10.1", + "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "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 = "compression-codecs" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "100590da849306918656ffbb22576bbdfc1382b50ad10d1d50ec177d3b205fb2" +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_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 = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[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 = "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +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.1", + "core-foundation 0.10.1", + "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.1", + "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.1", + "core-foundation 0.10.1", + "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.1", + "block", + "cfg-if", + "core-foundation 0.10.1", + "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.1", + "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.1", + "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.1", + "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 = "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[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 = [ + "hybrid-array", +] + +[[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.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[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", + "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 = "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 = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +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.2", + "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.1", + "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.5", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "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 = "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.5+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "encoding_rs" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a7a45518d2863d18aa47f4a0cf9faec2aa4304cc09df5e41299f276b3ad135e" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "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 = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[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 = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "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.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[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 = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +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.5", +] + +[[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 = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[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.5", +] + +[[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 = "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", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[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 = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[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 = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[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.20", + "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.1", + "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.1", +] + +[[package]] +name = "gpui-base" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2caaf00ebe0482774dd370a82a936edf4bf19a18a0d1a39353d20ecf61f70330" +dependencies = [ + "aho-corasick", + "anyhow", + "async-channel", + "chrono", + "futures", + "gpui-pre", + "gpui-pre-macros", + "gpui-pre-platform", + "gpui-pre-sum-tree", + "html5ever", + "instant", + "lsp-types", + "markdown", + "markup5ever_rcdom", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "raw-window-handle", + "regex", + "ropey", + "schemars", + "serde", + "serde_json", + "smallvec", + "smol", + "syntect", + "tracing", + "unicode-segmentation", + "web-time", +] + +[[package]] +name = "gpui-component" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd7938c395be32cea6127b92f7206501ad1fe7e121a609de1aafdedcb4d98be" +dependencies = [ + "anyhow", + "chrono", + "core-text", + "enum-iterator", + "gpui-base", + "gpui-component-macros", + "gpui-kit-assets", + "gpui-pre", + "gpui-pre-macros", + "gpui-pre-sum-tree", + "instant", + "itertools 0.13.0", + "log", + "lsp-types", + "markdown", + "notify", + "num-traits", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "once_cell", + "paste", + "raw-window-handle", + "resvg 0.45.1", + "ropey", + "rust-i18n", + "schemars", + "serde", + "serde_json", + "serde_repr", + "smallvec", + "smol", + "tracing", + "uuid", + "windows 0.58.0", +] + +[[package]] +name = "gpui-component-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dacef57cadf1ce9b05b1f1ba6b3f3fed2521fd814f548eea0e4d8e43c5857795" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-kit-assets" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb6fed67d6488fa9333b6534d17f5eaef32b83e3d7148b83a3d444a1884bd1c" +dependencies = [ + "anyhow", + "gpui-pre", + "gpui-pre-reqwest", + "log", + "rust-embed", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "gpui-pre" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20199390dbd6cfbb7f0cb2ca57b46120cc1f434746a77367d2780657e0973725" +dependencies = [ + "accesskit", + "anyhow", + "async-channel", + "async-task", + "bindgen", + "bitflags 2.13.1", + "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", + "gpui-pre-ztracing", + "heapless", + "image", + "inventory", + "itertools 0.14.0", + "log", + "lyon", + "num_cpus", + "parking", + "parking_lot", + "pin-project", + "pollster 0.4.0", + "postage", + "profiling", + "rand 0.9.5", + "raw-window-handle", + "regex", + "resvg 0.46.0", + "schemars", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "spin 0.10.1", + "strum", + "taffy", + "thiserror 2.0.20", + "tracing", + "ttf-parser", + "url", + "usvg 0.46.0", + "uuid", + "waker-fn", + "web-time", + "windows 0.62.2", + "zed-font-kit", + "zed-scap", +] + +[[package]] +name = "gpui-pre-apple" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4506904f40d7f214d72d9a1603fc8dc00b9884bd9c80770f215b3d12cac771b8" +dependencies = [ + "anyhow", + "block", + "cbindgen", + "cocoa 0.26.0", + "core-foundation 0.10.1", + "core-video", + "derive_more", + "etagere", + "foreign-types", + "gpui-pre", + "gpui-pre-collections", + "image", + "log", + "metal", + "objc", + "parking_lot", +] + +[[package]] +name = "gpui-pre-collections" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c8b0623a1a129d503d16dc38f8e51f5dd82e1e381dd19ea86054b983859cfc" +dependencies = [ + "gpui-pre-util", + "indexmap", + "rustc-hash 2.1.3", +] + +[[package]] +name = "gpui-pre-derive-refineable" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58e1b01bf92752443d78beae3b77d80896d1e7815078df3e3f50340c5ba96768" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-http-client" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9bfcb50ec19f2a2e814448e295e471647fa05f58fb211a270d84a77b9bf9fd8" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd1d912c039641f0236b1701b7871bfddfb4926ffd1915b3958b73d03177250" +dependencies = [ + "accesskit", + "accesskit_unix", + "anyhow", + "as-raw-xcb-connection", + "ashpd 0.13.13", + "bitflags 2.13.1", + "bytemuck", + "calloop", + "calloop-wayland-source", + "filedescriptor", + "futures", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-util", + "gpui-pre-wgpu", + "libc", + "log", + "notify-rust", + "oo7", + "open", + "parking_lot", + "raw-window-handle", + "smallvec", + "smol", + "strum", + "url", + "uuid", + "wayland-backend", + "wayland-client", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-plasma", + "wayland-protocols-wlr", + "x11-clipboard", + "x11rb", + "xkbcommon", + "zed-scap", + "zed-xim", +] + +[[package]] +name = "gpui-pre-macos" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15263eafcbe4e81aec7eb24a47d4c6c88f5e29ba2ba8ad86c430ab8e472883dc" +dependencies = [ + "accesskit", + "accesskit_macos", + "anyhow", + "async-task", + "block", + "block2 0.6.2", + "cocoa 0.26.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", + "ctor", + "dispatch2", + "foreign-types", + "futures", + "gpui-pre", + "gpui-pre-apple", + "gpui-pre-collections", + "gpui-pre-media", + "gpui-pre-util", + "image", + "itertools 0.14.0", + "libc", + "log", + "mach2", + "metal", + "objc", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-user-notifications", + "parking_lot", + "pathfinder_geometry", + "raw-window-handle", + "semver", + "smallvec", + "strum", + "uuid", + "zed-font-kit", +] + +[[package]] +name = "gpui-pre-macros" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e29384b3145f0143a17187b35339779398fe4c1d4534320d6a05594248a49965" +dependencies = [ + "heck 0.5.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-media" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4010771de1c32efc395cc5edf64a8d43350437763764d4032cccf01639c23a" +dependencies = [ + "anyhow", + "bindgen", + "core-foundation 0.10.1", + "core-video", + "foreign-types", + "metal", + "objc", +] + +[[package]] +name = "gpui-pre-perf" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b8b89fd6fa5330477e46146a2fc2dc47d0bb1084763a9bd9b3da4173e8bfcf" +dependencies = [ + "gpui-pre-collections", + "serde", + "serde_json", +] + +[[package]] +name = "gpui-pre-platform" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e85fc9ec7ea5ff87ffb69edd0e23254e5627bc95758de232fb876d578ebd9e6d" +dependencies = [ + "console_error_panic_hook", + "gpui-pre", + "gpui-pre-linux", + "gpui-pre-macos", + "gpui-pre-web", + "gpui-pre-windows", +] + +[[package]] +name = "gpui-pre-refineable" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8dd11743345d7ee24768b47ad4bade35e05f68400b843e19f149f08fc99bf59" +dependencies = [ + "gpui-pre-derive-refineable", +] + +[[package]] +name = "gpui-pre-reqwest" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05be23908e707966824f8c51a609904b7f30739e8d915e120a53c1b995ba964c" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry 0.4.0", +] + +[[package]] +name = "gpui-pre-scheduler" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28d65279f6eb7e74107457140ccd7aae3a3fcb12c4690568ac1748985b3b71e" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d597ac929a6c0f5f99a5de81322a67c061c3d2a87a558f8e9c6689ecc61ca75d" +dependencies = [ + "schemars", + "serde", + "smol_str", +] + +[[package]] +name = "gpui-pre-sum-tree" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c48ce7d9baabc52be67165433a11874b5286125d8e19edf1a8415f5735f478" +dependencies = [ + "gpui-pre-ztracing", + "heapless", + "log", + "rayon", + "tracing", +] + +[[package]] +name = "gpui-pre-util" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd91e2d2ac036e4bd496543cc4ae9b5108430a43a65bdaeecb79ef9e2e43e80b" +dependencies = [ + "anyhow", + "log", + "which", +] + +[[package]] +name = "gpui-pre-util-macros" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402f0aef1b2874792bde32aec90a9b133153c62eeed7a7d55c4b622312096eb0" +dependencies = [ + "gpui-pre-perf", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-web" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99e4d7b8867307c3b0599ae4759f86960c6eaa983021eb1a2a34a47815ca071a" +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", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_thread", + "web-sys", + "web-time", +] + +[[package]] +name = "gpui-pre-wgpu" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567f1fb79e72c80001d9e358e9f552e2696d79ad71af76aab60da5684848fd98" +dependencies = [ + "anyhow", + "bytemuck", + "cosmic-text", + "etagere", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-util", + "itertools 0.14.0", + "log", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "swash", + "unicode-bidi", + "unicode-segmentation", + "web-sys", + "wgpu", + "zed-font-kit", +] + +[[package]] +name = "gpui-pre-windows" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45918b16e62e89ef4952a6e140297f716934bf6f9b21206ad6ce54faa7316005" +dependencies = [ + "accesskit", + "accesskit_windows", + "anyhow", + "dunce", + "etagere", + "futures", + "gpui-pre", + "gpui-pre-collections", + "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 0.6.1", +] + +[[package]] +name = "gpui-pre-zlog" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24609786bd3dd9291b440a97ad700003827bbf55b69223038253ea0b401b3c36" +dependencies = [ + "anyhow", + "chrono", + "gpui-pre-collections", + "log", +] + +[[package]] +name = "gpui-pre-ztracing" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b59bfae633cc76bd10488b931308f04e3b916b211d3cb76b0e401eca7cf4ea5" +dependencies = [ + "gpui-pre-zlog", + "gpui-pre-ztracing-macro", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "gpui-pre-ztracing-macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a09e0bf58aede45b8e7eed6e8d5d6552381c431014d62b349b0133008737bd" + +[[package]] +name = "granit-parser" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ec0d45986cd51c847c75c5b69a00852c4fc84d0e5e79f041173f73437d0cdf" +dependencies = [ + "arraydeque", + "smallvec", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[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.1", + "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.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[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" + +[[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 = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[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 = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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 = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[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 0.14.2", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "tiff", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "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 = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.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 = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[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.1", + "core-foundation-sys", + "leaky-cow", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[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 = "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 = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "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.1", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "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 = "libdeflate-sys" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6949d73714ba8c32d2757405b89c94e427f64f078a4041debe07e1b8e9e850e9" +dependencies = [ + "cc", +] + +[[package]] +name = "libdeflater" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b72b274104f747cb65358c918e38c6cd4a69937937a36c8ad3cfd516c9c471e" +dependencies = [ + "libdeflate-sys", +] + +[[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 = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +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-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-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + +[[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" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[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 = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "serde", + "unicode-id", +] + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + +[[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.1", + "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 = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multiversion" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "target-features", +] + +[[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.1", + "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.20", + "unicode-ident", +] + +[[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 = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[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 = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.13.1", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[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 = "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", +] + +[[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.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "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.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[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.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.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.1", + "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.1", + "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-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.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-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[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.1", + "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-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "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.1", + "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.1", + "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.1", + "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.1", + "block2 0.6.2", + "objc2 0.6.4", + "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.1", + "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.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 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.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-location", + "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 = "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", + "ashpd 0.13.13", + "async-fs", + "async-io", + "async-lock", + "blocking", + "cbc", + "cipher", + "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 = "open" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[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.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.8", +] + +[[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 0.13.1", + "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.1", + "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 = "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.13+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 = "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 = "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 = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[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" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[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_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[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.20", + "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", + "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.1", +] + +[[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.4", + "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.1", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[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.5", +] + +[[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.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif 0.13.3", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.15.3", + "tiny-skia", + "usvg 0.45.1", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "resvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" +dependencies = [ + "gif 0.14.2", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.16.1", + "tiny-skia", + "usvg 0.46.0", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd 0.11.1", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster 0.4.0", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[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 = "ropey" +version = "2.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" +dependencies = [ + "str_indices", +] + +[[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 = "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", + "shellexpand", + "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 = [ + "globset", + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rust-i18n" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c43fd69c20da13756643a5bf51ae799965465c78e05c57a1242a736aaa56527" +dependencies = [ + "globwalk", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "086ec8a9eaa6afda33919b9bc1661c1f4e017c40e99bbd33ca98f6e1cb6ca91f" +dependencies = [ + "glob", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "syn 2.0.119", +] + +[[package]] +name = "rust-i18n-support" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac02dcb9a01ec145d0a5534ea9554a966050ca250b346bb2a9ab0fcdafab5979" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "normpath", + "serde", + "serde-saphyr", + "serde_json", + "siphasher", + "toml 0.8.23", + "triomphe", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "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 = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.5", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[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 = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "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 = "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-saphyr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3afb591f9cdb6223c88ba39269aff895620c7f0716dc42b705b5733d5c7c0823" +dependencies = [ + "annotate-snippets", + "base64 0.23.1", + "encoding_rs_io", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde_core", + "smallvec", + "zmij", +] + +[[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.5", +] + +[[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.5", +] + +[[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.5", +] + +[[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 = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[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 = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs 6.0.0", +] + +[[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-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 = "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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[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 = [ + "borsh", + "serde_core", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[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.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[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.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "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.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 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.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo 0.13.1", + "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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror 2.0.20", + "walkdir", +] + +[[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 = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.20", + "windows 0.61.3", + "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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[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", + "weezl", + "zune-jpeg 0.5.15", +] + +[[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", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[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.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +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.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +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 = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[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 = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[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 = "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-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-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 = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree 0.20.0", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.15.3", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[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 0.14.0", + "kurbo 0.13.1", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.16.1", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +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 = "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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[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.5", + "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-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[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-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "log", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[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 = "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.1", + "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.1", + "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.20", + "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.1", + "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", + "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.20", + "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.1", + "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.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.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.20", + "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.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "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.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +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.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + +[[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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + +[[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.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.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.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.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.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.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.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.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.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.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 = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml 1.1.5+spec-1.1.0", + "version_check", +] + +[[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 = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "rustix", + "x11rb-protocol", + "xcursor", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcb" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" +dependencies = [ + "bitflags 2.13.1", + "libc", + "quick-xml", + "x11", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xim-ctext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac61a7062c40f3c37b6e82eeeef835d5cc7824b632a72784a89b3963c33284c" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "xim-parser" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcee45f89572d5a65180af3a84e7ddb24f5ea690a6d3aa9de231281544dd7b7" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "as-raw-xcb-connection", + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "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.5", + "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.1", + "byteorder", + "core-foundation 0.10.1", + "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 = "zed-xim" +version = "0.4.0-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642" +dependencies = [ + "ahash", + "hashbrown 0.14.5", + "log", + "x11rb", + "xim-ctext", + "xim-parser", +] + +[[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.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "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.5", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[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.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.3", +] + +[[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", + "url", + "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.5", + "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.5", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index 1894299..186f52b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,20 @@ zstd = { version = "0.13", features = ["zstdmt"] } rayon = "1" clap = { version = "4", features = ["derive"] } +# `cargo run` compila sin optimizar, y eso con GPUI no es "un poco mas lento": +# su motor de layout, el modelador de texto y el rasterizador rehacen su trabajo +# en cada fotograma, y a opt-level 0 eso se ve como tirones al arrastrar y al +# marcar. Nada de esto es codigo de Arca ni se depura nunca, asi que las +# dependencias van optimizadas tambien en depuracion. +[profile.dev.package."*"] +opt-level = 3 + +# Y el codigo de Arca a 1, que es lo que necesitan el ordenado de la lista y el +# recorrido de las entradas para no notarse. A este nivel el depurador sigue +# parando donde se le dice. +[profile.dev] +opt-level = 1 + # Perfil agresivo: requisito R1 (arranque) y R3/R4 (rendimiento) [profile.release] opt-level = 3 diff --git a/Claude outputs/CLAUDE.md b/Claude outputs/CLAUDE.md index 0afc991..067ac3a 100644 --- a/Claude outputs/CLAUDE.md +++ b/Claude outputs/CLAUDE.md @@ -37,7 +37,7 @@ Elección de códec por algoritmo: | `arca-zip` | ZIP con Zip64; store, deflate y Zstandard | **prohibido** | | `arca-tar` | TAR ustar con checksum | **prohibido** | | `arca-cli` | Binario `arca` | permitido, sin usar | -| `arca-gui` | Interfaz grafica con egui, en Rust puro | **prohibido** | +| `arca-gui` | Interfaz grafica con GPUI Kit | **permitido** | | `windows/arca-shell` | Extensión del menú contextual (COM) | necesario | `windows/arca-shell` está **excluido del workspace** para que `cargo build` siga diff --git a/arca-gui/Cargo.toml b/arca-gui/Cargo.toml index a7d637e..dece7df 100644 --- a/arca-gui/Cargo.toml +++ b/arca-gui/Cargo.toml @@ -18,32 +18,9 @@ arca-tar.workspace = true arca-icons.workspace = true rayon.workspace = true -# egui in pure Rust. wgpu is dropped on purpose: glow starts sooner and does -# not drag in the Vulkan/DX12 runtime, which adds nothing to listing a zip. -# -# default-features = false also dropped accesskit, which is what exposes the -# window to Narrator and NVDA. Turning it off was not a decision, it was a side -# effect, and it left the window unreadable to a screen reader. -# -# x11 and wayland are already reaching winit through glutin-winit's own -# defaults, so they are not a fix; naming them here stops that from being an -# accident that a dependency could take away without anyone noticing. -eframe = { version = "0.29", default-features = false, features = [ - "glow", - "default_fonts", - "accesskit", - "x11", - "wayland", -] } - # Dialogos de fichero nativos. Es lo unico que toca la plataforma. rfd = "0.15" flate2.workspace = true -# La feature `image` trae el cargador que egui usa para dibujar una imagen -# desde bytes en memoria, que es lo que el visor tiene: una entrada sacada del -# archivo y nunca escrita en disco. Los formatos se eligen en el propio crate -# `image`, mas abajo. -egui_extras = { version = "0.29", features = ["image"] } # Solo los formatos que alguien mete en un zip y espera poder mirar. Sin las # features por defecto: son una docena de decodificadores y cada uno es peso en @@ -57,6 +34,16 @@ image = { version = "0.25", default-features = false, features = [ ] } sys-locale = "0.3" +# GPUI comes from the GPUI Kit release train (`gpui-pre*`), not from a zed git +# rev. gpui-component is built against `gpui-pre ^0.3`, and a git pin here would +# put two copies of gpui in the graph, which does not link. Renaming the +# packages back to `gpui` / `gpui_platform` keeps every `use gpui::...` intact. +gpui = { package = "gpui-pre", version = "0.3", default-features = false } +gpui-component = { version = "0.6" } +# The icon set gpui-component's `IconName` resolves against. It is already in +# the tree as a gpui-component dependency; naming it here is what lets +# `application()` be handed the asset source. +gpui-kit-assets = { version = "0.6" } # SHA-256 para comprobar lo que se baja antes de ejecutarlo. RustCrypto, safe # Rust puro, la misma familia que el sha1 del cifrado de arca-zip. sha2 = "0.10" @@ -68,6 +55,20 @@ sha2 = "0.10" [target."cfg(windows)".dependencies] clipboard-win = "5" +[target."cfg(windows)".dependencies.gpui_platform] +package = "gpui-pre-platform" +version = "0.3" + +[target.'cfg(target_os = "linux")'.dependencies.gpui_platform] +package = "gpui-pre-platform" +version = "0.3" +features = ["wayland", "x11"] + +[target.'cfg(target_os = "macos")'.dependencies.gpui_platform] +package = "gpui-pre-platform" +version = "0.3" +features = ["font-kit"] + [target."cfg(windows)".build-dependencies] winresource = "0.1" diff --git a/arca-gui/src/archive_ops/io.rs b/arca-gui/src/archive_ops/io.rs new file mode 100644 index 0000000..ffdc3ef --- /dev/null +++ b/arca-gui/src/archive_ops/io.rs @@ -0,0 +1,541 @@ +//! Archive readers, extractors, and filesystem writers. + +use crate::{archive_stem, detect, Format, Message}; +use arca_core::{Codec, Entry, Level}; +use arca_tar::{TarReader, TarWriter}; +use arca_zip::{ZipArchive, ZipWriter}; +use rayon::prelude::*; +use std::collections::HashSet; +use std::fs::{self, File}; +use std::io::{BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::Sender; + +pub(crate) const BUF: usize = 256 * 1024; + +pub(crate) fn open_source(archive: &Path, format: Format) -> std::io::Result> { + let f = BufReader::with_capacity(BUF, File::open(archive)?); + Ok(match format { + Format::TarGz => Box::new(flate2::read::GzDecoder::new(f)), + _ => Box::new(f), + }) +} + +pub(crate) fn list_entries(archive: &Path) -> arca_core::Result> { + let Some(format) = detect(archive) else { + return Err(arca_core::Error::Unsupported(format!( + "unrecognized extension in '{}'", + archive.display() + ))); + }; + match format { + Format::Zip => Ok(ZipArchive::open(File::open(archive)?)?.entries().to_vec()), + _ => { + let mut r = TarReader::new(open_source(archive, format)?); + let mut v = Vec::new(); + while let Some(e) = r.next_entry()? { + v.push(e.entry.clone()); + r.skip_data(&e)?; + } + Ok(v) + } + } +} + +// Only the central directory is read, which is a few kilobytes at the tail of +// the file. A .tar has no encryption to look for. +pub(crate) fn is_encrypted(archive: &Path) -> bool { + if detect(archive) != Some(Format::Zip) { + return false; + } + File::open(archive) + .ok() + .and_then(|f| ZipArchive::open(f).ok()) + .map(|a| a.has_encrypted()) + .unwrap_or(false) +} + +// The icon the desktop shows for this kind of file, kept as a texture per +// extension. Without the cache a listing of 1513 entries would ask the shell +// 1513 times a frame; with it, once per kind for the life of the window. +// +// A `None` in the map is a remembered failure, so a kind the system has no +// answer for is not asked about again every frame. +// What the desktop calls this kind of file, cached by extension the way the +// icons are: the answer is the same for every .txt in the archive, and asking +// the shell fifteen hundred times for it would be fifteen hundred round trips +// to another thread while the list is being drawn. +// A folder of its own per archive, so two archives holding a file with the same +// name do not overwrite each other's copy. safe_name is what keeps an entry +// called "../../evil" from landing outside it. +/// Where the version before the last change is kept, so it can be put back. +pub(crate) fn undo_path(archive: &Path) -> PathBuf { + let mut name = archive.as_os_str().to_os_string(); + name.push(".arca-undo"); + PathBuf::from(name) +} + +/// Moves the archive out of the way instead of letting the new one overwrite +/// it, so that the change can be taken back. +/// +/// A move, not a copy: the file stays on the volume it was already on and +/// nothing is read or written, so keeping the old version costs the time of a +/// directory entry however big the archive is. What it does cost is the space, +/// until the next change replaces it or the window closes. +pub(crate) fn step_aside(archive: &Path) -> std::io::Result<()> { + let keep = undo_path(archive); + if keep.exists() { + fs::remove_file(&keep)?; + } + fs::rename(archive, &keep) +} + +// One entry straight into memory, for looking at rather than for keeping. +// +// The same walk as `extract_one` without the file at the end of it: a viewer +// that wrote to the temporary folder on the way would have extracted the thing +// it was only supposed to show. +pub(crate) fn read_entry( + archive: &Path, + index: usize, + out: &mut Vec, + password: Option<&str>, +) -> arca_core::Result<()> { + let Some(format) = detect(archive) else { + return Err(arca_core::Error::Unsupported("unknown format".into())); + }; + match format { + Format::Zip => { + let mut a = ZipArchive::open(File::open(archive)?)?; + a.extract_to_with(index, out, password)?; + } + _ => { + // A tar has no index, so the only way to one entry is through all + // the ones before it. + let mut r = TarReader::new(open_source(archive, format)?); + let mut at = 0usize; + while let Some(e) = r.next_entry()? { + if at == index { + r.copy_data(&e, out)?; + return Ok(()); + } + r.skip_data(&e)?; + at += 1; + } + return Err(arca_core::Error::Format( + "that entry is not in the archive any more".into(), + )); + } + } + Ok(()) +} + +pub(crate) fn extract_one( + archive: &Path, + entry: &Entry, + password: Option<&str>, +) -> arca_core::Result { + let room = std::env::temp_dir() + .join("Arca") + .join(archive_stem(archive)); + let path = room.join(arca_core::safe_name(&entry.name)?); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let Some(format) = detect(archive) else { + return Err(arca_core::Error::Unsupported("unknown format".into())); + }; + let mut out = BufWriter::with_capacity(BUF, File::create(&path)?); + match format { + Format::Zip => { + let mut source = BufReader::with_capacity(BUF, File::open(archive)?); + arca_zip::extract_entry_with(&mut source, entry, &mut out, password)?; + } + _ => { + // A tar has no index, so the only way to one entry is through all + // the ones before it. + let mut r = TarReader::new(open_source(archive, format)?); + let mut found = false; + while let Some(e) = r.next_entry()? { + if e.entry.name == entry.name && !e.entry.is_dir { + r.copy_data(&e, &mut out)?; + found = true; + break; + } + r.skip_data(&e)?; + } + if !found { + return Err(arca_core::Error::Format(format!( + "'{}' is not in the archive any more", + entry.name + ))); + } + } + } + out.flush()?; + Ok(path) +} + +// Whatever the desktop opens this kind of file with. The child is left to run +// on its own; the window does not wait for it and does not care what it was. +#[cfg(windows)] +pub(crate) fn launch_with_system(path: &Path) -> arca_core::Result<()> { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + // The empty pair of quotes is the window title `start` insists on eating. + // Without it a quoted path becomes the title and nothing opens. No /WAIT + // either: that would leave a cmd sitting around until the viewer is closed. + std::process::Command::new("cmd") + .creation_flags(CREATE_NO_WINDOW) + .args(["/C", "start", ""]) + .arg(path) + .spawn() + .map_err(arca_core::Error::Io)?; + Ok(()) +} + +#[cfg(not(windows))] +pub(crate) fn launch_with_system(path: &Path) -> arca_core::Result<()> { + let opener = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + std::process::Command::new(opener) + .arg(path) + .spawn() + .map_err(arca_core::Error::Io)?; + Ok(()) +} + +// A button with a picture on it, and a word next to the picture when the button +// is one of the ones worth naming. GPUI Kit supplies the button surface; +// icons and labels remain separate so each can be styled independently. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Answer { + Replace, + ReplaceAll, + Skip, + SkipAll, + Rename, + RenameAll, + Cancel, +} + +// The worker asks the window and blocks until it answers. The "all" answers +// stick, so the question is asked once and not per file. +pub(crate) fn conflict_asker<'a>( + tx: &'a Sender, + replies: &'a std::sync::mpsc::Receiver, +) -> impl Fn(&Path) -> Answer + 'a { + let sticky = std::cell::Cell::new(None::); + move |path: &Path| { + if let Some(a) = sticky.get() { + return a; + } + if tx + .send(Message::Conflict(path.display().to_string())) + .is_err() + { + return Answer::Cancel; + } + let answer = replies.recv().unwrap_or(Answer::Cancel); + if matches!( + answer, + Answer::ReplaceAll | Answer::SkipAll | Answer::RenameAll | Answer::Cancel + ) { + sticky.set(Some(answer)); + } + answer + } +} + +// `claimed` holds the names this run has already handed out. A .zip decides +// every destination before writing anything, so `exists()` alone would give two +// entries with the same name the same free name. +pub(crate) fn free_name(path: &Path, claimed: &HashSet) -> PathBuf { + let dir = path.parent().map(PathBuf::from).unwrap_or_default(); + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let ext = path + .extension() + .map(|s| format!(".{}", s.to_string_lossy())) + .unwrap_or_default(); + for n in 1..10_000u32 { + let candidate = dir.join(format!("{stem} ({n}){ext}")); + if !candidate.exists() && !claimed.contains(&candidate) { + return candidate; + } + } + path.to_path_buf() +} + +// Returns None when the entry must be skipped, and Err on cancel. +pub(crate) fn dest_path( + dest: &Path, + name: &str, + is_dir: bool, + ask: &dyn Fn(&Path) -> Answer, + claimed: &mut HashSet, +) -> arca_core::Result> { + let path = dest.join(arca_core::safe_name(name)?); + if is_dir { + fs::create_dir_all(&path)?; + return Ok(None); + } + if let Some(p) = path.parent() { + fs::create_dir_all(p)?; + } + if !path.exists() && !claimed.contains(&path) { + claimed.insert(path.clone()); + return Ok(Some(path)); + } + let chosen = match ask(&path) { + Answer::Replace | Answer::ReplaceAll => path, + Answer::Skip | Answer::SkipAll => return Ok(None), + Answer::Rename | Answer::RenameAll => free_name(&path, claimed), + Answer::Cancel => return Err(arca_core::Error::Format("cancelled".into())), + }; + claimed.insert(chosen.clone()); + Ok(Some(chosen)) +} + +// A .zip is random access: the central directory says where every entry starts, +// so one thread per core can each open the file and decompress a different one. +// A .tar is a single stream, and a .tar.gz a single gzip stream on top of it, so +// there is nothing to split there and that branch stays sequential. +// +// The directories and the overwrite questions are settled first, in one thread. +// Asking the window from several threads at once would put the same dialog on +// screen twice, and racing on which name is free gives a different result every +// run. +pub(crate) fn extract( + archive: &Path, + dest: &Path, + wanted: &[bool], + // Told how far along this is, and answers whether to carry on. False is + // somebody pressing stop. + notify: &(dyn Fn(usize, usize, &str) -> bool + Sync), + ask: &dyn Fn(&Path) -> Answer, + password: Option<&str>, +) -> arca_core::Result { + let Some(format) = detect(archive) else { + return Err(arca_core::Error::Unsupported("unknown format".into())); + }; + fs::create_dir_all(dest)?; + let mut bytes = 0u64; + let mut claimed: HashSet = HashSet::new(); + + match format { + Format::Zip => { + let a = ZipArchive::open(File::open(archive)?)?; + let mut jobs: Vec<(Entry, PathBuf)> = Vec::new(); + for (i, e) in a.entries().iter().enumerate() { + if !wanted.is_empty() && !wanted.get(i).copied().unwrap_or(true) { + continue; + } + if let Some(path) = dest_path(dest, &e.name, e.is_dir, ask, &mut claimed)? { + jobs.push((e.clone(), path)); + } + } + drop(a); + + let total = jobs.len(); + let done = AtomicUsize::new(0); + let written: Vec = jobs + .par_iter() + .map(|(e, path)| { + let mut source = BufReader::with_capacity(BUF, File::open(archive)?); + let mut f = BufWriter::with_capacity(BUF, File::create(path)?); + let w = arca_zip::extract_entry_with(&mut source, e, &mut f, password)?; + f.flush()?; + if !notify(done.fetch_add(1, Ordering::Relaxed) + 1, total, &e.name) { + return Err(arca_core::Error::Cancelled); + } + Ok(w) + }) + .collect::>>()?; + bytes = written.iter().sum(); + let _ = notify(total, total, ""); + } + _ => { + let mut r = TarReader::new(open_source(archive, format)?); + let total = wanted.len(); + let mut i = 0usize; + while let Some(e) = r.next_entry()? { + if !notify(i, total, &e.entry.name) { + return Err(arca_core::Error::Cancelled); + } + if !wanted.is_empty() && !wanted.get(i).copied().unwrap_or(true) { + r.skip_data(&e)?; + i += 1; + continue; + } + match dest_path(dest, &e.entry.name, e.entry.is_dir, ask, &mut claimed)? { + Some(path) => { + let mut w = BufWriter::with_capacity(BUF, File::create(&path)?); + bytes += r.copy_data(&e, &mut w)?; + w.flush()?; + } + None => r.skip_data(&e)?, + } + i += 1; + } + let _ = notify(i, i, ""); + } + } + Ok(bytes) +} + +pub(crate) fn test_archive( + archive: &Path, + only: Option<&HashSet>, + // Told how far along this is, and answers whether to carry on. False is + // somebody pressing stop. + notify: &(dyn Fn(usize, usize, &str) -> bool + Sync), +) -> arca_core::Result<(usize, Vec)> { + let Some(format) = detect(archive) else { + return Err(arca_core::Error::Unsupported("unknown format".into())); + }; + let mut good = 0usize; + let mut bad = Vec::new(); + + match format { + Format::Zip => { + let mut a = ZipArchive::open(File::open(archive)?)?; + let total = a.len(); + for i in 0..total { + let name = a.entries()[i].name.clone(); + if !notify(i, total, &name) { + return Err(arca_core::Error::Cancelled); + } + if a.entries()[i].is_dir || only.is_some_and(|set| !set.contains(&name)) { + continue; + } + match a.extract_to(i, std::io::sink()) { + Ok(_) => good += 1, + Err(e) => bad.push(format!("{name}: {e}")), + } + } + let _ = notify(total, total, ""); + } + _ => { + let mut r = TarReader::new(open_source(archive, format)?); + let mut i = 0usize; + while let Some(e) = r.next_entry()? { + if !notify(i, i + 1, &e.entry.name) { + return Err(arca_core::Error::Cancelled); + } + if e.entry.is_dir || only.is_some_and(|set| !set.contains(&e.entry.name)) { + r.skip_data(&e)?; + } else { + match r.copy_data(&e, &mut std::io::sink()) { + Ok(_) => good += 1, + Err(err) => bad.push(format!("{}: {err}", e.entry.name)), + } + } + i += 1; + } + let _ = notify(i, i, ""); + } + } + Ok((good, bad)) +} + +pub(crate) fn collect_files(inputs: &[PathBuf]) -> std::io::Result> { + fn walk(p: &Path, base: &Path, out: &mut Vec<(PathBuf, String)>) -> std::io::Result<()> { + let meta = fs::symlink_metadata(p)?; + let rel = p.strip_prefix(base).unwrap_or(p); + let name = rel.to_string_lossy().replace('\\', "/"); + if meta.is_dir() { + let mut children: Vec<_> = fs::read_dir(p)?.collect::>>()?; + children.sort_by_key(|d| d.file_name()); + for c in children { + walk(&c.path(), base, out)?; + } + } else if meta.is_file() { + out.push((p.to_path_buf(), name)); + } + Ok(()) + } + + let mut v = Vec::new(); + for e in inputs { + let base = e.parent().unwrap_or(Path::new("")); + walk(e, base, &mut v)?; + } + Ok(v) +} + +pub(crate) fn compress( + out: &Path, + inputs: &[PathBuf], + format: Format, + codec: Codec, + level: Level, + // Told how far along this is, and answers whether to carry on. False is + // somebody pressing stop. + notify: &(dyn Fn(usize, usize, &str) -> bool + Sync), + password: Option<&str>, +) -> arca_core::Result<(u64, u64)> { + if password.is_some() && format != Format::Zip { + return Err(arca_core::Error::Unsupported( + "encryption only exists in .zip".into(), + )); + } + let files = collect_files(inputs)?; + let total = files.len(); + let mut source_bytes = 0u64; + + match format { + Format::Zip => { + let mut w = ZipWriter::new(BufWriter::with_capacity(BUF, File::create(out)?)); + for (i, (path, name)) in files.iter().enumerate() { + if !notify(i, total, name) { + return Err(arca_core::Error::Cancelled); + } + let meta = fs::metadata(path)?; + let f = BufReader::with_capacity(BUF, File::open(path)?); + w.add_with_password(name, f, codec, level, None, password)?; + source_bytes += meta.len(); + } + w.finish()?; + } + _ => { + let raw = BufWriter::with_capacity(BUF, File::create(out)?); + let sink: Box = if format == Format::TarGz { + Box::new(flate2::write::GzEncoder::new( + raw, + flate2::Compression::new(level.to_flate2()), + )) + } else { + Box::new(raw) + }; + let mut w = TarWriter::new(sink); + for (i, (path, name)) in files.iter().enumerate() { + if !notify(i, total, name) { + return Err(arca_core::Error::Cancelled); + } + let meta = fs::metadata(path)?; + let f = BufReader::with_capacity(BUF, File::open(path)?); + w.add(name, meta.len(), 0, 0o644, f)?; + source_bytes += meta.len(); + } + w.finish()?; + } + } + let _ = notify(total, total, ""); + let final_size = fs::metadata(out).map(|m| m.len()).unwrap_or(0); + Ok((source_bytes, final_size)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Destination { + Beside, + Subfolder, +} diff --git a/arca-gui/src/archive_ops/mod.rs b/arca-gui/src/archive_ops/mod.rs new file mode 100644 index 0000000..b56c6f5 --- /dev/null +++ b/arca-gui/src/archive_ops/mod.rs @@ -0,0 +1,650 @@ +//! Blocking archive jobs and their command-line entry points. + +mod io; +pub(crate) use io::*; + +use crate::i18n::Strings; +use crate::{archive_stem, detect, human, moved_name, sum_for, Format, INSTALLER_LIMIT}; +use arca_core::{Codec, Level}; +use std::collections::HashSet; +use std::fs::{self, File}; +use std::io::{BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; + +pub(crate) enum Job { + Extract { + archives: Vec, + dest: Destination, + password: Option, + }, + Test { + archive: PathBuf, + // The names to check, or all of them. A selection is checked by walking + // the whole archive and skipping what is not in the set: the entries + // have to be read in the order they are filed anyway. + only: Option>, + }, + // Rewriting an archive with a different password, or with none. + Password { + archive: PathBuf, + current: Option, + new: Option, + }, + // Taking entries out. A zip has no hole to leave behind, so this rebuilds + // the archive without them. + Delete { + archive: PathBuf, + names: Vec, + password: Option, + }, + CopyTo { + archive: PathBuf, + dest: PathBuf, + }, + // Getting the new version. Not a job that ends in a text to show: it ends + // in an installer to run, and running it closes Arca. + Update { + tag: String, + installer: String, + sums: String, + }, + // Dragging entries onto a folder of the same archive. + Move { + archive: PathBuf, + // Pairs of what a name is now and what it becomes, without the slash a + // folder carries: both spellings are handled where the move is made. + moves: Vec<(String, String)>, + password: Option, + }, + NewFolder { + archive: PathBuf, + // The whole path with the slash already on it, worked out where the + // folder you are looking at is known. + name: String, + password: Option, + }, + Rename { + archive: PathBuf, + // Both are full paths inside the archive, not the names on their own: + // renaming happens in the folder you are looking at and the entries are + // stored by their whole path. + from: String, + to: String, + // A folder is not one entry but everything filed under it, so the whole + // branch moves. There is no entry for it to be renamed on its own. + folder: bool, + password: Option, + }, + Compress { + out: PathBuf, + inputs: Vec, + format: Format, + codec: Codec, + level: Level, + password: Option, + }, + // Putting files in. Same rebuild as Delete, and for the same reason: the + // central directory is at the end of the file. + Add { + archive: PathBuf, + inputs: Vec, + // Where inside the archive they land, which is the folder the window is + // showing. Empty means the root. + dir: String, + codec: Codec, + level: Level, + password: Option, + }, +} + +pub(crate) enum Startup { + Browse(Option), + Run(Job), + Add(Vec), +} + +pub(crate) fn quick_output(inputs: &[PathBuf], format: Format) -> PathBuf { + let first = &inputs[0]; + let dir = first.parent().map(PathBuf::from).unwrap_or_default(); + let stem = if inputs.len() == 1 { + let name = first + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "archive".into()); + if first.is_dir() { + name + } else { + Path::new(&name) + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or(name) + } + } else { + dir.file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "archive".into()) + }; + dir.join(format!("{stem}.{}", format.extension())) +} + +pub(crate) fn parse_args() -> Startup { + let args: Vec = std::env::args().skip(1).collect(); + if args.is_empty() { + return Startup::Browse(None); + } + let rest: Vec = args[1..].iter().map(PathBuf::from).collect(); + match args[0].as_str() { + "--extract-here" if !rest.is_empty() => Startup::Run(Job::Extract { + archives: rest, + dest: Destination::Beside, + password: None, + }), + "--extract-to-folder" if !rest.is_empty() => Startup::Run(Job::Extract { + archives: rest, + dest: Destination::Subfolder, + password: None, + }), + "--test" if !rest.is_empty() => Startup::Run(Job::Test { + archive: rest[0].clone(), + only: None, + }), + "--add" if !rest.is_empty() => Startup::Add(rest), + "--add-quick" if !rest.is_empty() => Startup::Run(Job::Compress { + out: quick_output(&rest, Format::Zip), + inputs: rest, + format: Format::Zip, + codec: Codec::Deflate, + level: Level::Normal, + password: None, + }), + other if !other.starts_with("--") => Startup::Browse(Some(PathBuf::from(other))), + _ => Startup::Browse(None), + } +} + +pub(crate) fn fill(template: &str, pairs: &[(&str, &str)]) -> String { + let mut s = template.to_string(); + for (key, value) in pairs { + s = s.replace(&format!("{{{key}}}"), value); + } + s +} + +/// Gets the installer and checks it against the sums published beside it. +/// +/// That protects against a download cut short or corrupted on the way. It does +/// not protect against a poisoned release, because the sum comes from the same +/// place as the file: for that these binaries would have to be signed, and they +/// are not yet. +pub(crate) fn download_update( + installer: &str, + sums: &str, + s: &'static Strings, + notify: &(dyn Fn(usize, usize, &str) -> bool + Sync), +) -> std::result::Result { + use sha2::{Digest, Sha256}; + + let agent = format!("Arca/{}", env!("CARGO_PKG_VERSION")); + let name = installer + .rsplit('/') + .next() + .filter(|n| !n.is_empty()) + .ok_or_else(|| s.update_failed.to_string())?; + + // The sums first, which are four lines: if those cannot be fetched there is + // no sense in pulling five megabytes down to not be able to check them. + let listing = arca_net::get(sums, &agent).ok_or_else(|| s.update_failed.to_string())?; + let want = sum_for(&listing, name).ok_or_else(|| s.update_failed.to_string())?; + + let body = arca_net::fetch(installer, &agent, INSTALLER_LIMIT, &|so_far, total| { + notify(so_far, total.unwrap_or(0), name) + }) + .ok_or_else(|| s.update_failed.to_string())?; + + let got: [u8; 32] = Sha256::digest(&body).into(); + if got != want { + return Err(s.update_tampered.to_string()); + } + + let path = std::env::temp_dir().join(name); + fs::write(&path, &body).map_err(|e| e.to_string())?; + Ok(path) +} + +/// Runs the installer and stands aside. +/// +/// `/update=1` is ours, not Inno's, and tells the installer two things: not to +/// restart the Explorer to replace the shell menu DLL -- it is left in place +/// for the next boot and the old one still works -- and to open Arca again when +/// it finishes. +/// +/// Arca is deliberately not closed here: Inno sees that the program it is about +/// to replace is open and closes it itself. +#[cfg(windows)] +pub(crate) fn install_update(path: &Path) -> std::result::Result<(), String> { + std::process::Command::new(path) + .args([ + "/VERYSILENT", + "/NOCANCEL", + "/NORESTART", + // Not the Restart Manager: the installer reopens Arca itself with + // /update=1, and both doing it would give two windows. + "/NORESTARTAPPLICATIONS", + "/update=1", + ]) + .spawn() + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +#[cfg(not(windows))] +pub(crate) fn install_update(_path: &Path) -> std::result::Result<(), String> { + // There is no installer to fetch outside Windows, so this is never reached: + // `arca_net` does not answer and there is never a new version to offer. + Err("no installer on this system".into()) +} + +pub(crate) fn run_job_blocking( + job: Job, + s: &'static Strings, + // Told how far along this is, and answers whether to carry on. False is + // somebody pressing stop. + notify: &(dyn Fn(usize, usize, &str) -> bool + Sync), + ask: &dyn Fn(&Path) -> Answer, +) -> std::result::Result { + match job { + Job::Extract { + archives, + dest, + password, + } => { + if archives.is_empty() { + return Err(s.nothing_to_do.to_string()); + } + if archives.iter().any(|a| detect(a).is_none()) { + return Err(s.unknown_format.to_string()); + } + let mut total = 0u64; + let mut last = PathBuf::new(); + for a in &archives { + let base = a.parent().map(PathBuf::from).unwrap_or_default(); + let target = match dest { + Destination::Beside => base, + Destination::Subfolder => base.join(archive_stem(a)), + }; + total += extract(a, &target, &[], notify, ask, password.as_deref()) + .map_err(|e| e.to_string())?; + last = target; + } + Ok(fill( + s.extracted_to, + &[ + ("size", &human(total)), + ("dest", &last.display().to_string()), + ], + )) + } + Job::Test { archive, only } => { + if detect(&archive).is_none() { + return Err(s.unknown_format.to_string()); + } + let (good, bad) = + test_archive(&archive, only.as_ref(), notify).map_err(|e| e.to_string())?; + if bad.is_empty() { + Ok(fill(s.verified_ok, &[("n", &good.to_string())])) + } else { + Err(format!( + "{}: {}", + fill( + s.errors_found, + &[("good", &good.to_string()), ("bad", &bad.len().to_string())] + ), + bad.join("; ") + )) + } + } + // Built next to the original and read back in full before it replaces + // it. The archive is the only copy of what is inside it. + Job::Password { + archive, + current, + new, + } => { + let temp = archive.with_file_name(format!( + "{}.arca-new", + archive + .file_name() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or_default() + )); + let done = arca_zip::rewrite_password( + &archive, + &temp, + current.as_deref(), + new.as_deref(), + notify, + ); + if let Err(e) = done { + let _ = fs::remove_file(&temp); + return Err(e.to_string()); + } + step_aside(&archive).map_err(|e| e.to_string())?; + fs::rename(&temp, &archive).map_err(|e| e.to_string())?; + let name = archive + .file_name() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or_default(); + Ok(fill( + if new.is_some() { + s.password_set + } else { + s.password_removed + }, + &[("name", &name)], + )) + } + // Same shape as the password rewrite, and the same care: the archive + // is the only copy of what is inside it, so the new one is built + // alongside, read back in full, and only then moved over. + Job::Delete { + archive, + names, + password, + } => { + if detect(&archive) != Some(Format::Zip) { + return Err(s.only_zip_can_change.to_string()); + } + let doomed: HashSet = names.into_iter().collect(); + let temp = archive.with_file_name(format!( + "{}.arca-new", + archive + .file_name() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or_default() + )); + let done = arca_zip::remove_entries( + &archive, + &temp, + password.as_deref(), + &|e| !doomed.contains(&e.name), + notify, + ); + let gone = doomed.len(); + if let Err(e) = done { + let _ = fs::remove_file(&temp); + return Err(e.to_string()); + } + step_aside(&archive).map_err(|e| e.to_string())?; + fs::rename(&temp, &archive).map_err(|e| e.to_string())?; + Ok(fill(s.deleted, &[("n", &gone.to_string())])) + } + Job::CopyTo { archive, dest } => { + // Copied by hand rather than with `fs::copy`, which says nothing + // until it is finished: a three gigabyte archive would be a window + // that had stopped answering for a minute. This one has a bar and a + // way out, like everything else that takes a while. + let total = fs::metadata(&archive).map(|m| m.len()).unwrap_or(0); + let name = dest + .file_name() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or_default(); + let copied = (|| -> std::io::Result { + let mut from = BufReader::with_capacity(BUF, File::open(&archive)?); + let mut to = BufWriter::with_capacity(BUF, File::create(&dest)?); + let mut buf = vec![0u8; BUF]; + let mut done = 0u64; + loop { + let n = from.read(&mut buf)?; + if n == 0 { + break; + } + to.write_all(&buf[..n])?; + done += n as u64; + // The counters are whole megabytes: a bar that redraws once + // per sixty-four kilobytes is a bar drawing itself instead + // of the copy getting on with it. + if !notify( + (done / (1 << 20)) as usize, + (total / (1 << 20)).max(1) as usize, + &name, + ) { + return Err(std::io::Error::other("cancelled")); + } + } + to.flush()?; + Ok(done) + })(); + match copied { + Ok(bytes) => Ok(fill( + s.copied_to, + &[ + ("size", &human(bytes)), + ("dest", &dest.display().to_string()), + ], + )), + Err(e) => { + // Half a copy is not a copy. Whatever was written goes, + // whether the reason was a full disk or somebody pressing + // stop. + let _ = fs::remove_file(&dest); + if e.to_string() == "cancelled" { + return Err(arca_core::Error::Cancelled.to_string()); + } + Err(e.to_string()) + } + } + } + Job::Rename { + archive, + from, + to, + folder, + password, + } => { + if detect(&archive) != Some(Format::Zip) { + return Err(s.only_zip_can_change.to_string()); + } + let temp = archive.with_file_name(format!( + "{}.arca-new", + archive + .file_name() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or_default() + )); + // A folder answers to two spellings: some tools file an entry for + // the folder itself with a slash on the end, others only file what + // is inside it. Both have to move, and neither can be assumed. + let under = format!("{from}/"); + let moved = format!("{to}/"); + let rename = |name: &str| -> String { + if !folder { + return if name == from { + to.clone() + } else { + name.to_string() + }; + } + if name == from { + to.clone() + } else if name == under { + moved.clone() + } else if let Some(rest) = name.strip_prefix(&under) { + format!("{moved}{rest}") + } else { + name.to_string() + } + }; + let done = arca_zip::rename_entries( + &archive, + &temp, + password.as_deref(), + &|e| rename(&e.name), + notify, + ); + if let Err(e) = done { + let _ = fs::remove_file(&temp); + return Err(e.to_string()); + } + step_aside(&archive).map_err(|e| e.to_string())?; + fs::rename(&temp, &archive).map_err(|e| e.to_string())?; + // Nothing to say: the new name is in the list, which is where the + // eye already is. An empty word here leaves the summary of the + // archive standing, which is what the bar is for. + Ok(String::new()) + } + Job::Compress { + out, + inputs, + format, + codec, + level, + password, + } => { + if inputs.is_empty() { + return Err(s.nothing_to_do.to_string()); + } + let (from, to) = compress( + &out, + &inputs, + format, + codec, + level, + notify, + password.as_deref(), + ) + .map_err(|e| e.to_string())?; + let pct = if from == 0 { + 0.0 + } else { + (1.0 - to as f64 / from as f64) * 100.0 + }; + Ok(fill( + s.created, + &[ + ("name", &out.display().to_string()), + ("from", &human(from)), + ("to", &human(to)), + ("pct", &format!("{pct:.1}%")), + ], + )) + } + // Same care as Delete and the password rewrite: built alongside, read + // back in full, and only then moved over the original. + Job::Add { + archive, + inputs, + dir, + codec, + level, + password, + } => { + if detect(&archive) != Some(Format::Zip) { + return Err(s.only_zip_can_change.to_string()); + } + if inputs.is_empty() { + return Err(s.nothing_to_do.to_string()); + } + let extra: Vec = collect_files(&inputs) + .map_err(|e| e.to_string())? + .into_iter() + .map(|(source, name)| arca_zip::Addition { + source: Some(source), + name: format!("{dir}{name}"), + codec, + level, + }) + .collect(); + if extra.is_empty() { + return Err(s.nothing_to_do.to_string()); + } + let temp = archive.with_file_name(format!( + "{}.arca-new", + archive + .file_name() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or_default() + )); + let n = extra.len(); + let done = arca_zip::add_entries(&archive, &temp, password.as_deref(), &extra, notify); + if let Err(e) = done { + let _ = fs::remove_file(&temp); + return Err(e.to_string()); + } + step_aside(&archive).map_err(|e| e.to_string())?; + fs::rename(&temp, &archive).map_err(|e| e.to_string())?; + Ok(fill(s.added, &[("n", &n.to_string())])) + } + // Not reached: getting the new version is handled earlier, on the + // thread that starts the job, because it ends in a file to run and not + // in a text to show. + Job::Update { .. } => Err(s.update_failed.to_string()), + Job::Move { + archive, + moves, + password, + } => { + if detect(&archive) != Some(Format::Zip) { + return Err(s.only_zip_can_change.to_string()); + } + let temp = archive.with_file_name(format!( + "{}.arca-new", + archive + .file_name() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or_default() + )); + // All of them in one pass. Moving is renaming with a different + // folder in front, and renaming is a rewrite of the whole archive: + // five files moved one at a time would be five rewrites. + let done = arca_zip::rename_entries( + &archive, + &temp, + password.as_deref(), + &|e| moved_name(&e.name, &moves), + notify, + ); + if let Err(e) = done { + let _ = fs::remove_file(&temp); + return Err(e.to_string()); + } + step_aside(&archive).map_err(|e| e.to_string())?; + fs::rename(&temp, &archive).map_err(|e| e.to_string())?; + // The list says where everything is now, which is the whole answer. + Ok(String::new()) + } + Job::NewFolder { + archive, + name, + password, + } => { + if detect(&archive) != Some(Format::Zip) { + return Err(s.only_zip_can_change.to_string()); + } + let temp = archive.with_file_name(format!( + "{}.arca-new", + archive + .file_name() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or_default() + )); + let extra = [arca_zip::Addition { + // No file behind it: a folder in a zip is a name and nothing + // else. + source: None, + name, + codec: Codec::Store, + level: Level::Store, + }]; + let done = arca_zip::add_entries(&archive, &temp, password.as_deref(), &extra, notify); + if let Err(e) = done { + let _ = fs::remove_file(&temp); + return Err(e.to_string()); + } + step_aside(&archive).map_err(|e| e.to_string())?; + fs::rename(&temp, &archive).map_err(|e| e.to_string())?; + // Nothing to say: the folder is in the list, which is where the eye + // already is. + Ok(String::new()) + } + } +} diff --git a/arca-gui/src/controller/actions.rs b/arca-gui/src/controller/actions.rs new file mode 100644 index 0000000..9842ce0 --- /dev/null +++ b/arca-gui/src/controller/actions.rs @@ -0,0 +1,543 @@ +//! Controller protocol and pure runtime projections. + +use crate::archive_ops::*; +use crate::i18n::Lang; +use crate::model::*; +use crate::tree::{parent_of, Kind, Row}; +use arca_core::Entry; +use std::path::{Path, PathBuf}; + +pub(crate) enum Message { + Listing(PathBuf, Vec), + // The installer is down and checked. It ends in a file to run rather than + // in a text to read, which is why it is not a `Done`. + Downloaded(PathBuf), + Conflict(String), + Progress(usize, usize, String), + Done(String), + Failed(String), + // A cut reached the clipboard in one piece. Sent before Done, and only + // then, so a cut that failed halfway never leaves the window waiting to + // take entries out of an archive on the strength of it. + CutReady, +} + +// What a cut is waiting on. The entries stay in the archive until the paste +// actually happens, and this is what says which ones and how to tell. +pub(crate) struct Cut { + pub(super) archive: PathBuf, + // The extracted copies handed to the shell. A paste with the move effect + // takes them out of the temporary folder, and their absence is the only + // sign Windows gives that it happened. + pub(super) paths: Vec, + pub(super) names: Vec, +} + +// What the password window is standing in front of: a job the context menu +// handed us, or an encrypted archive just opened in the window. +// Which blank the password window is filling in. They are not the same +// question: one needs the password the archive already has, the other the one it +// is about to get. +pub(crate) enum Pending { + Extract(Box), + OpenArchive, + CurrentPassword(Box), +} + +pub(crate) enum View { + Browse, + Add, + Running, +} + +pub(crate) enum AppAction { + Open(PathBuf), + Run(Job), + ExtractTo { only_checked: bool, dest: PathBuf }, + PrepareCompress(Vec), + SetFilter(String), + SelectAllVisible, + InvertVisible, + Add(Vec), + Drop(Vec), + Copy { cut: bool }, + Paste, + OpenFile(usize), + Navigate(String), + Back, + Forward, + SetChecked { row: Row, value: bool }, + ClearSelection, + Sort(SortColumn), + ToggleColumn(SortColumn), + SetLanguage(Option), + SetTheme(ThemePreference), + AnswerConflict(Answer), + CancelPassword, + SetPasswordInput(String), + SubmitPassword(String), + TogglePasswordVisibility, + BeginPasswordChange, + RequestDelete, + ConfirmDelete(bool), + AnswerDrop(DropChoice), + CancelJob, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum DropChoice { + Open, + Add, + Cancel, +} + +/// The DOS attribute byte as the letters every file manager has shown it with +/// since there were file managers: read only, hidden, system, archive. +/// +/// A dash where a bit is off rather than a shorter string, so that the column +/// lines up down the page and the eye can read one position instead of one +/// word. The directory bit is not shown: the list already says which rows are +/// folders, in a way that does not need decoding. +pub(crate) fn attribute_letters(bits: u8) -> String { + [(0x01, 'R'), (0x02, 'H'), (0x04, 'S'), (0x20, 'A')] + .iter() + .map(|(mask, letter)| if bits & mask != 0 { *letter } else { '-' }) + .collect() +} + +/// Whether a name claims to be a picture of a kind the window can draw. +pub(crate) fn looks_like_picture(name: &str) -> bool { + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()); + matches!( + ext.as_deref(), + Some("png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp") + ) +} + +/// One line of a hex dump: where it starts, the bytes, and what they would be +/// if they were letters. +/// +/// The three columns are what makes a dump readable: the offset to point at, +/// the bytes to read, and the letters to recognise a string in the middle of +/// something that is not one. A dot stands for everything unprintable, which is +/// the convention every other dump follows. +pub(crate) fn hex_line(at: usize, bytes: &[u8]) -> String { + let mut out = format!("{at:08X} "); + for i in 0..16 { + match bytes.get(i) { + Some(b) => out.push_str(&format!("{b:02X} ")), + None => out.push_str(" "), + } + if i == 7 { + out.push(' '); + } + } + out.push(' '); + for b in bytes { + out.push(if (0x20..0x7F).contains(b) { + *b as char + } else { + '.' + }); + } + out +} + +/// How a file is being looked at in the viewer. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Look { + Text, + Hex, + Picture, +} + +/// A file out of the archive, held in memory for looking at. +/// +/// The bytes are never written to disk. Viewing something is not the same as +/// extracting it, and a viewer that leaves a copy in the temporary folder has +/// quietly extracted it. +pub(crate) struct Viewed { + pub(crate) name: String, + // Shared rather than owned outright: the picture view hands these to GPUI + // on every frame, and a file of thirty megabytes copied sixty times a + // second is two gigabytes a second of nothing. + pub(crate) bytes: std::sync::Arc<[u8]>, + pub(crate) look: Look, + // Split once when the file arrives rather than on every frame: the view is + // drawn a line at a time and the lines have to exist to be counted. + pub(crate) lines: Vec, + // Whether the picture loader made anything of it. Asked once, because a + // failed decode is as expensive as a successful one. + pub(crate) picture: bool, +} + +/// The most a file can be and still be opened for looking at. +/// +/// A viewer holds the whole thing in memory, and the point of it is a glance at +/// a text file or a picture, not reading a database. Past this the answer is to +/// take it out properly, which is what the rest of the window is for. +pub(crate) const VIEW_LIMIT: u64 = 32 * 1024 * 1024; + +/// Whether these bytes are meant to be read as words. +/// +/// Two questions, in the order that settles it fastest. A zero byte is the one +/// thing text almost never has and binary almost always does, so it is asked +/// first and on its own. Failing that, the balance of what is printable: a +/// stray high byte is a name with an accent in it, a run of them is a program. +/// +/// Only the head is read. A file that begins as text and turns into something +/// else halfway down is a file the reader will notice by looking at it. +pub(crate) fn looks_like_text(bytes: &[u8]) -> bool { + let head = &bytes[..bytes.len().min(8192)]; + if head.is_empty() { + return true; + } + if head.contains(&0) { + return false; + } + let odd = head + .iter() + .filter(|b| **b < 0x20 && !matches!(b, b'\t' | b'\n' | b'\r')) + .count(); + odd * 20 < head.len() +} + +/// Whether `name` answers to `mask`, where `*` stands for any run of +/// characters and `?` for exactly one. +/// +/// The same two wildcards WinRAR and the command line have always used, and +/// nothing else: a mask is something people type in a hurry and a language with +/// character classes in it would turn a typo into a silent mismatch. Case is +/// ignored, because Windows ignores it and the names came off a Windows disk. +/// +/// Written as a walk with one point of backtracking rather than as a recursion: +/// `*` is the only thing that can be taken back, so remembering where the last +/// one was and how far it had eaten is the whole of it. That is what keeps a +/// mask of nothing but stars from taking exponential time on a long name. +pub(crate) fn matches_mask(mask: &str, name: &str) -> bool { + let m: Vec = mask.to_lowercase().chars().collect(); + let n: Vec = name.to_lowercase().chars().collect(); + let (mut i, mut j) = (0usize, 0usize); + // Where to come back to: the star, and the character after which it had + // eaten everything up to. + let mut star: Option<(usize, usize)> = None; + + while j < n.len() { + match m.get(i) { + Some('*') => { + star = Some((i, j)); + i += 1; + } + Some('?') => { + i += 1; + j += 1; + } + Some(c) if *c == n[j] => { + i += 1; + j += 1; + } + // No match here. If a star is behind us it can swallow one more + // character and we try again from there; if not, there is nothing + // left to try. + _ => match star { + Some((si, sj)) => { + i = si + 1; + j = sj + 1; + star = Some((si, sj + 1)); + } + None => return false, + }, + } + } + // Trailing stars match the empty rest of the name; anything else does not. + m[i..].iter().all(|c| *c == '*') +} + +/// Seconds as a clock: `0:07`, `1:38`, `2:05:11`. +/// +/// Minutes and seconds until there are hours, and no leading zero on the +/// largest part: a job that says `0:00:07` is a job whose progress window was +/// designed for a job that takes hours. +pub(crate) fn clock(seconds: f64) -> String { + // A guess of a hundred hours is not a guess; anything past this is capped + // rather than shown, and NaN falls to nothing rather than to a panic. + let whole = if seconds.is_finite() { + seconds.clamp(0.0, 359_999.0) as u64 + } else { + 0 + }; + let (h, m, s) = (whole / 3600, (whole / 60) % 60, whole % 60); + if h > 0 { + format!("{h}:{m:02}:{s:02}") + } else { + format!("{m}:{s:02}") + } +} + +/// What a job is being done to: the name that says which of several windows +/// this one is. +pub(crate) fn subject_of(job: &Job) -> String { + let named = |p: &Path| { + p.file_name() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or_default() + }; + match job { + Job::Extract { archives, .. } => match archives.split_first() { + Some((only, [])) => named(only), + Some((_, rest)) => format!("{} +{}", named(&archives[0]), rest.len()), + None => String::new(), + }, + Job::Compress { out, .. } => named(out), + Job::Test { archive, .. } + | Job::Password { archive, .. } + | Job::Delete { archive, .. } + | Job::CopyTo { archive, .. } + | Job::Move { archive, .. } + | Job::NewFolder { archive, .. } + | Job::Rename { archive, .. } + | Job::Add { archive, .. } => named(archive), + // Here the file is the installer, and its name already has the version. + Job::Update { installer, .. } => { + installer.rsplit('/').next().unwrap_or_default().to_string() + } + } +} + +/// Where the announcement is asked for, and where a copy that cannot update +/// itself is sent instead. +pub(crate) const RELEASES_API: &str = "https://api.github.com/repos/THIONG/arca/releases/latest"; +pub(crate) const RELEASES_PAGE: &str = "https://github.com/THIONG/arca/releases/latest"; + +/// As much installer as is ever going to arrive. A reply longer than this is +/// not our release and is not going to be written to disk, let alone run. +pub(crate) const INSTALLER_LIMIT: usize = 64 * 1024 * 1024; + +#[derive(Clone)] +pub(crate) struct Release { + pub(crate) tag: String, + pub(crate) installer: Option, + pub(crate) sums: Option, +} + +/// Reads the announcement. +/// +/// Hand written rather than a JSON library, because this asks three questions +/// of one reply and the answers are short strings. A parser for the whole +/// language would be a dependency, and a large one, for that. +/// +/// The addresses are picked by what they end in rather than by walking the list +/// of assets: the shape of that list is GitHub's to change, but a file called +/// `SHA256SUMS.txt` is called that because we named it. +pub(crate) fn release_of(reply: &str) -> Option { + let tag = tag_of(reply)?; + let mut installer = None; + let mut sums = None; + for piece in reply.split("\"browser_download_url\"").skip(1) { + let Some(open) = piece.find('"').and_then(|c| piece.get(c + 1..)) else { + continue; + }; + let Some(close) = open.find('"') else { + continue; + }; + let url = &open[..close]; + // Only ours, and only over the wire we trust. A reply that names some + // other place is not one to go and fetch an executable from. + if !url.starts_with("https://github.com/THIONG/arca/releases/download/") { + continue; + } + if url.ends_with("/SHA256SUMS.txt") { + sums = Some(url.to_string()); + } else if url.ends_with("-x86_64.exe") && url.contains("/arca-setup-") { + installer = Some(url.to_string()); + } + } + Some(Release { + tag, + installer, + sums, + }) +} + +/// The line for `name` in a `sha256sum` listing, as raw bytes. +/// +/// Two spellings, because that is what the tool writes: two spaces for a file +/// it read as text and a space and a star for one it read as binary. The +/// Windows halves of our own releases come out with the star. +pub(crate) fn sum_for(listing: &str, name: &str) -> Option<[u8; 32]> { + for line in listing.lines() { + let (hash, rest) = line.split_once(' ')?; + let named = rest.trim_start_matches([' ', '*']); + if named != name || hash.len() != 64 { + continue; + } + let mut out = [0u8; 32]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = u8::from_str_radix(hash.get(i * 2..i * 2 + 2)?, 16).ok()?; + } + return Some(out); + } + None +} + +/// Whether this copy of Arca was put here by the installer. +/// +/// Inno Setup leaves its uninstaller in the folder it installed to, so that +/// file being next to the program is the program saying how it got there. A +/// copy unpacked from the .zip has no uninstaller and nothing to update: for +/// that one the only honest offer is the page. +pub(crate) fn installed_by_setup() -> bool { + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|d| d.join("unins000.exe"))) + .is_some_and(|u| u.exists()) +} + +/// Pulls the release's name out of what the announcement page answered. +/// +/// What it must not do is find the wrong `tag_name`. There is only one at the +/// top level of that reply, so the first is the right one; anything unexpected +/// gives nothing, and nothing means the window says nothing. +pub(crate) fn tag_of(reply: &str) -> Option { + let at = reply.find("\"tag_name\"")? + "\"tag_name\"".len(); + let rest = reply.get(at..)?; + let colon = rest.find(':')?; + let after = rest.get(colon + 1..)?; + let open = after.find('"')?; + let value = after.get(open + 1..)?; + let close = value.find('"')?; + let tag = value.get(..close)?.trim(); + // A name of nothing, or one long enough to be somebody being funny, is not + // a version. + (!tag.is_empty() && tag.len() <= 32).then(|| tag.to_string()) +} + +/// Whether `latest` is a later version than `running`. +/// +/// Numbers separated by dots, a leading `v` forgiven, and compared a part at a +/// time rather than as text: as text, `0.10.0` comes before `0.9.0` and the +/// window would either nag for ever or never say anything at all. +/// +/// A version with something after the numbers -- `0.6.0-rc1` -- counts as +/// earlier than the plain one, which is what those names mean everywhere. And +/// anything that is not a version at all answers no: silence is the right +/// behaviour for an announcement nobody can read. +pub(crate) fn newer(running: &str, latest: &str) -> bool { + pub(crate) fn parts(v: &str) -> Option<(Vec, bool)> { + let v = v.trim().trim_start_matches(['v', 'V']); + if v.is_empty() { + return None; + } + let (numbers, tail) = match v.find(['-', '+']) { + Some(cut) => (&v[..cut], true), + None => (v, false), + }; + let mut out = Vec::new(); + for piece in numbers.split('.') { + out.push(piece.parse::().ok()?); + } + (!out.is_empty()).then_some((out, tail)) + } + + let (Some((mine, mine_tail)), Some((theirs, theirs_tail))) = (parts(running), parts(latest)) + else { + return false; + }; + // Missing parts count as zero, so 0.6 and 0.6.0 are the same version. + let deep = mine.len().max(theirs.len()); + for i in 0..deep { + let a = mine.get(i).copied().unwrap_or(0); + let b = theirs.get(i).copied().unwrap_or(0); + if a != b { + return b > a; + } + } + // The same numbers: the one without a suffix is the finished one. + mine_tail && !theirs_tail +} + +/// A name in the one spelling the window works in. +/// +/// A zip written on Windows can hold backslashes, and comparing those against +/// the paths the tree is built from silently matched nothing: a move out of a +/// folder took the whole archive with it, and moving inside a folder did +/// nothing at all in those archives. +pub(crate) fn slashed(name: &str) -> String { + name.replace('\\', "/") +} + +/// What an entry is called after a move. +/// +/// A folder is not one entry but everything filed under it, so a move matches +/// the name itself, the name with its slash, and everything beneath it. +pub(crate) fn moved_name(name: &str, moves: &[(String, String)]) -> String { + // Compared, and written out again, in the spelling the window works in. + let name = slashed(name); + for (from, to) in moves { + if name == *from { + return to.clone(); + } + let under = format!("{from}/"); + if name == under { + return format!("{to}/"); + } + if let Some(rest) = name.strip_prefix(&under) { + return format!("{to}/{rest}"); + } + } + name +} + +/// The folder an entry is filed in, without the name on the end. Empty at the +/// root, which is where the archive itself is. +pub(crate) fn folder_of(path: &str) -> &str { + match path.trim_end_matches('/').rsplit_once('/') { + Some((parent, _)) => parent, + None => "", + } +} + +/// The way out of a folder: the row every file list keeps at the top, spelt the +/// way every file list spells it. +/// +/// It stands for the folder above and nothing else. There is no entry behind +/// it, so it cannot be picked, weighed, renamed or taken out, and the list +/// leaves it at the top however it is sorted. +pub(crate) fn up_row(dir: &str) -> Row { + Row { + label: "..".to_string(), + path: parent_of(dir), + kind: Kind::Dir, + is_dir: true, + entry: None, + size: 0, + packed: 0, + method: "", + encrypted: false, + count: 0, + mtime: None, + created: None, + accessed: None, + attributes: 0, + crc32: 0, + up: true, + } +} + +/// How fast the list should run, in pixels a second, for a pointer `away` +/// pixels from the anchor. Negative runs it up. +/// +/// Nothing at all inside a dead zone, because the wheel is a button too and a +/// hand that presses one moves a pixel or two doing it. Past that it grows +/// with the square of the distance: gently near the anchor, where the point is +/// to read what goes by, and hard further out, where the point is to get to +/// the end. Capped, because past a certain speed the only difference is how +/// blurred it is. +pub(crate) fn wheel_speed(away: f32) -> f32 { + const DEAD: f32 = 12.0; + let past = away.abs() - DEAD; + if past <= 0.0 { + return 0.0; + } + (past * past / 12.0).min(4000.0) * away.signum() +} diff --git a/arca-gui/src/controller/mod.rs b/arca-gui/src/controller/mod.rs new file mode 100644 index 0000000..b6c1e0d --- /dev/null +++ b/arca-gui/src/controller/mod.rs @@ -0,0 +1,1564 @@ +//! Toolkit-independent application state and action controller. + +mod actions; +mod state; +pub(crate) use actions::*; +pub(crate) use state::AppState; + +use crate::archive_ops::*; +use crate::model::*; +use crate::settings::Settings; +use crate::tree::{children_of, entries_under, kind_of, Row}; +use crate::{ + clipboard, + i18n::{strings, Strings}, + tree, +}; +#[cfg(windows)] +use arca_core::Entry; +use arca_core::{Codec, Level}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{channel, Sender}; +use std::time::Instant; + +pub(crate) struct AppController { + pub(crate) state: AppState, +} + +impl AppController { + pub(crate) fn dispatch(&mut self, action: AppAction) { + match action { + AppAction::Open(path) => self.open(path), + AppAction::Run(job) => self.run_job(job), + AppAction::ExtractTo { only_checked, dest } => { + self.start_extract_to(only_checked, dest) + } + AppAction::PrepareCompress(paths) => self.prepare_compress(paths), + AppAction::SetFilter(filter) => self.state.filter = filter, + AppAction::SelectAllVisible => self.select_all_visible(), + AppAction::InvertVisible => self.invert_visible(), + AppAction::Add(paths) => self.add_files(paths), + AppAction::Drop(paths) => self.dropped(paths), + AppAction::Copy { cut } => self.copy_to_clipboard(cut), + AppAction::Paste => self.paste_from_clipboard(), + AppAction::OpenFile(index) => self.open_file(index), + AppAction::Navigate(path) => self.go_to(path), + AppAction::Back => self.go_back(), + AppAction::Forward => self.go_forward(), + AppAction::SetChecked { row, value } => self.set_checked(&row, value), + AppAction::ClearSelection => self.clear_picked(), + AppAction::Sort(column) => self.sort_by(column), + AppAction::ToggleColumn(column) => { + let on = self.state.settings.columns.on(column); + self.state.settings.columns.set(column, !on); + self.state.settings.save(); + } + AppAction::SetLanguage(lang) => { + self.state.settings.lang = lang; + self.state.settings.save(); + } + AppAction::SetTheme(theme) => { + self.state.settings.theme = theme; + self.state.settings.save(); + } + AppAction::AnswerConflict(answer) => { + if let Some(tx) = &self.state.replies { + let _ = tx.send(answer); + } + self.state.conflict = None; + } + AppAction::CancelPassword => self.cancel_password(), + AppAction::SetPasswordInput(password) => self.state.password_input = password, + AppAction::SubmitPassword(password) => self.submit_password(password), + AppAction::TogglePasswordVisibility => { + self.state.show_password = !self.state.show_password + } + AppAction::BeginPasswordChange => self.begin_password_change(), + AppAction::RequestDelete => self.request_delete(), + AppAction::ConfirmDelete(confirmed) => self.confirm_delete(confirmed), + AppAction::AnswerDrop(choice) => self.answer_drop(choice), + // The worker reads this at the end of every entry. Let it go first, + // or the news would sit unread until somebody pressed Resume. + AppAction::CancelJob => { + use std::sync::atomic::Ordering; + self.state.stop.store(true, Ordering::Relaxed); + self.state.hold.store(false, Ordering::Relaxed); + } + } + } + + pub(crate) fn begin_password_change(&mut self) { + let Some(archive) = self.state.archive.clone() else { + return; + }; + if self.state.format != Format::Zip || self.state.busy { + return; + } + let job = Job::Password { + archive, + current: self.state.archive_password.clone(), + new: None, + }; + self.state.password_input.clear(); + if self.state.entries.iter().any(|entry| entry.encrypted) + && self.state.archive_password.is_none() + { + self.state.waiting_on_password = Some(Pending::CurrentPassword(Box::new(job))); + } else { + self.run_job(job); + } + } + + pub(crate) fn sort_by(&mut self, column: SortColumn) { + if self.state.order.0 == column { + self.state.order.1 = !self.state.order.1; + } else { + self.state.order = (column, true); + } + } + + pub(crate) fn start_extract_to(&mut self, only_checked: bool, mut dest: PathBuf) { + let Some(archive) = self.state.archive.clone() else { + return; + }; + if self.state.into_subfolder { + dest = dest.join(archive_stem(&archive)); + } + let wanted = if only_checked { + self.state.checked.clone() + } else { + vec![true; self.state.entries.len()] + }; + let s = self.s(); + let total = wanted.iter().filter(|b| **b).count(); + let pw = self.state.archive_password.clone(); + self.state.close_when_done = false; + let (reply_tx, reply_rx) = channel::(); + self.state.replies = Some(reply_tx); + self.spawn(total, move |tx| { + let notify = |i: usize, n: usize, name: &str| { + let _ = tx.send(Message::Progress(i, n, name.to_string())); + true + }; + let ask = conflict_asker(tx, &reply_rx); + let result = extract(&archive, &dest, &wanted, ¬ify, &ask, pw.as_deref()); + let _ = tx.send(match result { + Ok(bytes) => Message::Done(fill( + s.extracted_to, + &[ + ("size", &human(bytes)), + ("dest", &dest.display().to_string()), + ], + )), + Err(e) => Message::Failed(e.to_string()), + }); + }); + } + + pub(crate) fn submit_password(&mut self, password: String) { + if password.is_empty() { + return; + } + let Some(pending) = self.state.waiting_on_password.take() else { + return; + }; + self.state.password_input.clear(); + self.state.show_password = false; + match pending { + Pending::Extract(job) => { + if let Job::Extract { archives, dest, .. } = *job { + self.run_job(Job::Extract { + archives, + dest, + password: Some(password), + }); + } + } + Pending::OpenArchive => self.state.archive_password = Some(password), + Pending::CurrentPassword(job) => { + if let Job::Password { archive, new, .. } = *job { + self.state.archive_password = Some(password.clone()); + self.run_job(Job::Password { + archive, + current: Some(password), + new, + }); + } + } + } + } + + pub(crate) fn prepare_compress(&mut self, inputs: Vec) { + if inputs.is_empty() { + return; + } + self.state.output_name = quick_output(&inputs, self.state.format) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + self.state.pending_inputs = inputs; + self.state.view = View::Add; + } + pub(crate) fn select_all_visible(&mut self) { + for row in self.visible_rows() { + self.set_checked(&row, true); + } + } + pub(crate) fn invert_visible(&mut self) { + let rows = self.visible_rows(); + let values: Vec = rows.iter().map(|r| !self.is_checked(r)).collect(); + for (r, v) in rows.iter().zip(values) { + self.set_checked(r, v); + } + } + pub(crate) fn request_delete(&mut self) { + let names = self.selected_names(); + if !names.is_empty() { + self.state.confirm_delete = Some(names); + } + } + pub(crate) fn confirm_delete(&mut self, yes: bool) { + if let Some(names) = self.state.confirm_delete.take() { + if yes { + if let Some(archive) = self.state.archive.clone() { + self.run_job(Job::Delete { + archive, + names, + password: self.state.archive_password.clone(), + }); + } + } + } + } + pub(crate) fn answer_drop(&mut self, choice: DropChoice) { + if let Some(paths) = self.state.confirm_drop.take() { + match choice { + DropChoice::Open => { + if let Some(p) = paths.into_iter().next() { + self.open(p); + } + } + DropChoice::Add => self.add_files(paths), + DropChoice::Cancel => {} + } + } + } + + pub(crate) fn new(settings: Settings) -> Self { + AppController { + state: AppState { + view: View::Browse, + settings, + archive: None, + entries: Vec::new(), + checked: Vec::new(), + filter: String::new(), + order: (SortColumn::Name, true), + channel: None, + notice: String::new(), + error: false, + busy: false, + done_count: 0, + total_count: 0, + current_file: String::new(), + started: None, + format: Format::Zip, + codec: Codec::Deflate, + level: Level::Normal, + into_subfolder: false, + pending_inputs: Vec::new(), + output_name: String::new(), + close_when_done: false, + title: String::new(), + window_title: "Arca".to_string(), + current_dir: String::new(), + show_settings: false, + conflict: None, + replies: None, + waiting_on_password: None, + password_input: String::new(), + show_password: false, + add_password: String::new(), + archive_password: None, + after_password: None, + history: vec![String::new()], + here: 0, + cursor: None, + confirm_delete: None, + clip_dir: None, + cut_names: HashSet::new(), + confirm_drop: None, + renaming: None, + default_password: None, + asking_default_password: false, + asking_folder: false, + undo: None, + update: None, + update_rx: None, + asked_about_updates: false, + in_bytes: false, + subject: String::new(), + overlay: false, + stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + hold: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + viewing: None, + picking_group: None, + folders: tree::Folder::default(), + last_click: None, + cut_armed: None, + cut_pending: None, + show_shortcuts: false, + quiet: false, + }, + } + } + pub(crate) fn summary(&self) -> String { + let s = self.s(); + let n = self.state.entries.iter().filter(|e| !e.is_dir).count(); + let raw: u64 = self.state.entries.iter().map(|e| e.size).sum(); + let packed: u64 = self.state.entries.iter().map(|e| e.compressed_size).sum(); + let ratio = if raw == 0 { + 0.0 + } else { + (1.0 - packed as f64 / raw as f64) * 100.0 + }; + format!( + "{n} {} · {} {} · {} {} · {ratio:.1}% {}", + s.files_word, + human(raw), + s.uncompressed_word, + human(packed), + s.in_archive, + s.saved_word + ) + } + pub(crate) fn go_back(&mut self) { + if self.can_go_back() { + self.state.here -= 1; + self.state.current_dir = self.state.history[self.state.here].clone(); + self.state.filter.clear(); + self.clear_picked(); + } + } + pub(crate) fn selected_roots(&self) -> Vec { + let names: Vec = self + .state + .entries + .iter() + .map(|e| e.name.replace('\\', "/")) + .collect(); + // Whether everything under a prefix is ticked, worked out once per + // prefix: the same ancestors come round again for every file in a + // folder, and there can be thousands of them. + let mut whole: HashMap = HashMap::new(); + let mut roots: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + for (i, full) in names.iter().enumerate() { + if !self.state.checked.get(i).copied().unwrap_or(false) { + continue; + } + let trimmed = full.trim_end_matches('/'); + let mut root = trimmed.to_string(); + // Shortest ancestor first: the outermost folder that is ticked all + // the way down is the one that was meant. + let mut at = 0usize; + while let Some(cut) = trimmed[at..].find('/') { + at += cut + 1; + let prefix = &trimmed[..at]; + let all = *whole.entry(prefix.to_string()).or_insert_with(|| { + names + .iter() + .enumerate() + .filter(|(_, n)| n.starts_with(prefix)) + .all(|(j, _)| self.state.checked.get(j).copied().unwrap_or(false)) + }); + if all { + root = prefix.trim_end_matches('/').to_string(); + break; + } + } + if seen.insert(root.clone()) { + roots.push(root); + } + } + roots + } + + // Ctrl+C and Ctrl+X. The clipboard carries paths, not archive entries, so + // what is picked is extracted into a folder of its own under the temporary + // directory first and those paths are what the shell is handed. + // + // A cut marks the rows and asks the shell to move rather than copy, which + // is what empties the temporary folder afterwards. It does not take the + // entries out of the archive: nothing tells this window whether the paste + // ever happened, and removing them on the guess that it did would lose them + // for good the moment somebody changed their mind. + pub(crate) fn clear_picked(&mut self) { + self.state.checked.iter_mut().for_each(|c| *c = false); + self.state.cursor = None; + // A row number means something else in the folder now on screen. + self.state.last_click = None; + } + pub(crate) fn go_forward(&mut self) { + if self.can_go_forward() { + self.state.here += 1; + self.state.current_dir = self.state.history[self.state.here].clone(); + self.state.filter.clear(); + self.clear_picked(); + } + } + pub(crate) fn s(&self) -> &'static Strings { + strings(self.state.settings.effective_lang()) + } + pub(crate) fn selected_names(&self) -> Vec { + self.state + .entries + .iter() + .zip(&self.state.checked) + .filter(|(_, &on)| on) + .map(|(e, _)| e.name.clone()) + .collect() + } + + // The top of what is ticked. A folder with every one of its entries ticked + // stands for all of them, so a copy hands the clipboard one folder instead + // of the fifteen hundred files inside it, and the Explorer pastes a folder + // rather than a heap of loose files. + pub(crate) fn cancel_password(&mut self) { + let was_job = matches!(self.state.waiting_on_password, Some(Pending::Extract(_))); + self.state.waiting_on_password = None; + self.state.password_input.clear(); + // Only a job left the window on the running view with nothing running. + if was_job { + self.state.view = View::Browse; + } + } + // Puts the archive back the way it was before the last change. + // + // A swap of two names, because the version before the change was moved + // aside rather than thrown away. There is one step and no more: taking it + // back leaves nothing to take back, and the sidecar goes with it. + // + // Lives on the controller rather than in a view because there is nothing + // about it that belongs to a toolkit, and both surfaces offer it. + pub(crate) fn undo_last(&mut self) { + let Some((archive, _)) = self.state.undo.take() else { + return; + }; + let keep = undo_path(&archive); + if !keep.exists() { + return; + } + let pw = self.state.archive_password.clone(); + if let Err(e) = fs::remove_file(&archive).and_then(|_| fs::rename(&keep, &archive)) { + self.state.notice = e.to_string(); + self.state.error = true; + return; + } + self.open(archive); + self.state.archive_password = pw; + } + + // Reads the names in the archive again under another code page. + // + // Only the person looking at it can know which one an unflagged zip was + // written in, so it is a choice and not a guess, and the choice is + // remembered. + pub(crate) fn reread_names(&mut self, page: arca_zip::pages::Page) { + self.state.settings.page = page; + self.state.settings.save(); + for e in &mut self.state.entries { + if e.utf8 { + continue; + } + e.name = arca_zip::pages::decode(&e.raw_name, page); + e.is_dir = e.name.ends_with('/') || e.name.ends_with('\\'); + } + self.state.folders = tree::folders_of(&self.state.entries); + self.clear_picked(); + self.state.cursor = None; + self.state.current_dir.clear(); + self.state.history = vec![String::new()]; + self.state.here = 0; + self.state.notice = self.summary(); + self.state.error = false; + } + + /// A fresh pair of flags for a job about to start, handed back so the + /// worker and the window end up holding the same two. + /// + /// Fresh rather than lowered: a thread that was told to stop may still be + /// on its way out, and it must not read the flag the next job is watching. + pub(crate) fn fresh_flags( + &mut self, + ) -> ( + std::sync::Arc, + std::sync::Arc, + ) { + self.state.stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + self.state.hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + (self.state.stop.clone(), self.state.hold.clone()) + } + + /// Where a running job is shown: over the list it was started from, or as + /// the whole window when it came from the Explorer and there is no list + /// behind it to go back to. + pub(crate) fn show_job(&mut self, verb: &str, subject: String, from_here: bool) { + self.state.title = verb.to_string(); + self.state.subject = subject; + self.state.overlay = matches!(self.state.view, View::Browse) && from_here; + if !self.state.overlay { + self.state.view = View::Running; + self.state.window_title = if self.state.subject.is_empty() { + "Arca".to_string() + } else { + format!("{} — {}", self.state.title, self.state.subject) + }; + } + } + + // Asks, once, whether there is a newer Arca. + // + // On a thread and without a word: something nobody asked for must not make + // the window look busy. If the answer never comes -- no network, no reply, + // a machine that says no -- nothing happens and nothing is said. There is + // nothing here worth a complaint. + pub(crate) fn ask_about_updates(&mut self) { + if self.state.asked_about_updates || !self.state.settings.updates { + return; + } + self.state.asked_about_updates = true; + let (tx, rx) = channel::(); + self.state.update_rx = Some(rx); + let running = env!("CARGO_PKG_VERSION").to_string(); + std::thread::spawn(move || { + // GitHub turns away anything that does not name itself, and naming + // the program and its version is what a user agent is for. Nothing + // else is sent: no machine, no user, no archive. + let agent = format!("Arca/{running}"); + let Some(reply) = arca_net::get(RELEASES_API, &agent) else { + return; + }; + if let Some(release) = release_of(&reply) { + if newer(&running, &release.tag) { + let _ = tx.send(release); + } + } + }); + } + + // Takes the new version, if this copy is one that can replace itself. + // + // A copy put here by the installer updates itself. One unpacked from the + // .zip is loose files in a folder somebody chose, and there is nothing to + // update: for that one the only honest offer is the page. + pub(crate) fn start_update(&mut self) { + match self.state.update.clone() { + Some(Release { + tag, + installer: Some(installer), + sums: Some(sums), + }) if installed_by_setup() => self.run_job(Job::Update { + tag, + installer, + sums, + }), + _ => { + let _ = launch_with_system(Path::new(RELEASES_PAGE)); + } + } + } + + // Moves what was being carried into `target`, which is a folder's path or + // the empty string for the root. + // + // One job for all of it. A move is a rename with a different folder in + // front of it, and a rename is a rewrite of the whole archive: doing them + // one at a time would rewrite it once per file. + pub(crate) fn move_into(&mut self, roots: &[String], target: &str) { + let Some(archive) = self.state.archive.clone() else { + return; + }; + let s = self.s(); + let mut moves: Vec<(String, String)> = Vec::new(); + for root in roots { + let from = root.trim_end_matches('/').to_string(); + let leaf = from.rsplit('/').next().unwrap_or(&from).to_string(); + let to = format!("{target}{leaf}"); + // Already there, or into itself: nothing to do rather than a + // rewrite that changes nothing. + if from == to || to.starts_with(&format!("{from}/")) { + continue; + } + moves.push((from, to)); + } + if moves.is_empty() { + return; + } + // Nothing in the destination may already answer to the name. The + // archive would take it -- a zip can hold the same name twice -- and + // what came out afterwards would be anybody's guess. + let taken: HashSet<&str> = self + .state + .entries + .iter() + .map(|e| e.name.trim_end_matches('/')) + .collect(); + if let Some((_, to)) = moves.iter().find(|(_, to)| taken.contains(to.as_str())) { + let leaf = to.rsplit('/').next().unwrap_or(to); + self.state.notice = fill(s.name_taken, &[("name", leaf)]); + self.state.error = true; + return; + } + self.run_job(Job::Move { + archive, + moves, + password: self.state.archive_password.clone(), + }); + } + + pub(crate) fn extract_here(&mut self) { + let Some(archive) = self.state.archive.clone() else { + return; + }; + self.run_job(Job::Extract { + archives: vec![archive], + dest: if self.state.into_subfolder { + Destination::Subfolder + } else { + Destination::Beside + }, + password: self.state.archive_password.clone(), + }); + } + pub(crate) fn set_checked(&mut self, row: &Row, value: bool) { + // Nothing behind it, and its path is the folder above: ticking it would + // pick everything in the archive up to and including where you came + // from. Select all has to leave it alone. + if row.up { + return; + } + match row.entry { + Some(i) => self.state.checked[i] = value, + None => { + for i in entries_under(&self.state.entries, &row.path) { + self.state.checked[i] = value; + } + } + } + } + + // Double clicking a file pulls that one entry out to a temporary folder and + // hands it to whatever the system opens it with. It runs on its own thread + // because the entry can be large, and reports through the same progress + // window as everything else. + #[cfg(windows)] + pub(crate) fn dragged_files(&self) -> Vec<(Entry, String)> { + let base = &self.state.current_dir; + self.state + .entries + .iter() + .zip(&self.state.checked) + .filter(|(e, &on)| on && !e.is_dir) + .map(|(e, _)| { + let full = e.name.replace('\\', "/"); + let rel = full.strip_prefix(base.as_str()).unwrap_or(&full); + (e.clone(), rel.replace('/', "\\")) + }) + .filter(|(_, rel)| !rel.is_empty()) + .collect() + } + + // Dragging the selection out of the window. Blocks until it has been + // dropped or abandoned, because that is what `DoDragDrop` does: the window + // stops repainting for as long as the drag lasts, which nobody sees because + // the pointer is somewhere else by then. + // + // Nothing is extracted here. The shell is handed a list of names and sizes + // and asks for one file at a time while it is dropping, so a drag that is + // thought better of costs nothing, and a drag of six gigabytes starts as + // fast as a drag of one file. + pub(crate) fn go_to(&mut self, path: String) { + if self.state.history.get(self.state.here) == Some(&path) { + return; + } + self.state.history.truncate(self.state.here + 1); + self.state.history.push(path.clone()); + self.state.here = self.state.history.len() - 1; + self.state.current_dir = path; + self.state.filter.clear(); + self.clear_picked(); + } + + // Every folder starts with nothing picked, the way the Explorer does. + // A folder is picked here by ticking every entry underneath it, which is + // what lets one be extracted whole, so clicking a folder and walking into + // it used to arrive with all of its contents already ticked. + pub(crate) fn remember(&mut self, path: &Path) { + let text = path.to_string_lossy().to_string(); + self.state.settings.recent.retain(|p| *p != text); + self.state.settings.recent.insert(0, text); + self.state.settings.recent.truncate(10); + self.state.settings.save(); + } + pub(crate) fn spawn(&mut self, total: usize, work: F) + where + F: FnOnce(&Sender) + Send + 'static, + { + let (tx, rx) = channel(); + self.state.channel = Some(rx); + self.state.busy = true; + self.state.quiet = false; + self.state.error = false; + self.state.done_count = 0; + self.state.total_count = total; + self.state.current_file.clear(); + self.state.started = Some(Instant::now()); + std::thread::spawn(move || { + work(&tx); + }); + } + + // The folders of the archive down the side, the way WinRAR and the Explorer + // both offer one. + // + // It earns its place in a deep archive, where walking to a folder six + // levels down and back is a dozen double clicks. Off by default: in a flat + // archive it would be an empty column taking a fifth of the window. + pub(crate) fn can_go_forward(&self) -> bool { + self.state.here + 1 < self.state.history.len() + } + pub(crate) fn codec_name(&self, c: Codec) -> &'static str { + let s = self.s(); + match c { + Codec::Store => s.codec_store, + Codec::Deflate => s.codec_deflate, + Codec::Zstd => s.codec_zstd, + } + } + // Dragging the selection out of the window. Blocks until it has been + // dropped or abandoned, because that is what `DoDragDrop` does: the window + // stops repainting for as long as the drag lasts, which nobody sees because + // the pointer is somewhere else by then. + // + // Nothing is extracted here. The shell is handed a list of names and sizes + // and asks for one file at a time while it is dropping, so a drag that is + // thought better of costs nothing, and a drag of six gigabytes starts as + // fast as a drag of one file. + #[cfg(windows)] + pub(crate) fn drag_out(&mut self) { + let Some(archive) = self.state.archive.clone() else { + return; + }; + let picked = self.dragged_files(); + if picked.is_empty() { + return; + } + let items: Vec = picked + .iter() + .map(|(e, name)| arca_drag::Item { + name: name.clone(), + size: e.size, + mtime: e.mtime, + }) + .collect(); + let password = self.state.archive_password.clone(); + let entries: Vec = picked.into_iter().map(|(e, _)| e).collect(); + let deliver = Box::new(move |i: usize| { + entries + .get(i) + .and_then(|e| extract_one(&archive, e, password.as_deref()).ok()) + }); + // Copy only. Moving would mean taking the entries out of the archive, + // and the one gesture that does that already asks first. + let _ = arca_drag::drag(items, deliver, false); + } + + #[cfg(not(windows))] + pub(crate) fn drag_out(&mut self) {} + + // Escape and the Cancel button are the same act, so they go through the + // same code: two copies of this would drift apart the first time one side + // grew a step. + // The names ticked right now, which is what every action that works on a + // selection needs. + pub(crate) fn cut_landed(&mut self) { + let Some(cut) = &self.state.cut_pending else { + return; + }; + if self.state.busy || self.state.archive.as_ref() != Some(&cut.archive) { + return; + } + if cut.paths.iter().any(|p| p.exists()) { + return; + } + let Some(cut) = self.state.cut_pending.take() else { + return; + }; + self.state.cut_names.clear(); + self.run_job(Job::Delete { + archive: cut.archive, + names: cut.names, + password: self.state.archive_password.clone(), + }); + } + + // What is picked, named the way it should land where it is dropped: the + // folder on screen is the base, so dragging a folder out puts that folder + // down rather than scattering what was inside it. + pub(crate) fn level_name(&self, l: Level) -> &'static str { + let s = self.s(); + match l { + Level::Store => s.level_none, + Level::Fast => s.level_fast, + Level::Normal => s.level_normal, + Level::Best => s.level_best, + } + } + pub(crate) fn can_go_back(&self) -> bool { + self.state.here > 0 + } + pub(crate) fn add_files(&mut self, inputs: Vec) { + let Some(archive) = self.state.archive.clone() else { + return; + }; + self.run_job(Job::Add { + archive, + inputs, + dir: self.state.current_dir.clone(), + codec: self.state.codec, + level: self.state.level, + password: self.state.archive_password.clone(), + }); + } + + // What a drop means depends on what the window is already showing. With + // nothing open there is only one thing it can be, and that is what it has + // always done: open it. With an archive open, dropping a file on it means + // putting the file inside, which is what every other archiver does and what + // opening a second archive over the first never was. + // + // The exception is dropping an archive onto an archive, which is honestly + // both, so it asks instead of picking one and being wrong half the time. + pub(crate) fn copy_to_clipboard(&mut self, cut: bool) { + let s: &'static Strings = self.s(); + let Some(archive) = self.state.archive.clone() else { + return; + }; + let roots = self.selected_roots(); + if roots.is_empty() { + return; + } + // A folder of its own per copy, so the paths already on the clipboard + // never end up pointing at something a later copy overwrote. + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let dir = std::env::temp_dir() + .join("Arca") + .join(format!("clip-{stamp:x}")); + let previous = self.state.clip_dir.replace(dir.clone()); + let names = self.selected_names(); + // Before the old folder is thrown away further down: an earlier cut + // still waiting was watching for those files to disappear, and this is + // about to delete them itself. + self.state.cut_armed = None; + self.state.cut_pending = None; + self.state.cut_names = if cut { + names.iter().cloned().collect() + } else { + HashSet::new() + }; + // Where each picked thing will land once extracted. Worked out here + // rather than in the thread because it is also what a pending cut has + // to watch, and only a .zip can have entries taken out of it in place. + let landed: Vec = roots + .iter() + .filter_map(|r| arca_core::safe_name(r).ok()) + .map(|r| dir.join(r)) + .collect(); + if cut && detect(&archive) == Some(Format::Zip) { + self.state.cut_armed = Some(Cut { + archive: archive.clone(), + paths: landed.clone(), + names, + }); + } + + let wanted = self.state.checked.clone(); + let total = wanted.iter().filter(|b| **b).count(); + let pw = self.state.archive_password.clone(); + self.state.close_when_done = false; + self.spawn(total, move |tx| { + let notify = |i: usize, n: usize, name: &str| { + let _ = tx.send(Message::Progress(i, n, name.to_string())); + true + }; + // A folder nobody has seen yet has nothing in it to overwrite, so + // there is no question to put on screen. + let ask = |_: &Path| Answer::Replace; + let outcome = extract(&archive, &dir, &wanted, ¬ify, &ask, pw.as_deref()) + .map_err(|e| e.to_string()) + .and_then(|_| clipboard::set_files(&landed, cut).map(|()| landed.len())); + // Only once the new list is on the clipboard: until that moment the + // old paths are still what a paste would reach for. + if let Some(old) = previous { + let _ = fs::remove_dir_all(old); + } + let _ = tx.send(match outcome { + // Nothing to say. Copying somewhere else does not announce + // itself either, and the rows a cut is holding are already + // faded; what is worth a line is the entries leaving the + // archive, and that has its own. An empty message hands the + // status bar back to the summary of what is open. + Ok(_) => { + if cut { + let _ = tx.send(Message::CutReady); + } + Message::Done(String::new()) + } + Err(why) => Message::Failed(fill(s.clipboard_failed, &[("why", &why)])), + }); + }); + // After `spawn`, which clears it: this is the one job that runs without + // saying so. + self.state.quiet = true; + } + + // Whether the cut waiting on a paste has had it. + // + // Windows never says. What it does instead, when the clipboard asked for a + // move rather than a copy, is take the files out of the folder they were + // handed over in, so their absence is the whole of the evidence. It is + // checked when the window gets the keyboard back, because pasting somewhere + // else means having gone somewhere else first. + // + // Only all of them counts. A cut that is half gone is more likely to be a + // paste still running than one that finished, and leaving the entries where + // they are costs nothing: they will still be there next time. Every way + // this can be wrong leaves the archive untouched, which is the side to be + // wrong on when there is no undo. + pub(crate) fn open_file(&mut self, index: usize) { + let Some(archive) = self.state.archive.clone() else { + return; + }; + let Some(entry) = self.state.entries.get(index).cloned() else { + return; + }; + if entry.is_dir { + return; + } + let password = self.state.archive_password.clone(); + let s = self.s(); + self.state.close_when_done = false; + self.state.title = s.opening.to_string(); + self.state.view = View::Running; + self.spawn(1, move |tx| { + let _ = tx.send(Message::Progress(0, 1, entry.name.clone())); + let outcome = extract_one(&archive, &entry, password.as_deref()) + .and_then(|path| launch_with_system(&path).map(|()| path)); + let _ = tx.send(match outcome { + Ok(path) => Message::Done(fill( + s.opened_with_system, + &[("name", &path.display().to_string())], + )), + Err(e) => Message::Failed(e.to_string()), + }); + }); + } + + // Everything the keyboard does to the list, in one place. `rows` is what is + // on screen right now, which is what the arrows should walk: filtering or + // changing folder changes the list under the cursor, so it is clamped here + // rather than tracked separately. + pub(crate) fn receive(&mut self) -> bool { + // The answer about a newer version, if it ever came. Its own channel, + // because it is not a job and must not make the window look busy. + if let Some(rx) = &self.state.update_rx { + if let Ok(release) = rx.try_recv() { + self.state.update = Some(release); + self.state.update_rx = None; + } + } + let mut close = false; + let mut finished_ok = false; + // Set when the installer is down and checked, and answered as "close + // the window": running it is Inno replacing the program that is open. + let mut installing = false; + if let Some(rx) = &self.state.channel { + while let Ok(m) = rx.try_recv() { + match m { + Message::Listing(path, v) => { + if v.iter().any(|e| e.encrypted) && self.state.archive_password.is_none() { + self.state.password_input.clear(); + self.state.archive_password = None; + self.state.waiting_on_password = Some(Pending::OpenArchive); + } + // Nothing picked to begin with. It used to be + // everything, which was invisible while the ticks were + // the only sign of it; now that a picked row is painted + // it would open as a wall of blue, and "everything is + // selected" is not what a list means when you open it. + // The buttons that work on the whole archive never + // looked at the ticks anyway. + self.state.checked = vec![false; v.len()]; + self.state.folders = tree::folders_of(&v); + self.state.entries = v; + if let Some(f) = detect(&path) { + self.state.format = f; + } + // The name of what is open goes where every other + // program puts it, which frees a whole row above the + // list for nothing at all. + self.state.window_title = format!( + "{} — Arca", + path.file_name() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or_default() + ); + self.state.archive = Some(path); + self.state.history = vec![String::new()]; + self.state.here = 0; + self.state.current_dir = String::new(); + self.state.busy = false; + close = true; + } + Message::Conflict(path) => { + self.state.conflict = Some(path); + } + Message::Progress(done, total, name) => { + self.state.done_count = done; + self.state.total_count = total; + self.state.current_file = name; + } + Message::Done(text) => { + self.state.notice = text; + self.state.busy = false; + close = true; + finished_ok = true; + } + Message::Failed(text) => { + // Stopping is not failing. Nothing is wrong with the + // archive and there is nothing to report in red: the + // rewrite gave up before it swapped anything. + let quit = text == arca_core::Error::Cancelled.to_string(); + self.state.notice = if quit { + self.s().stopped.to_string() + } else { + text + }; + self.state.error = !quit; + self.state.busy = false; + close = true; + } + // The installer is down and checked. It is run silently and + // Arca stands aside: Inno Setup closes the program it is + // about to replace and opens it again when it finishes, + // which is how something that is running gets updated. + Message::Downloaded(path) => { + self.state.busy = false; + close = true; + let version = self + .state + .update + .as_ref() + .map(|r| r.tag.clone()) + .unwrap_or_default(); + self.state.notice = + fill(self.s().update_installing, &[("version", &version)]); + match install_update(&path) { + Ok(()) => installing = true, + Err(e) => { + self.state.notice = e; + self.state.error = true; + } + } + } + Message::CutReady => { + self.state.cut_pending = self.state.cut_armed.take(); + } + } + } + } + if close { + self.state.channel = None; + self.state.quiet = false; + if !self.state.entries.is_empty() && self.state.notice.is_empty() { + self.state.notice = self.summary(); + } + } + // The archive on disk is not the one that was listed any more. Reopen it + // with the password it now carries, so the browse view shows the new + // state and does not ask for a password it was just handed. + if finished_ok { + if let Some((path, pw)) = self.state.after_password.take() { + let notice = std::mem::take(&mut self.state.notice); + self.open(path); + self.state.archive_password = pw; + self.state.notice = notice; + self.state.view = View::Browse; + } + } + installing + || (finished_ok + && self.state.close_when_done + && matches!(self.state.view, View::Running)) + } + pub(crate) fn paste_from_clipboard(&mut self) { + let Some(archive) = self.state.archive.clone() else { + return; + }; + let here = fs::canonicalize(&archive).unwrap_or_else(|_| archive.clone()); + // Pasting the archive into itself would have the rewrite reading the + // file it is replacing. + let inputs: Vec = clipboard::files() + .into_iter() + .filter(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()) != here) + .collect(); + if inputs.is_empty() { + self.state.notice = self.s().clipboard_empty.to_string(); + self.state.error = true; + return; + } + self.add_files(inputs); + } + + // Files from anywhere outside into the folder the window is showing. Both + // the paste and the drop end here so they cannot answer the same question + // two different ways. + pub(crate) fn visible_rows(&self) -> Vec { + let filter = self.state.filter.trim().to_lowercase(); + // Flat view: every file in the archive at once, wherever it is filed. + // It is how you find something when you know its name and not its + // folder, and it is the same list a filter builds, only without one. + let flat = self.state.settings.flat && filter.is_empty(); + let mut rows = if flat { + self.state + .entries + .iter() + .enumerate() + .filter(|(_, e)| !e.is_dir) + .map(|(i, e)| { + let full = e.name.replace('\\', "/"); + Row { + // The leaf here and the folder in its own column, the + // way WinRAR splits them: a column of paths that all + // begin the same way is a column you read the end of. + label: full.rsplit('/').next().unwrap_or(&full).to_string(), + kind: kind_of(&full, false), + path: full, + is_dir: false, + entry: Some(i), + size: e.size, + packed: e.compressed_size, + method: e.method.name(), + encrypted: e.encrypted, + count: 0, + mtime: e.mtime, + created: e.created, + accessed: e.accessed, + attributes: e.attributes, + crc32: e.crc32, + up: false, + } + }) + .collect() + } else if filter.is_empty() { + children_of(&self.state.entries, &self.state.current_dir) + } else { + self.state + .entries + .iter() + .enumerate() + .filter(|(_, e)| !e.is_dir && e.name.to_lowercase().contains(&filter)) + .map(|(i, e)| Row { + label: e.name.replace('\\', "/"), + path: e.name.replace('\\', "/"), + kind: kind_of(&e.name, false), + is_dir: false, + entry: Some(i), + size: e.size, + packed: e.compressed_size, + method: e.method.name(), + encrypted: e.encrypted, + count: 0, + mtime: e.mtime, + created: e.created, + accessed: e.accessed, + attributes: e.attributes, + crc32: e.crc32, + up: false, + }) + .collect() + }; + + let (col, asc) = self.state.order; + rows.sort_by(|x, y| { + if x.is_dir != y.is_dir { + return if x.is_dir { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + }; + } + let o = match col { + // Folded a character at a time rather than through two new + // strings. `to_lowercase()` here allocated twice per + // comparison, which for fifteen hundred rows is some thirty + // thousand allocations every time the list is built -- and the + // list is built on every pointer move while a band is being + // pulled. + SortColumn::Name => x + .label + .chars() + .flat_map(char::to_lowercase) + .cmp(y.label.chars().flat_map(char::to_lowercase)), + SortColumn::Size => x.size.cmp(&y.size), + SortColumn::Packed => x.packed.cmp(&y.packed), + SortColumn::Method => x.method.cmp(y.method), + SortColumn::Saved => saved_of(x) + .partial_cmp(&saved_of(y)) + .unwrap_or(std::cmp::Ordering::Equal), + SortColumn::Modified => x.mtime.cmp(&y.mtime), + SortColumn::Crc => x.crc32.cmp(&y.crc32), + // By extension, which is what the type is worked out from: + // sorting by the words themselves would need the shell asked + // about every entry in the archive to answer one click. + SortColumn::Type => arca_icons::cache_key(&x.label, x.is_dir) + .cmp(&arca_icons::cache_key(&y.label, y.is_dir)), + SortColumn::Path => folder_of(&x.path).cmp(folder_of(&y.path)), + SortColumn::Created => x.created.cmp(&y.created), + SortColumn::Accessed => x.accessed.cmp(&y.accessed), + SortColumn::Attributes => x.attributes.cmp(&y.attributes), + }; + if asc { + o + } else { + o.reverse() + } + }); + // Put on after the sort, because it belongs at the top whichever column + // the list is held by and whichever way round. + if !flat && filter.is_empty() && !self.state.current_dir.is_empty() { + rows.insert(0, up_row(&self.state.current_dir)); + } + rows + } + pub(crate) fn rename_to(&mut self, rows: &[Row], path: &str, name: &str) { + let Some(row) = rows.iter().find(|r| r.path == path) else { + return; + }; + if name == row.label { + return; + } + let s = self.s(); + if name.is_empty() || name.contains('/') || name.contains('\\') { + self.state.notice = s.bad_name.to_string(); + self.state.error = true; + return; + } + // Only against what is in this folder: the same name elsewhere in the + // archive is somebody else's business. + if rows + .iter() + .any(|r| r.path != path && r.label.eq_ignore_ascii_case(name)) + { + self.state.notice = fill(s.name_taken, &[("name", name)]); + self.state.error = true; + return; + } + let to = match path.rsplit_once('/') { + Some((parent, _)) => format!("{parent}/{name}"), + None => name.to_string(), + }; + let Some(archive) = self.state.archive.clone() else { + return; + }; + self.run_job(Job::Rename { + archive, + from: path.to_string(), + to, + folder: row.is_dir, + password: self.state.archive_password.clone(), + }); + } + + // The rule between two columns, and the handle that moves it. + // + // The handle is a hand's width either side of the rule and only as tall as + // the header, which is where every list of files on the machine puts it. + // The table's own went from the header to the foot of the list, so six + // columns meant six invisible strips down the length of it and a press + // near any of them was a column edge rather than the start of a selection. + // The rule is still drawn the whole way down: that is what tells you which + // number belongs under which heading halfway down a page. + #[allow(clippy::too_many_arguments)] + pub(crate) fn dropped(&mut self, paths: Vec) { + if paths.is_empty() { + return; + } + self.state.notice.clear(); + self.state.error = false; + let open_first = |me: &mut Self, paths: Vec| { + if let Some(p) = paths.into_iter().next() { + me.open(p); + } + }; + let Some(archive) = self.state.archive.clone() else { + open_first(self, paths); + return; + }; + let all_archives = paths.iter().all(|p| detect(p).is_some()); + // Only a .zip can be added to in place. With a .tar open there is + // nothing to weigh up: an archive opens, and anything else has to say + // why it cannot go in rather than quietly do nothing. + if detect(&archive) != Some(Format::Zip) { + if all_archives { + open_first(self, paths); + } else { + self.state.notice = self.s().only_zip_can_change.to_string(); + self.state.error = true; + } + return; + } + if all_archives { + self.state.confirm_drop = Some(paths); + return; + } + self.add_files(paths); + } + + // A drop used to mean one thing and now means another, so while something + // is held over the window it says which. Guessing in silence is what made + // the old behaviour surprising in the first place. + pub(crate) fn view_entry(&mut self, index: usize) { + let Some(archive) = self.state.archive.clone() else { + return; + }; + let Some(entry) = self.state.entries.get(index).cloned() else { + return; + }; + let s = self.s(); + if entry.is_dir { + return; + } + if entry.size > VIEW_LIMIT { + self.state.notice = fill(s.too_big_to_view, &[("size", &human(VIEW_LIMIT))]); + self.state.error = true; + return; + } + let mut bytes = Vec::with_capacity(entry.size as usize); + if let Err(e) = read_entry( + &archive, + index, + &mut bytes, + self.state.archive_password.as_deref(), + ) { + self.state.notice = e.to_string(); + self.state.error = true; + return; + } + + let name = entry.name.rsplit(['/', '\\']).next().unwrap_or(&entry.name); + // Asked once, and only of the names that claim to be pictures: handing + // every unknown file to a decoder to find out is a decoder run on + // whatever happens to be in the archive. + let picture = looks_like_picture(name) + && image::guess_format(&bytes).is_ok_and(|f| { + image::ImageReader::new(std::io::Cursor::new(&bytes)) + .with_guessed_format() + .is_ok_and(|r| r.format() == Some(f)) + }); + let look = if picture { + Look::Picture + } else if looks_like_text(&bytes) { + Look::Text + } else { + Look::Hex + }; + // Split now, once. The text is drawn a line at a time and only the + // lines on screen are laid out, so a log of a million lines opens as + // fast as a note of three. + let lines = String::from_utf8_lossy(&bytes) + .lines() + .map(|l| l.to_string()) + .collect(); + self.state.viewing = Some(Viewed { + name: name.to_string(), + bytes: bytes.into(), + look, + lines, + picture, + }); + } + + // The file being looked at, in its own window over the list. + pub(crate) fn open(&mut self, path: PathBuf) { + self.state.archive_password = None; + // Whatever was cut belonged to the listing being replaced, and so did + // whatever the status bar was saying: the summary of the archive being + // closed sat there over the one that had just opened. + self.state.cut_names.clear(); + self.state.cut_armed = None; + self.state.cut_pending = None; + self.state.notice.clear(); + self.state.error = false; + self.remember(&path); + self.spawn(0, move |tx| { + let m = match list_entries(&path) { + Ok(v) => Message::Listing(path, v), + Err(e) => Message::Failed(e.to_string()), + }; + let _ = tx.send(m); + }); + } + + // Reading the central directory is enough to know whether the archive is + // encrypted, and costs nothing next to extracting it. Asking here, before + // any work starts, keeps the question on the window's own thread. + pub(crate) fn run_job(&mut self, job: Job) { + if let Job::Extract { + archives, + password: None, + .. + } = &job + { + if archives.iter().any(|a| is_encrypted(a)) { + self.state.password_input.clear(); + self.state.waiting_on_password = Some(Pending::Extract(Box::new(job))); + self.state.view = View::Running; + self.state.title = self.s().extracting.to_string(); + return; + } + } + let s: &'static Strings = self.s(); + let verb = match &job { + Job::Extract { .. } => s.extracting.to_string(), + Job::Test { .. } => s.testing.to_string(), + Job::Password { .. } => s.changing_password.to_string(), + Job::Delete { .. } => s.deleting.to_string(), + Job::Rename { .. } => s.renaming.to_string(), + Job::CopyTo { .. } => s.copying_word.to_string(), + Job::NewFolder { .. } => s.adding.to_string(), + Job::Move { .. } => s.moving_word.to_string(), + Job::Compress { .. } => s.compressing.to_string(), + Job::Add { .. } => s.adding.to_string(), + // The only verb with a hole in it: which version is coming down is + // known to the job, not to the list of words. + Job::Update { tag, .. } => fill(s.update_downloading, &[("version", tag)]), + }; + // The download measures itself in bytes; everything else counts + // entries. + self.state.in_bytes = matches!(job, Job::Update { .. }); + // Getting the new version is asked for from this window's menu; the + // rest can come from the Explorer, and then there is no list behind it. + let from_here = self.state.archive.is_some() || matches!(job, Job::Update { .. }); + self.show_job(&verb, subject_of(&job), from_here); + self.state.close_when_done = !matches!( + job, + Job::Test { .. } + | Job::Password { .. } + | Job::Delete { .. } + | Job::Rename { .. } + | Job::CopyTo { .. } + | Job::NewFolder { .. } + | Job::Move { .. } + | Job::Add { .. } + ); + // The file on disk is about to change, so the listing has to be redone. + if let Job::Password { archive, new, .. } = &job { + self.state.after_password = Some((archive.clone(), new.clone())); + } + if let Job::Delete { + archive, password, .. + } = &job + { + self.state.after_password = Some((archive.clone(), password.clone())); + } + if let Job::Add { + archive, password, .. + } = &job + { + self.state.after_password = Some((archive.clone(), password.clone())); + } + if let Job::Rename { + archive, password, .. + } = &job + { + self.state.after_password = Some((archive.clone(), password.clone())); + } + // The ones that build the archive again leave the old one beside it. + // What is kept here is the word for the change, so that offering to + // take it back can say what it would be taking back. + // + // Without this the undo entry was never written, which is why Ctrl+Z + // and the menu entry were permanently greyed out. + let words = self.s(); + self.state.undo = match &job { + Job::Delete { archive, .. } => Some((archive.clone(), words.delete_word)), + Job::Rename { archive, .. } => Some((archive.clone(), words.rename_word)), + Job::Add { archive, .. } => Some((archive.clone(), words.add_to_archive)), + Job::Password { archive, .. } => Some((archive.clone(), words.password_word)), + Job::NewFolder { archive, .. } => Some((archive.clone(), words.new_folder)), + Job::Move { archive, .. } => Some((archive.clone(), words.moving_word)), + _ => None, + }; + + let (reply_tx, reply_rx) = channel::(); + self.state.replies = Some(reply_tx); + let (stop, hold) = self.fresh_flags(); + self.spawn(0, move |tx| { + use std::sync::atomic::Ordering; + let notify = |i: usize, n: usize, name: &str| { + let _ = tx.send(Message::Progress(i, n, name.to_string())); + // Held right here while it is paused. This is the end of an + // entry, which is the one moment the work is not in the middle + // of something; stopping still gets through, so a paused job + // can be given up on without being let go first. + while hold.load(Ordering::Relaxed) && !stop.load(Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(60)); + } + // The answer to "carry on?". Read on every step because that is + // the only place a long job looks up from what it is doing. + !stop.load(Ordering::Relaxed) + }; + // Getting the new version does not end in a text to read but in a + // file to run, and running it closes Arca. That is why it leaves by + // its own message and not by Done: who decides to install is the + // window, not this thread. + if let Job::Update { + installer, sums, .. + } = &job + { + let _ = tx.send(match download_update(installer, sums, s, ¬ify) { + Ok(path) => Message::Downloaded(path), + Err(text) => Message::Failed(text), + }); + return; + } + let ask = conflict_asker(tx, &reply_rx); + let outcome = run_job_blocking(job, s, ¬ify, &ask); + let _ = tx.send(match outcome { + Ok(text) => Message::Done(text), + Err(text) => Message::Failed(text), + }); + }); + } + pub(crate) fn is_checked(&self, row: &Row) -> bool { + // The way out of the folder is not a thing that can be picked. + if row.up { + return false; + } + match row.entry { + Some(i) => self.state.checked[i], + None => { + let under = entries_under(&self.state.entries, &row.path); + !under.is_empty() && under.iter().all(|&i| self.state.checked[i]) + } + } + } +} diff --git a/arca-gui/src/controller/state.rs b/arca-gui/src/controller/state.rs new file mode 100644 index 0000000..b1b0532 --- /dev/null +++ b/arca-gui/src/controller/state.rs @@ -0,0 +1,133 @@ +//! Runtime state owned by the application controller. + +use super::{Cut, Message, Pending, Release, View, Viewed}; +use crate::archive_ops::Answer; +use crate::model::{Format, SortColumn}; +use crate::settings::Settings; +use crate::tree; +use arca_core::{Codec, Entry, Level}; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, Sender}; +use std::time::Instant; + +pub(crate) struct AppState { + pub(crate) view: View, + pub(crate) settings: Settings, + pub(crate) archive: Option, + pub(crate) entries: Vec, + pub(crate) checked: Vec, + pub(crate) filter: String, + pub(crate) order: (SortColumn, bool), + pub(crate) channel: Option>, + pub(crate) notice: String, + pub(crate) error: bool, + pub(crate) busy: bool, + pub(crate) done_count: usize, + pub(crate) total_count: usize, + pub(crate) current_file: String, + pub(crate) started: Option, + pub(crate) format: Format, + pub(crate) codec: Codec, + pub(crate) level: Level, + pub(crate) into_subfolder: bool, + pub(crate) pending_inputs: Vec, + pub(crate) output_name: String, + pub(crate) close_when_done: bool, + pub(crate) title: String, + pub(crate) window_title: String, + pub(crate) current_dir: String, + pub(crate) show_settings: bool, + pub(crate) conflict: Option, + pub(crate) replies: Option>, + // A job held back until the password window has an answer. Extraction asks + // once, before it starts, rather than per entry: every entry in a .zip is + // encrypted with the same password, and asking again per file is noise. + pub(crate) waiting_on_password: Option, + pub(crate) password_input: String, + pub(crate) show_password: bool, + pub(crate) add_password: String, + // Held for the archive currently open in the window, so extracting from it + // does not ask again for every button press. + pub(crate) archive_password: Option, + // Where to look again once a password job has rewritten the archive, and the + // password it now carries. + pub(crate) after_password: Option<(PathBuf, Option)>, + // Where the window has been, so the mouse back and forward buttons have + // somewhere to go. `here` indexes into it; going somewhere new throws away + // whatever was ahead, the way a browser does. + pub(crate) history: Vec, + pub(crate) here: usize, + // The row the keyboard is on. Everything the arrows, Enter and Space do + // hangs off this, and there was no such thing before: the table had + // checkboxes but no cursor. None means nothing is focused yet. + pub(crate) cursor: Option, + // The entry being renamed and what has been typed into it so far. Held by + // path rather than by row number so that sorting or filtering underneath a + // half typed name cannot move the box onto somebody else's row. + pub(crate) renaming: Option<(String, String)>, + // One password to try before asking, for a folder of archives all locked + // with the same word. Never written anywhere: see `default_password_window`. + pub(crate) default_password: Option, + pub(crate) asking_default_password: bool, + // Set while the box that asks for a new folder's name is up. + pub(crate) asking_folder: bool, + // The archive that has a previous version kept beside it, and the word for + // what was done to it. One step back, which is the one anybody wants: + // deeper than that and the sidecars would pile up. + pub(crate) undo: Option<(PathBuf, &'static str)>, + // The newer Arca, once the announcement has answered. Its own channel + // rather than a job, because something nobody asked for must not make the + // window look busy. + pub(crate) update: Option, + pub(crate) update_rx: Option>, + pub(crate) asked_about_updates: bool, + // Whether the two counts are bytes rather than entries. Only the download + // measures itself that way. + pub(crate) in_bytes: bool, + // What the job is being done to, beside the verb in the title. + pub(crate) subject: String, + // Whether the job is a panel over the list it was started from, rather than + // the whole window. A job that came from the Explorer has no list behind it + // to go back to. + pub(crate) overlay: bool, + // Told to give up, and told to hold. Shared with the thread doing the work, + // which reads both at the end of every entry -- the one moment it is not in + // the middle of something. + pub(crate) stop: std::sync::Arc, + pub(crate) hold: std::sync::Arc, + // The folders of the archive, rebuilt when a listing arrives rather than + // every frame: it is fifteen hundred paths split on every slash and the + // answer only changes when the archive does. + pub(crate) folders: tree::Folder, + // The file being looked at without taking it out of the archive. + pub(crate) viewing: Option, + // Set while the box that picks a group by name is up: true to add what + // matches to the selection, false to take it away. + pub(crate) picking_group: Option, + // Set while the wheel is being used to walk the list up and down. + // The last row a left click landed on, and when. What tells a second click + // on the same row from the first one of a new pair. + pub(crate) last_click: Option<(usize, f64)>, + // Names waiting on a yes before they are taken out of the archive. There + // is no undo, so this one asks. + pub(crate) confirm_delete: Option>, + // An archive dropped onto an open archive, which is two reasonable things + // at once and so gets asked about rather than guessed at. + pub(crate) confirm_drop: Option>, + // The folder the last Ctrl+C or Ctrl+X extracted into. The clipboard is + // holding paths inside it, so it stays until the next copy replaces it and + // makes those paths meaningless anyway. + pub(crate) clip_dir: Option, + // What the last Ctrl+X put on the clipboard, so those rows can show it. + pub(crate) cut_names: HashSet, + // Made ready before the extraction runs and armed only when it says the + // clipboard took it, which is what `Message::CutReady` reports. + pub(crate) cut_armed: Option, + pub(crate) cut_pending: Option, + pub(crate) show_shortcuts: bool, + // Set for work that says nothing while it runs. Copying to the clipboard is + // the only such job: it is over before a bar has finished appearing, and a + // bar that flashes past says less than nothing. + pub(crate) quiet: bool, +} diff --git a/arca-gui/src/glyphs.rs b/arca-gui/src/glyphs.rs deleted file mode 100644 index 0d5f801..0000000 --- a/arca-gui/src/glyphs.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! The little pictures on the buttons. -//! -//! Painted rather than loaded. An icon font would be a file to ship and a -//! licence to track for eight shapes, and an SVG loader a whole renderer; these -//! are a dozen lines of rectangles and lines each, they take the colour of -//! whatever button they sit on, and they cannot come out as a hollow box on a -//! machine that is missing something. The arrows in the navigation row were -//! already drawn this way and these keep them company. -//! -//! All of them are drawn inside a square of `SIZE` and read at that size: no -//! detail smaller than a pixel, nothing that depends on a hairline landing on -//! an exact half pixel. - -use eframe::egui::{self, Color32, Pos2, Rect, Stroke, Vec2}; - -/// The square every glyph is drawn inside. -pub const SIZE: f32 = 15.0; - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Glyph { - /// A folder standing open: opening an archive. - Open, - /// A closed box: making one. - Compress, - /// A box with an arrow leaving it downwards: taking everything out. - ExtractAll, - /// The same with a tick beside it: taking out what is picked. - ExtractPicked, - /// A padlock, shut or open, for putting a password on and taking it off. - Locked, - Unlocked, - /// A horizontal ellipsis: everything that did not fit on the bar. - More, - /// The three that walk the folders. - Back, - Forward, - Up, -} - -/// The same picture out of the font Windows draws its own programs with. -/// -/// Preferred over everything below it. Drawing an icon that reads at fifteen -/// points is a craft, and the hand-painted padlock came out as a handbag and -/// the hand-painted cogwheel as an asterisk; more to the point, these are the -/// shapes the user has already learned from every other window on the machine. -/// What stays below is the fallback for a machine without the font. -pub fn codepoint(glyph: Glyph) -> Option { - Some(match glyph { - // A page with an arrow leaving it. The plain open folder, E838, is - // about folders, and what is being opened here is a file. - Glyph::Open => '\u{E8E5}', - // A folder with a zip fastener down it, which is the icon Windows - // itself puts on a .zip. - Glyph::Compress => '\u{F012}', - // An arrow coming down onto a line: out of the archive and onto the - // disk. Both extract buttons share it; the words tell them apart. - Glyph::ExtractAll | Glyph::ExtractPicked => '\u{E896}', - Glyph::Locked => '\u{E72E}', - Glyph::Unlocked => '\u{E785}', - Glyph::More => '\u{E712}', - Glyph::Back => '\u{E72B}', - Glyph::Forward => '\u{E72A}', - Glyph::Up => '\u{E74A}', - }) -} - -fn line(p: &egui::Painter, a: Pos2, b: Pos2, c: Color32, w: f32) { - p.line_segment([a, b], Stroke::new(w, c)); -} - -/// Draws `glyph` in `rect`, in `color`. The rect is expected to be square and -/// about [`SIZE`] across; anything else still draws, just not as carefully. -pub fn draw(painter: &egui::Painter, rect: Rect, glyph: Glyph, color: Color32) { - let c = rect.center(); - let r = Rect::from_center_size(Pos2::new(c.x.round(), c.y.round()), Vec2::splat(SIZE)); - let (x0, y0, x1, y1) = (r.left(), r.top(), r.right(), r.bottom()); - let thin = 1.4; - - match glyph { - Glyph::Open => { - // A folder, tab and all. It started as a box with a lid and could - // not be told apart from the one next to it; the three that follow - // are all boxes, so this one had better not be. - painter.add(egui::Shape::convex_polygon( - vec![ - Pos2::new(x0 + 1.0, y1 - 2.0), - Pos2::new(x0 + 1.0, y0 + 3.0), - Pos2::new(x0 + 6.0, y0 + 3.0), - Pos2::new(x0 + 7.5, y0 + 5.0), - Pos2::new(x1 - 1.0, y0 + 5.0), - Pos2::new(x1 - 1.0, y1 - 2.0), - ], - Color32::TRANSPARENT, - Stroke::new(thin, color), - )); - } - Glyph::Compress => { - // The same box as extracting, with the arrow going the other way. - // In and out is the whole difference between the two jobs, so it is - // the whole difference between the two pictures. - let (l, right) = (x0 + 1.5, x1 - 1.5); - line( - painter, - Pos2::new(l, y0 + 5.0), - Pos2::new(l, y1 - 1.5), - color, - thin, - ); - line( - painter, - Pos2::new(right, y0 + 5.0), - Pos2::new(right, y1 - 1.5), - color, - thin, - ); - line( - painter, - Pos2::new(l, y1 - 1.5), - Pos2::new(right, y1 - 1.5), - color, - thin, - ); - let mid = (l + right) / 2.0; - line( - painter, - Pos2::new(mid, y0 + 0.5), - Pos2::new(mid, y0 + 5.5), - color, - thin, - ); - painter.add(egui::Shape::convex_polygon( - vec![ - Pos2::new(mid - 2.5, y0 + 4.0), - Pos2::new(mid + 2.5, y0 + 4.0), - Pos2::new(mid, y0 + 7.5), - ], - color, - Stroke::NONE, - )); - } - Glyph::ExtractAll | Glyph::ExtractPicked => { - // A box open at the top with something coming out of it, and the - // same picture for both buttons. - // - // Two goes at telling them apart failed at this size: a tick beside - // the box meant squeezing the box narrow and both halves came out - // cramped, and a bar left inside it was too small to see at all. - // The two buttons are a hand's width apart and both are labelled, - // so the label is what tells them apart and the picture says what - // family they belong to. A difference nobody can see is worse than - // no difference. - let (l, right) = (x0 + 1.5, x1 - 1.5); - line( - painter, - Pos2::new(l, y0 + 5.0), - Pos2::new(l, y1 - 1.5), - color, - thin, - ); - line( - painter, - Pos2::new(right, y0 + 5.0), - Pos2::new(right, y1 - 1.5), - color, - thin, - ); - line( - painter, - Pos2::new(l, y1 - 1.5), - Pos2::new(right, y1 - 1.5), - color, - thin, - ); - // Rising out of the box, which is the whole difference from the - // picture next door: that arrow goes in, this one comes out. - let mid = (l + right) / 2.0; - line( - painter, - Pos2::new(mid, y0 + 3.0), - Pos2::new(mid, y0 + 9.0), - color, - thin, - ); - painter.add(egui::Shape::convex_polygon( - vec![ - Pos2::new(mid - 2.5, y0 + 4.0), - Pos2::new(mid + 2.5, y0 + 4.0), - Pos2::new(mid, y0 + 0.5), - ], - color, - Stroke::NONE, - )); - } - Glyph::Locked | Glyph::Unlocked => { - // Outlined with a keyhole, not a filled slab: a solid body came out - // as a blob with a wire over it. The shackle is a real arc rather - // than three straight pieces, which is most of what makes it read - // as a padlock instead of as a rectangle wearing a bracket. - let body = - Rect::from_min_max(Pos2::new(x0 + 2.0, y0 + 6.5), Pos2::new(x1 - 2.0, y1 - 1.5)); - painter.rect_stroke(body, 1.5, Stroke::new(thin, color)); - painter.circle_filled(Pos2::new(body.center().x, body.center().y), 1.2, color); - - let shut = matches!(glyph, Glyph::Locked); - // Shut, the arc sits over the middle of the body. Open, it is the - // same arc lifted and turned, hinged on its right leg. - let cx = if shut { - body.center().x - } else { - body.center().x + 2.2 - }; - let radius = 3.1; - let bottom = y0 + 6.5; - let steps = 12; - let mut arc: Vec = (0..=steps) - .map(|k| { - let t = std::f32::consts::PI * (k as f32) / (steps as f32); - Pos2::new(cx - radius * t.cos(), bottom - radius * t.sin()) - }) - .collect(); - if !shut { - // The near leg stops short, which is what an open one looks - // like; the far one still reaches the body. - arc.truncate(steps - 2); - } - painter.add(egui::Shape::line(arc, Stroke::new(1.6_f32, color))); - } - Glyph::More => { - for k in [-1.0_f32, 0.0, 1.0] { - painter.circle_filled(Pos2::new(r.center().x + k * 4.6, r.center().y), 1.35, color); - } - } - Glyph::Back | Glyph::Forward | Glyph::Up => { - let c = r.center(); - let (aw, ah) = (4.5, 5.5); - let p = match glyph { - Glyph::Back => [ - Pos2::new(c.x + aw * 0.6, c.y - ah), - Pos2::new(c.x + aw * 0.6, c.y + ah), - Pos2::new(c.x - aw, c.y), - ], - Glyph::Forward => [ - Pos2::new(c.x - aw * 0.6, c.y - ah), - Pos2::new(c.x - aw * 0.6, c.y + ah), - Pos2::new(c.x + aw, c.y), - ], - _ => [ - Pos2::new(c.x - ah, c.y + aw * 0.6), - Pos2::new(c.x + ah, c.y + aw * 0.6), - Pos2::new(c.x, c.y - aw), - ], - }; - painter.add(egui::Shape::convex_polygon(p.to_vec(), color, Stroke::NONE)); - } - } -} diff --git a/arca-gui/src/gpui_shell/input.rs b/arca-gui/src/gpui_shell/input.rs new file mode 100644 index 0000000..74a3c79 --- /dev/null +++ b/arca-gui/src/gpui_shell/input.rs @@ -0,0 +1,452 @@ +//! Text input adapter and native dialog results for the GPUI shell. + +use super::{AppAction, Backspace, GpuiShell, SelectAll}; +use crate::Strings; +use gpui::{ + div, point, prelude::*, px, size, App, Bounds, Element, ElementId, ElementInputHandler, Entity, + EntityInputHandler, FocusHandle, Focusable, GlobalElementId, LayoutId, Role, ShapedLine, Style, + TextRun, UTF16Selection, WeakEntity, Window, +}; +use gpui_component::ActiveTheme; +use std::ops::Range; +use std::path::PathBuf; + +pub(super) enum DialogResult { + Open(Option), + Compress(Option>), + Extract { + only_checked: bool, + destination: Option, + }, + AddFiles(Option>), + SaveCopy(Option), +} + +/// The smallest native text input GPUI needs for this surface. It follows the +/// same UTF-16 contract used by platform IMEs, while the controller stores UTF-8. +#[derive(Clone, Copy)] +pub(super) enum TextFieldKind { + Password, + OutputName, + AddPassword, + /// The one field shared by the dialogs that ask for a piece of text: a new + /// folder, a new name, a mask. They are modal and mutually exclusive, so + /// one field with the name the open dialog gives it is one field, not + /// three that are always empty. + Name, +} + +pub(super) struct FilterInput { + pub(super) owner: WeakEntity, + pub(super) kind: TextFieldKind, + /// The field reads its own name out to a screen reader, so it needs the + /// language too. The shell pushes it in on every frame, because the + /// settings dialog can change it while the window is up. + pub(super) strings: &'static Strings, + /// What a screen reader calls this field. Set by the shell each frame, + /// because the shared `Name` field is a folder name in one dialog and a + /// mask in another. + pub(super) label: &'static str, + pub(super) masked: bool, + pub(super) focus_handle: FocusHandle, + pub(super) enabled: bool, + pub(super) content: String, + pub(super) selected_range: Range, + pub(super) marked_range: Option>, + pub(super) last_layout: Option, + pub(super) last_bounds: Option>, +} + +impl FilterInput { + fn utf8_from_utf16(text: &str, offset: usize) -> usize { + let mut utf16 = 0; + for (index, character) in text.char_indices() { + if offset <= utf16 { + return index; + } + utf16 += character.len_utf16(); + if offset < utf16 { + return index; + } + } + text.len() + } + + fn utf16_from_utf8(text: &str, offset: usize) -> usize { + text[..offset.min(text.len())] + .chars() + .map(char::len_utf16) + .sum() + } + + fn utf8_range(text: &str, range: Range) -> Range { + Self::utf8_from_utf16(text, range.start)..Self::utf8_from_utf16(text, range.end) + } + + fn utf16_range(text: &str, range: Range) -> Range { + Self::utf16_from_utf8(text, range.start)..Self::utf16_from_utf8(text, range.end) + } + + pub(super) fn sync_from_state(&mut self, value: &str) { + self.content.clear(); + self.content.push_str(value); + let cursor = self.content.len(); + self.selected_range = cursor..cursor; + self.marked_range = None; + } + + fn replace(&mut self, range: Range, value: &str, cx: &mut Context) { + if !self.enabled { + return; + } + self.content.replace_range(range.clone(), value); + let cursor = range.start + value.len(); + self.selected_range = cursor..cursor; + self.marked_range = None; + self.push_to_owner(cx); + cx.notify(); + } + + /// Hands what was typed back to whoever owns it. + /// + /// One copy, not one per entry point: a plain keystroke, an IME commit and + /// a marked-text edit all end here, and three copies of the same match is + /// three places for a new field to be forgotten in. + fn push_to_owner(&self, cx: &mut Context) { + let content = self.content.clone(); + let kind = self.kind; + let _ = self.owner.update(cx, |shell, cx| { + match kind { + TextFieldKind::Password => shell + .controller + .dispatch(AppAction::SetPasswordInput(content)), + TextFieldKind::OutputName => shell.controller.state.output_name = content, + TextFieldKind::AddPassword => shell.controller.state.add_password = content, + TextFieldKind::Name => shell.name_value = content, + } + cx.notify(); + }); + } + + fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + let range = if self.selected_range.is_empty() { + let Some((start, _)) = self.content[..self.selected_range.start] + .char_indices() + .next_back() + else { + window.play_system_bell(); + return; + }; + start..self.selected_range.end + } else { + self.selected_range.clone() + }; + self.replace(range, "", cx); + } + + fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.selected_range = 0..self.content.len(); + cx.notify(); + } +} + +impl EntityInputHandler for FilterInput { + fn text_for_range( + &mut self, + range_utf16: Range, + actual_range: &mut Option>, + _: &mut Window, + _: &mut Context, + ) -> Option { + let range = Self::utf8_range(&self.content, range_utf16); + actual_range.replace(Self::utf16_range(&self.content, range.clone())); + Some(self.content[range].to_string()) + } + + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut Context, + ) -> Option { + Some(UTF16Selection { + range: Self::utf16_range(&self.content, self.selected_range.clone()), + reversed: false, + }) + } + + fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { + self.marked_range + .as_ref() + .map(|range| Self::utf16_range(&self.content, range.clone())) + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut Context) { + self.marked_range = None; + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _: &mut Window, + cx: &mut Context, + ) { + if !self.enabled { + return; + } + let range = range_utf16 + .map(|range| Self::utf8_range(&self.content, range)) + .or_else(|| self.marked_range.clone()) + .unwrap_or_else(|| self.selected_range.clone()); + self.replace(range, new_text, cx); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + new_selected_range_utf16: Option>, + _: &mut Window, + cx: &mut Context, + ) { + if !self.enabled { + return; + } + let range = range_utf16 + .map(|range| Self::utf8_range(&self.content, range)) + .or_else(|| self.marked_range.clone()) + .unwrap_or_else(|| self.selected_range.clone()); + self.content.replace_range(range.clone(), new_text); + self.marked_range = + (!new_text.is_empty()).then_some(range.start..range.start + new_text.len()); + self.selected_range = new_selected_range_utf16 + .map(|selected| { + let selected = Self::utf8_range(new_text, selected); + range.start + selected.start..range.start + selected.end + }) + .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); + self.push_to_owner(cx); + cx.notify(); + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + bounds: Bounds, + _: &mut Window, + _: &mut Context, + ) -> Option> { + let line = self.last_layout.as_ref()?; + let range = Self::utf8_range(&self.content, range_utf16); + Some(Bounds::from_corners( + point(bounds.left() + line.x_for_index(range.start), bounds.top()), + point(bounds.left() + line.x_for_index(range.end), bounds.bottom()), + )) + } + + fn character_index_for_point( + &mut self, + point: gpui::Point, + _: &mut Window, + _: &mut Context, + ) -> Option { + if self.content.is_empty() { + return Some(0); + } + let bounds = self.last_bounds?; + let line = self.last_layout.as_ref()?; + let local_point = bounds.localize(&point)?; + Some(Self::utf16_from_utf8( + &self.content, + line.closest_index_for_x(local_point.x), + )) + } + + fn text_length_utf16(&mut self, _: &mut Window, _: &mut Context) -> Option { + Some(Self::utf16_from_utf8(&self.content, self.content.len())) + } +} + +struct FilterElement { + input: Entity, +} + +struct FilterPrepaint { + line: Option, +} + +impl IntoElement for FilterElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for FilterElement { + type RequestLayoutState = (); + type PrepaintState = FilterPrepaint; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.size.width = gpui::relative(1.).into(); + style.size.height = window.line_height().into(); + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let input = self.input.read(cx); + let value = if input.content.is_empty() { + match input.kind { + TextFieldKind::Password => input.strings.password_hint.to_string(), + TextFieldKind::OutputName => "archive.zip".to_string(), + TextFieldKind::AddPassword => input.strings.password_optional.to_string(), + TextFieldKind::Name => input.label.to_string(), + } + } else if matches!( + input.kind, + TextFieldKind::Password | TextFieldKind::AddPassword + ) && input.masked + { + "•".repeat(input.content.chars().count()) + } else { + input.content.clone() + }; + let style = window.text_style(); + let line = window.text_system().shape_line( + value.clone().into(), + style.font_size.to_pixels(window.rem_size()), + &[TextRun { + len: value.len(), + font: style.font(), + color: style.color, + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + FilterPrepaint { line: Some(line) } + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let (focus_handle, cursor) = { + let input = self.input.read(cx); + (input.focus_handle.clone(), input.selected_range.end) + }; + if self.input.read(cx).enabled { + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.input.clone()), + cx, + ); + } + let line = prepaint.line.take().expect("filter line"); + let cursor_x = line.x_for_index(cursor); + line.paint( + bounds.origin, + window.line_height(), + gpui::TextAlign::Left, + None, + window, + cx, + ) + .ok(); + if focus_handle.is_focused(window) { + window.paint_quad(gpui::fill( + Bounds::new( + point(bounds.left() + cursor_x, bounds.top()), + size(px(2.), bounds.size.height), + ), + cx.theme().caret, + )); + } + self.input.update(cx, |input, _| { + input.last_layout = Some(line); + input.last_bounds = Some(bounds); + }); + } +} + +impl Render for FilterInput { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let mut input = div() + .id(match self.kind { + TextFieldKind::Password => "password-input", + TextFieldKind::OutputName => "output-name-input", + TextFieldKind::AddPassword => "add-password-input", + TextFieldKind::Name => "name-input", + }) + .key_context("FilterInput") + .aria_label(self.label) + .aria_value( + if matches!( + self.kind, + TextFieldKind::Password | TextFieldKind::AddPassword + ) { + self.strings.password_word.into() + } else { + self.content.clone() + }, + ) + .track_focus(&self.focus_handle) + .tab_stop(self.enabled) + .border_1() + .border_color(cx.theme().input) + .bg(cx.theme().input_background()) + .rounded(cx.theme().radius) + .px_2() + .flex() + .items_center() + .h(px(26.)) + .w_full() + .text_xs() + .child(FilterElement { input: cx.entity() }); + if self.enabled { + input = input + .role(Role::TextInput) + .focusable() + .on_action(cx.listener(Self::backspace)) + .on_action(cx.listener(Self::select_all)); + } + input + } +} + +impl Focusable for FilterInput { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} diff --git a/arca-gui/src/gpui_shell/mod.rs b/arca-gui/src/gpui_shell/mod.rs new file mode 100644 index 0000000..32eb363 --- /dev/null +++ b/arca-gui/src/gpui_shell/mod.rs @@ -0,0 +1,6067 @@ +//! Opt-in GPUI surface for the G4.1 toolbar and navigation migration. +//! +//! This view translates user input into `AppAction`s. Archive work stays in +//! `AppController` workers; native file dialogs are bridged from a thread so +//! they never block GPUI's UI thread. The file table uses GPUI's virtualized +//! `uniform_list`; dialog state and overlays are rendered by GPUI, while native +//! file pickers stay on worker threads. + +mod input; +use input::*; + +use super::{ + fill, human, parent_of, saved_of, when, Answer, AppAction, AppController, Columns, DropChoice, + Job, Pending, Settings, SortColumn, Startup, Strings, View, +}; +use crate::{ + clipboard, gpui_theme, + tree::{Folder, Kind}, +}; +use gpui::{actions, point}; +use gpui::{ + canvas, div, prelude::*, px, size, uniform_list, App, Bounds, ClickEvent, Context, ElementId, + Entity, FocusHandle, Focusable, KeyBinding, KeyDownEvent, Role, ScrollStrategy, Stateful, + UniformListScrollHandle, WeakEntity, Window, WindowBounds, WindowOptions, +}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::menu::{DropdownMenu as _, PopupMenuItem}; +use gpui_component::separator::Separator; +use gpui_component::sidebar::{SidebarItem, SidebarMenu, SidebarMenuItem}; +use gpui_component::status_bar::StatusBar; +use gpui_component::tooltip::Tooltip; +use gpui_component::{ActiveTheme, Icon, IconName}; +use gpui_component::{Disableable, TitleBar, TITLE_BAR_HEIGHT}; +use gpui_platform::application; +use std::ops::Range; +use std::path::PathBuf; +use std::sync::mpsc::{channel, Receiver}; +use std::time::Duration; + +/// The ring GPUI paints around whatever the keyboard is on. +/// +/// One function rather than seven copies of the same closure, because a focus +/// ring that is not identical everywhere is a focus ring you have to look for. +/// It reads the tokens once and carries them into the closure, since +/// `focus_visible` runs without a context. +fn focus_ring(cx: &App) -> impl FnOnce(gpui::StyleRefinement) -> gpui::StyleRefinement { + let (ring, accent) = (cx.theme().ring, cx.theme().accent); + move |style: gpui::StyleRefinement| style.border_2().border_color(ring).bg(accent) +} + +const COMPACT_SIZE: (f32, f32) = (560.0, 300.0); +const NORMAL_SIZE: (f32, f32) = (1000.0, 660.0); +const MINIMUM_SIZE: (f32, f32) = (720.0, 320.0); + +/// Where the recently opened archives start in `overflow_item_focus`, and how +/// many of them the menu keeps room for. Ten is about as many as anybody scans +/// before giving up and going to the folder instead. +const RECENT_SLOT: usize = 12; +const RECENT_MAX: usize = 10; + +/// The offer of a newer Arca, which is only drawn when there is one. Parked +/// past the columns rather than at the front, so that adding it does not move +/// every other slot along. +const RELEASE_SLOT: usize = RECENT_SLOT + RECENT_MAX + 4 + Columns::ALL.len(); + +actions!( + arca_gpui, + [ + Backspace, + SelectAll, + FocusFilter, + CopyFiles, + CutFiles, + PasteFiles + ] +); + +struct GpuiShell { + controller: AppController, + filter: Entity, + password: Entity, + output_name: Entity, + add_password: Entity, + name_input: Entity, + focus_handle: FocusHandle, + list_focus: FocusHandle, + list_scroll: UniformListScrollHandle, + dialog: Option>, + overflow_open: bool, + breadcrumbs_open: bool, + overflow_trigger_focus: FocusHandle, + breadcrumbs_trigger_focus: FocusHandle, + open_trigger_focus: FocusHandle, + compress_trigger_focus: FocusHandle, + extract_all_trigger_focus: FocusHandle, + extract_selected_trigger_focus: FocusHandle, + password_trigger_focus: FocusHandle, + delete_trigger_focus: FocusHandle, + drop_trigger_focus: FocusHandle, + conflict_trigger_focus: FocusHandle, + overflow_menu_focus: FocusHandle, + breadcrumbs_menu_focus: FocusHandle, + overflow_item_focus: Vec, + breadcrumbs_item_focus: Vec, + dialog_return_focus: FocusHandle, + modal_seen: Option, + password_toggle_focus: FocusHandle, + dialog_primary_focus: FocusHandle, + dialog_secondary_focus: FocusHandle, + dialog_tertiary_focus: FocusHandle, + dialog_quaternary_focus: FocusHandle, + dialog_rename_focus: FocusHandle, + dialog_rename_all_focus: FocusHandle, + dialog_cancel_focus: FocusHandle, + add_format_focus: FocusHandle, + add_codec_focus: FocusHandle, + add_level_focus: FocusHandle, + add_password_toggle_focus: FocusHandle, + add_start_focus: FocusHandle, + add_cancel_focus: FocusHandle, + /// The eleven controls of the settings dialog, in the order they are drawn. + /// One vector rather than eleven fields because every one of them is the + /// same thing -- a row in a list of preferences -- and `SettingsControl` + /// already says which is which. + settings_focus: Vec, + /// Which row the right button was pressed on, and where the pointer was, + /// so the menu opens under it instead of in a fixed corner. + /// The band being drawn, while it is being drawn. + band: Option, + /// The wheel used as a button: press it and the list runs towards the + /// pointer until something puts it away. + wheel: Option, + /// Where the pointer was last seen, so the tick that keeps the list running + /// knows which way to go without an event of its own. + pointer: gpui::Point, + /// A selection is in the air. Where it lands is not decided until it leaves + /// the list or is dropped on a folder. + carrying: bool, + /// How many rows the list last drew. + /// + /// Kept because `visible_rows` walks every entry, allocates a `Row` for + /// each and sorts the lot: asking it for nothing but a count, twice per + /// pointer move, was most of why dragging a band felt heavy. Render is the + /// one place that already has the answer. + row_count: usize, + /// Which column edge is in hand: its slot in `Settings::widths`, where the + /// pointer was when it was grabbed, and how wide the column was then. + /// Kept as the state at the grab rather than as a running delta, so a + /// dropped mouse-move event cannot make the column drift. + resizing: Option<(usize, f32, f32)>, + row_menu: Option<(usize, gpui::Point)>, + row_menu_focus: FocusHandle, + row_menu_item_focus: Vec, + /// What the shared `Name` field currently holds. The controller has no + /// business knowing about a half-typed folder name, so it stays here until + /// the dialog is answered. + name_value: String, + /// Text, Hex and Picture, in that order. + viewer_focus: Vec, + viewer_scroll: UniformListScrollHandle, + /// The decoded picture, kept by the name it came out of the archive under. + /// Handing GPUI a fresh `Image` every frame would decode a thirty megabyte + /// photograph sixty times a second. + viewer_image: Option<(String, std::sync::Arc)>, + drop_paths: Vec, +} + +/// What the menu on a row offers. Everything here already exists as a +/// controller call or a dialog; the menu is only a second way in, for the +/// times the hand is already down on the list. +#[derive(Clone, Copy, PartialEq, Eq)] +enum RowAction { + Open, + ExtractSelection, + ExtractHere, + TestSelection, + View, + Rename, + Delete, + Copy, + Cut, + Paste, + CopyNames, + SelectAll, +} + +impl RowAction { + const ALL: [RowAction; 12] = [ + RowAction::Open, + RowAction::ExtractSelection, + RowAction::ExtractHere, + RowAction::TestSelection, + RowAction::View, + RowAction::Rename, + RowAction::Delete, + RowAction::Copy, + RowAction::Cut, + RowAction::Paste, + RowAction::CopyNames, + RowAction::SelectAll, + ]; + + /// What it is called, and the keys that do the same thing. A menu that does + /// not name the shortcut is a menu nobody graduates from. + fn label(self, s: &'static Strings) -> (&'static str, &'static str) { + match self { + RowAction::Open => (s.open_word, "Enter"), + RowAction::ExtractSelection => (s.extract_selected, "Ctrl+E"), + RowAction::ExtractHere => (s.extract_here, "Alt+W"), + RowAction::TestSelection => (s.test_selection, ""), + RowAction::View => (s.view_word, "F3"), + RowAction::Rename => (s.rename_word, "F2"), + RowAction::Delete => (s.delete_word, "Supr"), + RowAction::Copy => (s.copy_word, "Ctrl+C"), + RowAction::Cut => (s.cut_word, "Ctrl+X"), + RowAction::Paste => (s.paste_word, "Ctrl+V"), + RowAction::CopyNames => (s.copy_names, "Ctrl+Shift+C"), + RowAction::SelectAll => (s.select_all, "Ctrl+A"), + } + } + + /// Whether a rule goes above this one. Looking at an entry, changing it, + /// moving it through the clipboard and working on the selection are four + /// different things, and twelve entries in one run is a wall. + fn starts_group(self) -> bool { + matches!( + self, + RowAction::Rename | RowAction::Copy | RowAction::CopyNames + ) + } + + /// Copy, cut and paste are left out rather than greyed out where the shell + /// has nowhere to put them: a menu entry that can never do anything is + /// worse than no entry. + fn offered(self) -> bool { + !matches!(self, RowAction::Copy | RowAction::Cut | RowAction::Paste) || clipboard::AVAILABLE + } +} + +/// A control in the settings dialog, in draw order. The index into +/// `settings_focus` is `control as usize`, so the keyboard and the mouse reach +/// the same code instead of two copies of it. +#[derive(Clone, Copy, PartialEq, Eq)] +enum SettingsControl { + LangSystem, + LangEn, + LangEs, + ThemeSystem, + ThemeLight, + ThemeDark, + Format, + Codec, + Level, + Subfolder, + /// Which code page an unflagged zip has its names written in. Only the + /// person looking at the archive can know, so it is a choice, and a choice + /// that is remembered is a setting. + NamePage, + Close, +} + +impl SettingsControl { + const ALL: [SettingsControl; 12] = [ + SettingsControl::LangSystem, + SettingsControl::LangEn, + SettingsControl::LangEs, + SettingsControl::ThemeSystem, + SettingsControl::ThemeLight, + SettingsControl::ThemeDark, + SettingsControl::Format, + SettingsControl::Codec, + SettingsControl::Level, + SettingsControl::Subfolder, + SettingsControl::NamePage, + SettingsControl::Close, + ]; +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ModalKind { + Password, + Conflict, + Delete, + Drop, + Add, + Viewer, + NewFolder, + Rename, + Mask, + DefaultPassword, + Settings, + Shortcuts, +} + +impl GpuiShell { + fn new(window: &mut Window, cx: &mut Context, startup: Startup) -> Self { + let owner = cx.weak_entity(); + let strings = super::strings(Settings::load().effective_lang()); + let filter = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(strings.find_word) + .context_menu(false) + }); + cx.subscribe_in(&filter, window, |shell, state, event, _, cx| { + if matches!(event, InputEvent::Change) { + shell + .controller + .dispatch(AppAction::SetFilter(state.read(cx).value().to_string())); + cx.notify(); + } + }) + .detach(); + let password = cx.new(|cx| FilterInput { + owner: owner.clone(), + strings, + label: strings.password_word, + focus_handle: cx.focus_handle(), + enabled: false, + kind: TextFieldKind::Password, + masked: true, + content: String::new(), + selected_range: 0..0, + marked_range: None, + last_layout: None, + last_bounds: None, + }); + let output_name = cx.new(|cx| FilterInput { + owner: owner.clone(), + strings, + label: strings.output_name, + focus_handle: cx.focus_handle(), + enabled: false, + kind: TextFieldKind::OutputName, + masked: false, + content: String::new(), + selected_range: 0..0, + marked_range: None, + last_layout: None, + last_bounds: None, + }); + let add_password = cx.new(|cx| FilterInput { + owner: owner.clone(), + strings, + label: strings.password_optional, + focus_handle: cx.focus_handle(), + enabled: false, + kind: TextFieldKind::AddPassword, + masked: true, + content: String::new(), + selected_range: 0..0, + marked_range: None, + last_layout: None, + last_bounds: None, + }); + let name_input = cx.new(|cx| FilterInput { + owner, + strings, + label: strings.folder_name, + focus_handle: cx.focus_handle(), + enabled: false, + kind: TextFieldKind::Name, + masked: false, + content: String::new(), + selected_range: 0..0, + marked_range: None, + last_layout: None, + last_bounds: None, + }); + let mut controller = AppController::new(Settings::load()); + apply_startup(&mut controller, startup); + // The stored preference decides light, dark or whatever the desktop is + // set to, and it has to land before the first frame or the window opens + // in one theme and repaints into the other. + gpui_theme::apply(controller.state.settings.theme, Some(window), cx); + window.set_window_title(&controller.state.window_title); + let view = cx.weak_entity(); + window + .spawn(cx, async move |async_cx| loop { + async_cx + .background_executor() + .timer(Duration::from_millis(100)) + .await; + if view + .update_in(async_cx, |shell, window, cx| { + shell.poll_dialog(window, cx); + // The list keeps running while the hand is still, which + // is the whole point of the gesture: without a step + // here it would only move on a stray mouse event. + let pointer = shell.pointer; + shell.tick_wheel(pointer); + shell.controller.ask_about_updates(); + let conflict_was_open = shell.controller.state.conflict.is_some(); + let close_window = shell.controller.receive(); + if close_window { + window.remove_window(); + } else if !conflict_was_open && shell.controller.state.conflict.is_some() { + shell.remember_conflict_focus(); + } + if shell.controller.state.cut_pending.is_some() { + shell.controller.cut_landed(); + } + cx.notify(); + }) + .is_err() + { + break; + } + }) + .detach(); + Self { + controller, + filter, + password, + output_name, + add_password, + name_input, + focus_handle: cx.focus_handle(), + list_focus: cx.focus_handle(), + list_scroll: UniformListScrollHandle::new(), + dialog: None, + overflow_open: false, + breadcrumbs_open: false, + overflow_trigger_focus: cx.focus_handle(), + breadcrumbs_trigger_focus: cx.focus_handle(), + open_trigger_focus: cx.focus_handle().tab_stop(true), + compress_trigger_focus: cx.focus_handle().tab_stop(true), + extract_all_trigger_focus: cx.focus_handle().tab_stop(true), + extract_selected_trigger_focus: cx.focus_handle().tab_stop(true), + password_trigger_focus: cx.focus_handle().tab_stop(true), + delete_trigger_focus: cx.focus_handle(), + drop_trigger_focus: cx.focus_handle(), + conflict_trigger_focus: cx.focus_handle(), + overflow_menu_focus: cx.focus_handle(), + breadcrumbs_menu_focus: cx.focus_handle(), + // Test/select/invert/clear, copy/cut/paste, the five that change + // the archive, the recent list and its broom, flat view, + // shortcuts, settings, then the columns. + overflow_item_focus: (0..(RELEASE_SLOT + 1)) + .map(|_| cx.focus_handle().tab_stop(true)) + .collect(), + breadcrumbs_item_focus: Vec::new(), + dialog_return_focus: cx.focus_handle(), + modal_seen: None, + password_toggle_focus: cx.focus_handle().tab_stop(true), + dialog_primary_focus: cx.focus_handle().tab_stop(true), + dialog_secondary_focus: cx.focus_handle().tab_stop(true), + dialog_tertiary_focus: cx.focus_handle().tab_stop(true), + dialog_quaternary_focus: cx.focus_handle().tab_stop(true), + dialog_rename_focus: cx.focus_handle().tab_stop(true), + dialog_rename_all_focus: cx.focus_handle().tab_stop(true), + dialog_cancel_focus: cx.focus_handle().tab_stop(true), + add_format_focus: cx.focus_handle().tab_stop(true), + add_codec_focus: cx.focus_handle().tab_stop(true), + add_level_focus: cx.focus_handle().tab_stop(true), + add_password_toggle_focus: cx.focus_handle().tab_stop(true), + add_start_focus: cx.focus_handle().tab_stop(true), + add_cancel_focus: cx.focus_handle().tab_stop(true), + settings_focus: SettingsControl::ALL + .iter() + .map(|_| cx.focus_handle().tab_stop(true)) + .collect(), + band: None, + wheel: None, + pointer: point(px(0.), px(0.)), + carrying: false, + row_count: 0, + resizing: None, + row_menu: None, + row_menu_focus: cx.focus_handle(), + row_menu_item_focus: RowAction::ALL + .iter() + .map(|_| cx.focus_handle().tab_stop(true)) + .collect(), + name_value: String::new(), + viewer_focus: (0..3).map(|_| cx.focus_handle().tab_stop(true)).collect(), + viewer_scroll: UniformListScrollHandle::new(), + viewer_image: None, + drop_paths: Vec::new(), + } + } + + /// One folder and everything under it, as a nestable sidebar item. + /// + /// The branch leading to the folder you are in opens itself, so opening an + /// archive three levels down does not present a closed tree you have to + /// re-walk by hand. Everything else stays shut, because an archive of a + /// source tree fully expanded is not a sidebar, it is a second file list. + fn folder_item( + folder: &Folder, + label: &str, + path: String, + current: &str, + cx: &mut Context, + ) -> SidebarMenuItem { + let on_path = current.starts_with(path.as_str()); + SidebarMenuItem::new(label.to_string()) + .icon(if on_path { + IconName::FolderOpen + } else { + IconName::Folder + }) + .active(current == path) + .default_open(on_path) + // Clicking the label navigates; the disclosure chevron is what + // opens a branch. Merging the two would make it impossible to look + // inside a folder without leaving the one you are in. + .click_to_open(false) + .children( + folder + .kids + .iter() + .map(|(child_label, child)| { + Self::folder_item( + child, + child_label, + format!("{path}{child_label}/"), + current, + cx, + ) + }) + .collect::>(), + ) + .on_click(cx.listener(move |this, _, _, cx| { + if this.background_idle() { + this.controller.dispatch(AppAction::Navigate(path.clone())); + this.route_changed(cx); + } + })) + } + + /// The archive's folders, down the left edge. + /// + /// Breadcrumbs say where you are; this says what else there is. A deep + /// archive was previously only navigable by descending one double-click at + /// a time and reversing back out. + fn sidebar(&mut self, window: &mut Window, cx: &mut Context) -> Stateful { + let current = self.controller.state.current_dir.clone(); + let at_root = current.is_empty(); + let root_item = SidebarMenuItem::new(self.controller.s().archive_root.to_string()) + .icon(IconName::Inbox) + .active(at_root) + .on_click(cx.listener(|this, _, _, cx| { + if this.background_idle() { + this.controller.dispatch(AppAction::Navigate(String::new())); + this.route_changed(cx); + } + })); + let mut items = vec![root_item]; + let tree = &self.controller.state.folders; + items.extend( + tree.kids + .iter() + .map(|(label, folder)| { + Self::folder_item(folder, label, format!("{label}/"), ¤t, cx) + }) + .collect::>(), + ); + let menu = SidebarMenu::new().children(items); + div() + .id("archive-folders") + .w(px(224.)) + .flex_none() + .flex() + .flex_col() + .overflow_hidden() + .bg(cx.theme().sidebar) + .border_r_1() + .border_color(cx.theme().border) + .p_2() + .child(menu.render("archive-folder-menu", window, cx)) + } + + fn begin_dialog(&mut self, kind: DialogKind, cx: &mut Context) { + if self.dialog.is_some() || self.controller.state.busy || self.modal_kind().is_some() { + return; + } + if matches!(kind, DialogKind::Extract { .. }) + && (self.controller.state.archive.is_none() + || (matches!(kind, DialogKind::Extract { only_checked: true }) + && !self.controller.state.checked.iter().any(|checked| *checked))) + { + return; + } + let (tx, rx) = channel(); + self.dialog = Some(rx); + std::thread::spawn(move || { + let result = match kind { + DialogKind::Open => DialogResult::Open( + rfd::FileDialog::new() + .add_filter("Archives", &["zip", "tar", "gz", "tgz"]) + .pick_file(), + ), + DialogKind::Compress => DialogResult::Compress(rfd::FileDialog::new().pick_files()), + DialogKind::Extract { only_checked } => DialogResult::Extract { + only_checked, + destination: rfd::FileDialog::new().pick_folder(), + }, + DialogKind::AddFiles => DialogResult::AddFiles(rfd::FileDialog::new().pick_files()), + DialogKind::SaveCopy { name, directory } => DialogResult::SaveCopy( + rfd::FileDialog::new() + .set_file_name(&name) + .set_directory(&directory) + .save_file(), + ), + }; + let _ = tx.send(result); + }); + cx.notify(); + } + + fn poll_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let Some(dialog) = &self.dialog else { + return; + }; + let Ok(result) = dialog.try_recv() else { + return; + }; + self.dialog = None; + match result { + DialogResult::Open(Some(path)) => self.controller.dispatch(AppAction::Open(path)), + DialogResult::Compress(Some(paths)) if !paths.is_empty() => { + self.controller.dispatch(AppAction::PrepareCompress(paths)) + } + DialogResult::Extract { + only_checked, + destination: Some(dest), + } => self + .controller + .dispatch(AppAction::ExtractTo { only_checked, dest }), + DialogResult::AddFiles(Some(paths)) if !paths.is_empty() => { + self.controller.dispatch(AppAction::Add(paths)) + } + DialogResult::SaveCopy(Some(dest)) => { + if let Some(archive) = self.controller.state.archive.clone() { + // Copying an archive over itself is not a backup, it is a + // truncation. + if dest != archive { + self.controller + .dispatch(AppAction::Run(Job::CopyTo { archive, dest })); + } + } + } + _ => {} + } + let return_focus = self.dialog_return_focus.clone(); + window.on_next_frame(move |window, cx| window.focus(&return_focus, cx)); + cx.notify(); + } + + fn background_blocked(&self) -> bool { + !background_event_allowed(self.modal_kind().is_some(), self.dialog.is_some()) + } + + fn focus_filter(&mut self, _: &FocusFilter, window: &mut Window, cx: &mut Context) { + if self.modal_kind().is_some() || self.dialog.is_some() { + cx.stop_propagation(); + return; + } + let handle = self.filter.read(cx).focus_handle(cx).clone(); + window.focus(&handle, cx); + } + + fn menu_key_down( + event: &KeyDownEvent, + items: &[FocusHandle], + window: &mut Window, + cx: &mut Context, + ) { + let key = event.keystroke.key.as_str(); + if key == "tab" { + Self::trap_focus(items, event.keystroke.modifiers.shift, window, cx); + return; + } + if !matches!(key, "down" | "up") { + return; + } + let Some(current) = items.iter().position(|item| item.is_focused(window)) else { + cx.stop_propagation(); + return; + }; + let Some(next) = menu_target(current, key, items.len()) else { + cx.stop_propagation(); + return; + }; + items[next].focus(window, cx); + cx.stop_propagation(); + } + + fn overflow_key_down( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + if event.keystroke.key == "escape" { + self.overflow_open = false; + self.overflow_trigger_focus.focus(window, cx); + cx.stop_propagation(); + cx.notify(); + } else { + let mut items = Vec::new(); + if self.controller.state.archive.is_some() && self.menu_enabled() { + items.extend(self.overflow_item_focus[..4].iter().cloned()); + if self.can_copy_files() { + items.push(self.overflow_item_focus[4].clone()); + items.push(self.overflow_item_focus[5].clone()); + } + } + if self.can_paste_files() { + items.push(self.overflow_item_focus[6].clone()); + } + if self.menu_enabled() { + items.extend(self.overflow_item_focus[7..RECENT_SLOT].iter().cloned()); + // Only the recent slots that have an archive behind them: an + // empty slot is a stop on the way down that lands on nothing. + let recent = self.recent_shown(); + items.extend( + self.overflow_item_focus[RECENT_SLOT..RECENT_SLOT + recent] + .iter() + .cloned(), + ); + items.extend( + self.overflow_item_focus[RECENT_SLOT + RECENT_MAX..RELEASE_SLOT] + .iter() + .cloned(), + ); + if self.controller.state.update.is_some() { + items.push(self.overflow_item_focus[RELEASE_SLOT].clone()); + } + } + Self::menu_key_down(event, &items, window, cx); + } + } + + fn breadcrumbs_key_down( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + if event.keystroke.key == "escape" { + self.breadcrumbs_open = false; + self.breadcrumbs_trigger_focus.focus(window, cx); + cx.stop_propagation(); + cx.notify(); + } else { + let hidden_len = Self::visible_crumb_indices(self.crumbs().len()).1.len(); + let visible_items = visible_menu_items(&self.breadcrumbs_item_focus, hidden_len); + Self::menu_key_down(event, visible_items, window, cx); + } + } + + fn sync_breadcrumb_item_focus(&mut self, cx: &mut Context) { + let hidden_len = Self::visible_crumb_indices(self.crumbs().len()).1.len(); + self.breadcrumbs_item_focus.truncate(hidden_len); + while self.breadcrumbs_item_focus.len() < hidden_len { + self.breadcrumbs_item_focus + .push(cx.focus_handle().tab_stop(true)); + } + } + + fn route_changed(&mut self, cx: &mut Context) { + self.sync_breadcrumb_item_focus(cx); + cx.notify(); + } + + fn button( + id: impl Into, + label: impl Into, + accessible_name: String, + enabled: bool, + cx: &App, + ) -> Stateful { + // Borderless, with the ground only appearing under the pointer. A row + // of outlined boxes reads as seven competing things; the same row + // without outlines reads as one toolbar, and the hover tint says which + // one you are about to press. Disabled loses the ink, not the space, + // so nothing shifts when an action becomes available. + let mut button = div() + .id(id) + .aria_label(accessible_name) + .h(px(26.)) + .px_2() + .flex() + .items_center() + .flex_none() + .rounded(cx.theme().radius) + .focus_visible(focus_ring(cx)) + .text_xs() + .child(label.into()); + if enabled { + button = button + .role(Role::Button) + .focusable() + .tab_stop(true) + .cursor_pointer() + .text_color(cx.theme().foreground) + .hover(|style| style.bg(cx.theme().accent)) + .active(|style| style.bg(cx.theme().secondary_active)); + } else { + button = button + .tab_stop(false) + .text_color(cx.theme().muted_foreground); + } + button + } + + /// A square button carrying one of the kit's icons instead of a word. + /// + /// Only for the controls whose meaning is a direction — back, forward, up. + /// A named action stays a word, because an icon that needs a tooltip to be + /// understood has cost a word and bought nothing. The accessible name is + /// still the word, so nothing changes for a screen reader. + fn icon_button( + id: impl Into, + icon: IconName, + accessible_name: String, + enabled: bool, + cx: &App, + ) -> Stateful { + let tooltip = accessible_name.clone(); + let mut button = div() + .id(id) + .aria_label(accessible_name) + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) + .size(px(26.)) + .flex_none() + .flex() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .focus_visible(focus_ring(cx)) + .child(Icon::new(icon).size_4().text_color(if enabled { + cx.theme().foreground + } else { + cx.theme().muted_foreground + })); + if enabled { + button = button + .role(Role::Button) + .focusable() + .tab_stop(true) + .cursor_pointer() + .hover(|style| style.bg(cx.theme().accent)); + } else { + button = button.tab_stop(false); + } + button + } + + fn popup_action( + owner: WeakEntity, + label: impl Into, + action: OverflowAction, + ) -> PopupMenuItem { + PopupMenuItem::new(label).on_click(move |_, _, cx| { + let _ = owner.update(cx, |this, cx| { + this.overflow_action(action, cx); + cx.notify(); + }); + }) + } + + /// A row of the menu: what it does on the left, the keys that do the same + /// on the right. + /// + /// Two children rather than one string with a tab in it. GPUI lays out text + /// and a tab is nothing at all there, so the keys came out stuck to the word + /// -- "OpenEnter", "ViewF3". The keys are quieter than the name, because they are a way in + /// and not a second thing to read. + fn menu_item( + id: impl Into, + label: impl Into, + keys: &str, + accessible_name: String, + enabled: bool, + cx: &App, + ) -> Stateful { + // A menu item is a full-width row, not a button that happens to be in a + // menu: it takes the whole popover so the hover tint reaches both edges. + // The label goes in as a child of its own so it can take the room the + // keys do not. + let mut item = Self::button(id, "", accessible_name, enabled, cx) + .w_full() + .gap_4() + .justify_start() + .child(div().flex_1().truncate().child(label.into())); + if !keys.is_empty() { + item = item.child( + div() + .flex_none() + .text_color(cx.theme().muted_foreground) + .child(keys.to_string()), + ); + } + if enabled { + item = item.role(Role::MenuItem); + } + item + } + + fn modal_kind(&self) -> Option { + if self.controller.state.waiting_on_password.is_some() { + Some(ModalKind::Password) + } else if self.controller.state.conflict.is_some() { + Some(ModalKind::Conflict) + } else if self.controller.state.confirm_delete.is_some() { + Some(ModalKind::Delete) + } else if self.controller.state.confirm_drop.is_some() { + Some(ModalKind::Drop) + } else if matches!(self.controller.state.view, View::Add) { + Some(ModalKind::Add) + } else if self.controller.state.viewing.is_some() { + Some(ModalKind::Viewer) + } else if self.controller.state.asking_folder { + Some(ModalKind::NewFolder) + } else if self.controller.state.renaming.is_some() { + Some(ModalKind::Rename) + } else if self.controller.state.picking_group.is_some() { + Some(ModalKind::Mask) + } else if self.controller.state.asking_default_password { + Some(ModalKind::DefaultPassword) + } else if self.controller.state.show_settings { + Some(ModalKind::Settings) + } else if self.controller.state.show_shortcuts { + Some(ModalKind::Shortcuts) + } else { + None + } + } + + fn modal_focus_targets(&self, kind: ModalKind, cx: &mut Context) -> Vec { + match kind { + ModalKind::Password => vec![ + self.password.read(cx).focus_handle.clone(), + self.password_toggle_focus.clone(), + self.dialog_primary_focus.clone(), + self.dialog_cancel_focus.clone(), + ], + ModalKind::Conflict => vec![ + self.dialog_primary_focus.clone(), + self.dialog_secondary_focus.clone(), + self.dialog_tertiary_focus.clone(), + self.dialog_quaternary_focus.clone(), + self.dialog_rename_focus.clone(), + self.dialog_rename_all_focus.clone(), + self.dialog_cancel_focus.clone(), + ], + ModalKind::Delete => vec![ + self.dialog_primary_focus.clone(), + self.dialog_cancel_focus.clone(), + ], + ModalKind::Drop => vec![ + self.dialog_primary_focus.clone(), + self.dialog_secondary_focus.clone(), + self.dialog_cancel_focus.clone(), + ], + ModalKind::Add => self.add_focus_targets(cx), + ModalKind::Viewer => self.viewer_focus.clone(), + ModalKind::NewFolder | ModalKind::Rename | ModalKind::Mask => vec![ + self.name_input.read(cx).focus_handle.clone(), + self.dialog_primary_focus.clone(), + self.dialog_cancel_focus.clone(), + ], + ModalKind::DefaultPassword => vec![ + self.password.read(cx).focus_handle.clone(), + self.password_toggle_focus.clone(), + self.dialog_primary_focus.clone(), + self.dialog_secondary_focus.clone(), + self.dialog_cancel_focus.clone(), + ], + ModalKind::Settings => self.settings_focus.clone(), + ModalKind::Shortcuts => vec![self.dialog_cancel_focus.clone()], + } + } + + fn add_focus_targets(&self, cx: &mut Context) -> Vec { + let mut targets = vec![ + self.output_name.read(cx).focus_handle.clone(), + self.add_format_focus.clone(), + ]; + if self.controller.state.format == super::Format::Zip { + targets.push(self.add_codec_focus.clone()); + } + targets.push(self.add_level_focus.clone()); + if self.controller.state.format == super::Format::Zip { + targets.push(self.add_password.read(cx).focus_handle.clone()); + targets.push(self.add_password_toggle_focus.clone()); + } + targets.extend([self.add_start_focus.clone(), self.add_cancel_focus.clone()]); + targets + } + + fn trap_focus( + targets: &[FocusHandle], + reverse: bool, + window: &mut Window, + cx: &mut Context, + ) { + if targets.is_empty() { + cx.stop_propagation(); + return; + } + let current = targets.iter().position(|target| target.is_focused(window)); + if let Some(index) = focus_cycle_index(current, reverse, targets.len()) { + window.focus(&targets[index], cx); + } + cx.stop_propagation(); + } + + fn remember_background_focus(&mut self, window: &Window, cx: &mut Context) { + if self.modal_seen.is_none() + && self.modal_kind().is_none() + && self.dialog.is_none() + && !self.overflow_open + && !self.breadcrumbs_open + { + if let Some(focus) = window.focused(cx) { + self.dialog_return_focus = focus; + } + } + } + + fn remember_conflict_focus(&mut self) { + self.dialog_return_focus = self.conflict_trigger_focus.clone(); + } + + fn sync_modal_focus(&mut self, window: &mut Window, cx: &mut Context) { + let current = self.modal_kind(); + if current == self.modal_seen { + return; + } + if current == Some(ModalKind::Conflict) { + self.remember_conflict_focus(); + } + self.modal_seen = current; + let target = match current { + Some(ModalKind::Password) => self.password.read(cx).focus_handle.clone(), + Some(ModalKind::Conflict | ModalKind::Delete | ModalKind::Drop) => { + self.dialog_primary_focus.clone() + } + Some(ModalKind::Add) => self.output_name.read(cx).focus_handle.clone(), + Some(ModalKind::Viewer) => self.viewer_focus[0].clone(), + Some(ModalKind::NewFolder | ModalKind::Rename | ModalKind::Mask) => { + self.name_input.read(cx).focus_handle.clone() + } + Some(ModalKind::DefaultPassword) => self.password.read(cx).focus_handle.clone(), + Some(ModalKind::Settings) => self.settings_focus[0].clone(), + Some(ModalKind::Shortcuts) => self.dialog_cancel_focus.clone(), + None => self.dialog_return_focus.clone(), + }; + window.on_next_frame(move |window, cx| window.focus(&target, cx)); + } + + fn answer_conflict(&mut self, answer: Answer) { + self.remember_conflict_focus(); + self.controller.dispatch(AppAction::AnswerConflict(answer)); + } + + fn modal_key_down( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + let key = event.keystroke.key.as_str(); + let Some(kind) = self.modal_kind() else { + return; + }; + if key == "tab" { + let targets = self.modal_focus_targets(kind, cx); + Self::trap_focus(&targets, event.keystroke.modifiers.shift, window, cx); + return; + } + if key == "escape" { + match kind { + ModalKind::Password => self.controller.dispatch(AppAction::CancelPassword), + ModalKind::Conflict => self.answer_conflict(Answer::Cancel), + ModalKind::Delete => self.controller.dispatch(AppAction::ConfirmDelete(false)), + ModalKind::Drop => self + .controller + .dispatch(AppAction::AnswerDrop(DropChoice::Cancel)), + ModalKind::Add => self.controller.state.view = View::Browse, + ModalKind::Viewer => self.controller.state.viewing = None, + ModalKind::NewFolder | ModalKind::Rename | ModalKind::Mask => self.close_name(), + ModalKind::DefaultPassword => { + self.controller.state.asking_default_password = false; + self.controller.state.password_input.clear(); + } + ModalKind::Settings => self.controller.state.show_settings = false, + ModalKind::Shortcuts => self.controller.state.show_shortcuts = false, + } + cx.stop_propagation(); + cx.notify(); + return; + } + if key != "enter" { + return; + } + self.modal_enter(kind, window, cx); + cx.stop_propagation(); + cx.notify(); + } + + fn modal_enter(&mut self, kind: ModalKind, window: &mut Window, cx: &mut Context) { + let focused = |handle: &FocusHandle| handle.is_focused(window); + match kind { + ModalKind::Password => { + if focused(&self.password_toggle_focus) { + self.controller + .dispatch(AppAction::TogglePasswordVisibility); + } else if focused(&self.dialog_cancel_focus) { + self.controller.dispatch(AppAction::CancelPassword); + } else { + let password = self.password.read(cx).content.clone(); + self.controller + .dispatch(AppAction::SubmitPassword(password)); + } + } + ModalKind::Conflict => { + let answer = if focused(&self.dialog_secondary_focus) { + Answer::ReplaceAll + } else if focused(&self.dialog_tertiary_focus) { + Answer::Skip + } else if focused(&self.dialog_quaternary_focus) { + Answer::SkipAll + } else if focused(&self.dialog_rename_focus) { + Answer::Rename + } else if focused(&self.dialog_rename_all_focus) { + Answer::RenameAll + } else if focused(&self.dialog_cancel_focus) { + Answer::Cancel + } else { + Answer::Replace + }; + self.answer_conflict(answer); + } + ModalKind::Delete => self.controller.dispatch(AppAction::ConfirmDelete(!focused( + &self.dialog_cancel_focus, + ))), + ModalKind::Drop => { + let choice = if focused(&self.dialog_secondary_focus) { + DropChoice::Add + } else if focused(&self.dialog_cancel_focus) { + DropChoice::Cancel + } else { + DropChoice::Open + }; + self.controller.dispatch(AppAction::AnswerDrop(choice)); + } + ModalKind::Add => { + if focused(&self.add_cancel_focus) { + self.controller.state.view = View::Browse; + } else if focused(&self.add_format_focus) { + self.cycle_format(); + } else if focused(&self.add_codec_focus) { + self.cycle_codec(); + } else if focused(&self.add_level_focus) { + self.cycle_level(); + } else if focused(&self.add_password_toggle_focus) { + self.controller.state.show_password = !self.controller.state.show_password; + } else { + self.start_add(cx); + } + } + ModalKind::NewFolder | ModalKind::Rename | ModalKind::Mask => { + if focused(&self.dialog_cancel_focus) { + self.close_name(); + } else { + self.confirm_name(kind, cx); + } + } + ModalKind::DefaultPassword => { + if focused(&self.password_toggle_focus) { + self.controller.state.show_password = !self.controller.state.show_password; + } else if focused(&self.dialog_secondary_focus) { + self.forget_default_password(); + } else if focused(&self.dialog_cancel_focus) { + self.controller.state.asking_default_password = false; + self.controller.state.password_input.clear(); + } else { + self.keep_default_password(); + } + } + ModalKind::Viewer => { + if let Some(look) = [super::Look::Text, super::Look::Hex, super::Look::Picture] + .into_iter() + .enumerate() + .find(|(index, _)| focused(&self.viewer_focus[*index])) + .map(|(_, look)| look) + { + if let Some(view) = &mut self.controller.state.viewing { + view.look = look; + } + } else { + self.controller.state.viewing = None; + } + } + ModalKind::Shortcuts => self.controller.state.show_shortcuts = false, + ModalKind::Settings => { + if let Some(control) = SettingsControl::ALL + .iter() + .copied() + .find(|control| focused(&self.settings_focus[*control as usize])) + { + self.settings_activate(control, window, cx); + } + } + } + } + + /// One place where a settings control does its work, so the click handler + /// and Enter cannot drift apart. + fn settings_activate( + &mut self, + control: SettingsControl, + window: &mut Window, + cx: &mut Context, + ) { + match control { + SettingsControl::LangSystem => { + self.controller.dispatch(AppAction::SetLanguage(None)); + } + SettingsControl::LangEn => { + self.controller + .dispatch(AppAction::SetLanguage(Some(super::Lang::En))); + } + SettingsControl::LangEs => { + self.controller + .dispatch(AppAction::SetLanguage(Some(super::Lang::Es))); + } + SettingsControl::ThemeSystem => { + self.set_theme(super::ThemePreference::System, window, cx) + } + SettingsControl::ThemeLight => { + self.set_theme(super::ThemePreference::Light, window, cx) + } + SettingsControl::ThemeDark => self.set_theme(super::ThemePreference::Dark, window, cx), + SettingsControl::Format => self.cycle_format(), + SettingsControl::Codec => self.cycle_codec(), + SettingsControl::Level => self.cycle_level(), + SettingsControl::Subfolder => { + self.controller.state.into_subfolder = !self.controller.state.into_subfolder; + } + SettingsControl::NamePage => { + let pages = arca_zip::pages::Page::ALL; + let at = pages + .iter() + .position(|(page, _, _)| *page == self.controller.state.settings.page) + .unwrap_or(0); + self.controller + .reread_names(pages[(at + 1) % pages.len()].0); + } + SettingsControl::Close => self.controller.state.show_settings = false, + } + } + + /// The preference is stored by the controller and painted by GPUI, and both + /// have to happen: saving without repainting leaves the window in the old + /// theme until it is restarted. + fn set_theme( + &mut self, + theme: super::ThemePreference, + window: &mut Window, + cx: &mut Context, + ) { + self.controller.dispatch(AppAction::SetTheme(theme)); + gpui_theme::apply(theme, Some(window), cx); + } + + /// The entries of the overflow menu that change the archive itself. + fn overflow_action(&mut self, action: OverflowAction, cx: &mut Context) { + match action { + OverflowAction::Release => self.controller.start_update(), + OverflowAction::AddFiles => self.begin_dialog(DialogKind::AddFiles, cx), + // Asked for in a box rather than made as "New folder" and renamed + // after: making it rewrites the whole archive, and doing that twice + // for one folder would be silly. + OverflowAction::NewFolder => { + self.name_value.clear(); + self.controller.state.asking_folder = true; + } + OverflowAction::Undo => self.controller.undo_last(), + OverflowAction::SaveCopy => { + let Some(archive) = self.controller.state.archive.clone() else { + return; + }; + let name = archive + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + let directory = archive + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + self.begin_dialog(DialogKind::SaveCopy { name, directory }, cx); + } + OverflowAction::DefaultPassword => { + self.controller.state.password_input.clear(); + self.controller.state.asking_default_password = true; + } + } + } + + /// An entry out of the archive, looked at without taking it out: as text, + /// as hex, or as the picture it is. + /// + /// The text and the hex go through `uniform_list`, so only the lines on + /// screen are laid out and a log of a million lines opens as fast as a + /// note of three. + fn viewer_dialog(&mut self, cx: &mut Context) -> Stateful { + let s = self.controller.s(); + let Some(view) = &self.controller.state.viewing else { + return div().id("viewer-missing"); + }; + let name = view.name.clone(); + let size = human(view.bytes.len() as u64); + let look = view.look; + let picture = view.picture; + let bytes = view.bytes.clone(); + let lines = view.lines.clone(); + + let mut tabs = div().flex().items_center().gap_2(); + for (index, (candidate, label)) in [ + (super::Look::Text, s.as_text), + (super::Look::Hex, s.as_hex), + (super::Look::Picture, s.as_picture), + ] + .into_iter() + .enumerate() + { + // Only where there is a picture to show. A tab that says "picture" + // over a text file is a tab that lies. + if candidate == super::Look::Picture && !picture { + continue; + } + tabs = tabs.child( + Self::dialog_button( + ("viewer-tab", index), + label, + &self.viewer_focus[index], + look == candidate, + cx, + ) + .aria_selected(look == candidate) + .on_click(cx.listener(move |this, _, _, cx| { + if let Some(view) = &mut this.controller.state.viewing { + view.look = candidate; + } + cx.notify(); + })), + ); + } + tabs = tabs.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(size), + ); + + let content = match look { + super::Look::Picture => { + let image = self.viewer_picture(&name, &bytes); + div() + .id("viewer-picture") + .flex_1() + .min_h(px(1.)) + .overflow_scroll() + .children(image.map(gpui::img)) + .into_any_element() + } + super::Look::Text => { + let total = lines.len(); + uniform_list( + "viewer-text", + total, + move |range: Range, _window, _cx| { + range + .map(|index| { + div() + .font_family("monospace") + .text_xs() + .child(lines[index].clone()) + }) + .collect::>() + }, + ) + .track_scroll(&self.viewer_scroll) + .size_full() + .into_any_element() + } + super::Look::Hex => { + let total = bytes.len().div_ceil(16); + uniform_list( + "viewer-hex", + total, + move |range: Range, _window, _cx| { + range + .map(|row| { + let at = row * 16; + let end = (at + 16).min(bytes.len()); + div() + .font_family("monospace") + .text_xs() + .child(super::hex_line(at, &bytes[at..end])) + }) + .collect::>() + }, + ) + .track_scroll(&self.viewer_scroll) + .size_full() + .into_any_element() + } + }; + + let body = div() + .id("viewer-dialog-body") + .flex() + .flex_col() + .gap_3() + .w(px(760.)) + .h(px(460.)) + .child(tabs) + .child(Separator::horizontal()) + .child( + div() + .id("viewer-content") + .flex_1() + .min_h(px(1.)) + .overflow_hidden() + .child(content), + ); + self.dialog_overlay(ModalKind::Viewer, name, s.view_word, body, cx) + } + + /// The decoded picture for the entry being looked at, decoded once. + fn viewer_picture( + &mut self, + name: &str, + bytes: &std::sync::Arc<[u8]>, + ) -> Option> { + if let Some((cached, image)) = &self.viewer_image { + if cached == name { + return Some(image.clone()); + } + } + let format = match image::guess_format(bytes).ok()? { + image::ImageFormat::Png => gpui::ImageFormat::Png, + image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, + image::ImageFormat::Gif => gpui::ImageFormat::Gif, + image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, + image::ImageFormat::WebP => gpui::ImageFormat::Webp, + // The `image` crate is built with five decoders on purpose; any + // other tag here is a format this build cannot read anyway. + _ => return None, + }; + let image = std::sync::Arc::new(gpui::Image::from_bytes(format, bytes.to_vec())); + self.viewer_image = Some((name.to_string(), image.clone())); + Some(image) + } + + /// Shuts whichever text dialog is open and forgets what was typed in it. + fn close_name(&mut self) { + self.controller.state.asking_folder = false; + self.controller.state.renaming = None; + self.controller.state.picking_group = None; + self.name_value.clear(); + } + + /// Acts on what the shared text field holds, according to which dialog + /// asked for it. + fn confirm_name(&mut self, kind: ModalKind, cx: &mut Context) { + let s = self.controller.s(); + let name = self.name_value.trim().to_string(); + match kind { + ModalKind::NewFolder => { + let Some(archive) = self.controller.state.archive.clone() else { + self.close_name(); + return; + }; + // The same rules a rename lives by: a name is a name and not a + // path, and nothing here is called that already. + if name.is_empty() || name.contains('/') || name.contains('\\') { + self.controller.state.notice = s.bad_name.to_string(); + self.controller.state.error = true; + self.close_name(); + return; + } + if self + .controller + .visible_rows() + .iter() + .any(|row| row.label.eq_ignore_ascii_case(&name)) + { + self.controller.state.notice = fill(s.name_taken, &[("name", &name)]); + self.controller.state.error = true; + self.close_name(); + return; + } + let full = format!("{}{name}/", self.controller.state.current_dir); + self.close_name(); + self.controller.dispatch(AppAction::Run(Job::NewFolder { + archive, + name: full, + password: self.controller.state.archive_password.clone(), + })); + } + ModalKind::Rename => { + let Some((path, _)) = self.controller.state.renaming.clone() else { + self.close_name(); + return; + }; + let rows = self.controller.visible_rows(); + self.close_name(); + self.controller.rename_to(&rows, &path, name.trim()); + } + ModalKind::Mask => { + // WinRAR's keypad plus and minus: a mask picks or drops every + // name in this folder that matches it, in one go. + let adding = self.controller.state.picking_group.unwrap_or(true); + let rows = self.controller.visible_rows(); + self.close_name(); + if name.is_empty() { + return; + } + for row in &rows { + if super::matches_mask(&name, &row.label) { + self.controller.dispatch(AppAction::SetChecked { + row: row.clone(), + value: adding, + }); + } + } + } + _ => self.close_name(), + } + cx.notify(); + } + + /// The password to try on anything that asks for one, so a folder full of + /// archives locked with the same word is opened once and not fifteen + /// times. In memory and nowhere else: a password in plain text beside the + /// theme and the column widths is how an encrypted archive stops being + /// encrypted. + fn keep_default_password(&mut self) { + let given = std::mem::take(&mut self.controller.state.password_input); + self.controller.state.default_password = (!given.is_empty()).then_some(given); + self.controller.state.asking_default_password = false; + } + + fn forget_default_password(&mut self) { + let s = self.controller.s(); + self.controller.state.default_password = None; + self.controller.state.asking_default_password = false; + self.controller.state.password_input.clear(); + self.controller.state.notice = s.password_forgotten.to_string(); + self.controller.state.error = false; + } + + /// The dialogs that are one text field and two buttons: a new folder, a + /// new name, a mask. + fn name_dialog(&mut self, kind: ModalKind, cx: &mut Context) -> Stateful { + let s = self.controller.s(); + let (title, hint, confirm) = match kind { + ModalKind::NewFolder => (s.new_folder, s.folder_name, s.new_folder), + ModalKind::Rename => (s.rename_word, s.rename_word, s.rename_word), + _ => { + let adding = self.controller.state.picking_group.unwrap_or(true); + ( + if adding { + s.select_group + } else { + s.deselect_group + }, + s.mask_hint, + s.start, + ) + } + }; + let ok = Self::dialog_button("name-ok", confirm, &self.dialog_primary_focus, true, cx) + .on_click(cx.listener(move |this, _, _, cx| this.confirm_name(kind, cx))); + let cancel = Self::dialog_button( + "name-cancel", + s.cancel, + &self.dialog_cancel_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.close_name(); + cx.notify(); + })); + let body = div() + .id("name-dialog-body") + .flex() + .flex_col() + .gap_3() + .child(self.name_input.clone()) + .child(div().flex().gap_2().child(ok).child(cancel)); + self.dialog_overlay(kind, title, hint, body, cx) + } + + fn default_password_dialog(&mut self, cx: &mut Context) -> Stateful { + let s = self.controller.s(); + let show = !self.controller.state.show_password; + let toggle = Self::dialog_button( + "default-password-visibility", + if show { s.show_password } else { s.hide_word }, + &self.password_toggle_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.controller + .dispatch(AppAction::TogglePasswordVisibility); + cx.notify(); + })); + let keep = Self::dialog_button( + "default-password-keep", + s.start, + &self.dialog_primary_focus, + true, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.keep_default_password(); + cx.notify(); + })); + let forget = Self::dialog_button( + "default-password-forget", + s.remove_password, + &self.dialog_secondary_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.forget_default_password(); + cx.notify(); + })); + let cancel = Self::dialog_button( + "default-password-cancel", + s.cancel, + &self.dialog_cancel_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.controller.state.asking_default_password = false; + this.controller.state.password_input.clear(); + cx.notify(); + })); + let body = div() + .id("default-password-dialog-body") + .flex() + .flex_col() + .gap_3() + .child(self.password.clone()) + .child(toggle) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(s.password_kept), + ) + .child(div().flex().gap_2().child(keep).child(forget).child(cancel)); + self.dialog_overlay( + ModalKind::DefaultPassword, + s.default_password, + s.password_hint, + body, + cx, + ) + } + + /// Language, theme, and the defaults a new archive is made with. + /// + /// Every choice is a row of buttons with the current one filled in, not a + /// dropdown: there are three of each at most, and a list that short costs + /// more to open than to read. + fn settings_dialog(&mut self, cx: &mut Context) -> Stateful { + let s = self.controller.s(); + let lang = self.controller.state.settings.lang; + let theme = self.controller.state.settings.theme; + let is_zip = self.controller.state.format == super::Format::Zip; + let choice = |this: &Self, + control: SettingsControl, + label: &'static str, + active: bool, + enabled: bool, + cx: &mut Context| { + let mut button = Self::dialog_button( + ("settings", control as usize), + label, + &this.settings_focus[control as usize], + active, + cx, + ) + .aria_selected(active); + if enabled { + button = button.on_click(cx.listener(move |this, _, window, cx| { + this.settings_activate(control, window, cx); + cx.notify(); + })); + } + button + }; + let row = |label: &'static str, children: Vec>| { + div() + .flex() + .items_center() + .flex_wrap() + .gap_2() + .child(div().w(px(110.)).flex_none().child(label)) + .children(children) + }; + let languages = row( + s.language, + vec![ + choice( + self, + SettingsControl::LangSystem, + s.theme_system, + lang.is_none(), + true, + cx, + ), + choice( + self, + SettingsControl::LangEn, + super::Lang::En.label(), + lang == Some(super::Lang::En), + true, + cx, + ), + choice( + self, + SettingsControl::LangEs, + super::Lang::Es.label(), + lang == Some(super::Lang::Es), + true, + cx, + ), + ], + ); + let themes = row( + s.theme, + vec![ + choice( + self, + SettingsControl::ThemeSystem, + s.theme_system, + theme == super::ThemePreference::System, + true, + cx, + ), + choice( + self, + SettingsControl::ThemeLight, + s.theme_light, + theme == super::ThemePreference::Light, + true, + cx, + ), + choice( + self, + SettingsControl::ThemeDark, + s.theme_dark, + theme == super::ThemePreference::Dark, + true, + cx, + ), + ], + ); + let defaults = div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(s.defaults_title), + ) + .child(row( + s.format, + vec![choice( + self, + SettingsControl::Format, + self.controller.state.format.label(), + false, + true, + cx, + )], + )) + .child(row( + s.compressor, + vec![choice( + self, + SettingsControl::Codec, + self.controller.codec_name(self.controller.state.codec), + false, + is_zip, + cx, + )], + )) + .child(row( + s.level, + vec![choice( + self, + SettingsControl::Level, + self.controller.level_name(self.controller.state.level), + false, + true, + cx, + )], + )); + let subfolder = choice( + self, + SettingsControl::Subfolder, + s.into_subfolder, + self.controller.state.into_subfolder, + true, + cx, + ); + let page = arca_zip::pages::Page::ALL + .iter() + .find(|(page, _, _)| *page == self.controller.state.settings.page) + .map(|(_, _, label)| *label) + .unwrap_or(""); + let name_page = row( + s.name_encoding, + vec![choice( + self, + SettingsControl::NamePage, + page, + false, + true, + cx, + )], + ); + let close = choice(self, SettingsControl::Close, s.close, true, true, cx); + let body = div() + .id("settings-dialog-body") + .flex() + .flex_col() + .gap_3() + .child(languages) + .child(themes) + .child(name_page) + .child(Separator::horizontal()) + .child(defaults) + .child(subfolder) + .child(div().flex().gap_2().child(close)); + self.dialog_overlay(ModalKind::Settings, s.settings, s.defaults_title, body, cx) + } + + fn dialog_button( + id: impl Into, + label: impl Into, + focus: &FocusHandle, + primary: bool, + cx: &App, + ) -> Stateful { + let label: gpui::SharedString = label.into(); + let mut button = div() + .id(id) + .role(Role::Button) + .aria_label(label.clone()) + .focusable() + .tab_stop(true) + .track_focus(focus) + .cursor_pointer() + .px_3() + .py_2() + .rounded(cx.theme().radius) + .border_1() + .border_color(if primary { + cx.theme().primary + } else { + cx.theme().border + }) + .focus_visible(focus_ring(cx)); + if primary { + button = button + .bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + .hover(|style| style.bg(cx.theme().primary_hover)) + .active(|style| style.bg(cx.theme().primary_active)); + } else { + button = button + .bg(cx.theme().button) + .text_color(cx.theme().button_foreground) + .hover(|style| style.bg(cx.theme().button_hover)) + .active(|style| style.bg(cx.theme().button_active)); + } + button.child(label) + } + + fn dialog_overlay( + &mut self, + kind: ModalKind, + title: impl Into, + description: impl Into, + body: Stateful, + cx: &mut Context, + ) -> Stateful { + let title: gpui::SharedString = title.into(); + let description: gpui::SharedString = description.into(); + div() + .id(("gpui-dialog", kind as usize)) + .absolute() + // Everything below the title bar. The three window buttons are not + // the background: a dialog that covered them would be a dialog you + // could not close the window behind. + .top(TITLE_BAR_HEIGHT) + .left_0() + .right_0() + .bottom_0() + .bg(cx.theme().overlay) + .role(Role::Dialog) + .aria_label(title.clone()) + .aria_keyshortcuts("Escape Enter") + .occlude() + .on_mouse_down(gpui::MouseButton::Left, |_, _, _| {}) + .capture_key_down(cx.listener(Self::modal_key_down)) + .child( + div() + .id(("gpui-dialog-card", kind as usize)) + .m_8() + // Wide enough for the viewer, which is the only dialog that + // holds content rather than a question. The rest are sized + // by what is in them and never reach it. + .max_w(px(920.)) + .p_5() + .gap_3() + .flex() + .flex_col() + .tab_group() + .bg(cx.theme().popover) + .text_color(cx.theme().popover_foreground) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius_lg) + .shadow_lg() + .child( + div() + .id(("dialog-title", kind as usize)) + .role(Role::Heading) + .text_lg() + .child(title.clone()), + ) + .child( + div() + .id(("dialog-description", kind as usize)) + .role(Role::Note) + .aria_label("Dialog description") + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(description), + ) + .child(body), + ) + } + + fn cycle_format(&mut self) { + self.controller.state.format = match self.controller.state.format { + super::Format::Zip => super::Format::Tar, + super::Format::Tar => super::Format::TarGz, + super::Format::TarGz => super::Format::Zip, + }; + } + + fn cycle_codec(&mut self) { + if self.controller.state.format == super::Format::Zip { + self.controller.state.codec = match self.controller.state.codec { + super::Codec::Store => super::Codec::Deflate, + super::Codec::Deflate => super::Codec::Zstd, + super::Codec::Zstd => super::Codec::Store, + }; + } + } + + fn cycle_level(&mut self) { + self.controller.state.level = match self.controller.state.level { + super::Level::Store => super::Level::Fast, + super::Level::Fast => super::Level::Normal, + super::Level::Normal => super::Level::Best, + super::Level::Best => super::Level::Store, + }; + } + + fn start_add(&mut self, cx: &mut Context) { + let Some(first) = self.controller.state.pending_inputs.first() else { + return; + }; + self.dialog_return_focus = self.add_start_focus.clone(); + let dir = first.parent().map(PathBuf::from).unwrap_or_default(); + let name = { + let name = self.controller.state.output_name.trim(); + if name.is_empty() { + format!("archive.{}", self.controller.state.format.extension()) + } else { + name.to_string() + } + }; + self.controller.dispatch(AppAction::Run(Job::Compress { + out: dir.join(name), + inputs: self.controller.state.pending_inputs.clone(), + format: self.controller.state.format, + codec: self.controller.state.codec, + level: self.controller.state.level, + password: (self.controller.state.format == super::Format::Zip + && !self.controller.state.add_password.is_empty()) + .then(|| self.controller.state.add_password.clone()), + })); + cx.notify(); + } + + fn add_dialog(&mut self, cx: &mut Context) -> Stateful { + let s = self.controller.s(); + let format = self.controller.state.format; + let is_zip = format == super::Format::Zip; + let format_button = Self::dialog_button( + "add-format", + format.label(), + &self.add_format_focus, + false, + cx, + ) + .aria_label(s.format) + .on_click(cx.listener(|this, _, _, cx| { + this.cycle_format(); + cx.notify(); + })); + let codec_button = Self::button( + "add-codec", + self.controller.codec_name(self.controller.state.codec), + s.compressor.to_string(), + is_zip, + cx, + ) + .track_focus(&self.add_codec_focus) + .on_click(cx.listener(|this, _, _, cx| { + this.cycle_codec(); + cx.notify(); + })); + let level_button = Self::dialog_button( + "add-level", + self.controller.level_name(self.controller.state.level), + &self.add_level_focus, + false, + cx, + ) + .aria_label(s.level) + .on_click(cx.listener(|this, _, _, cx| { + this.cycle_level(); + cx.notify(); + })); + let password = if is_zip { + let show = self.controller.state.show_password; + let toggle = Self::dialog_button( + "add-password-visibility", + if show { s.hide_word } else { s.show_password }, + &self.add_password_toggle_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.controller.state.show_password = !this.controller.state.show_password; + cx.notify(); + })); + Some( + div() + .flex() + .gap_2() + .child(self.add_password.clone()) + .child(toggle), + ) + } else { + None + }; + let start = Self::dialog_button("add-start", s.start, &self.add_start_focus, true, cx) + .on_click(cx.listener(|this, _, _, cx| this.start_add(cx))); + let cancel = Self::dialog_button("add-cancel", s.cancel, &self.add_cancel_focus, false, cx) + .on_click(cx.listener(|this, _, _, cx| { + this.controller.state.view = View::Browse; + cx.notify(); + })); + let count = self.controller.state.pending_inputs.len(); + let options = div() + .flex() + .flex_wrap() + .gap_2() + .child( + div() + .flex() + .items_center() + .gap_1() + .child(s.format) + .child(format_button), + ) + .child( + div() + .flex() + .items_center() + .gap_1() + .child(s.compressor) + .child(codec_button), + ) + .child( + div() + .flex() + .items_center() + .gap_1() + .child(s.level) + .child(level_button), + ); + let mut body = div() + .id("add-dialog-body") + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .items_center() + .gap_2() + .child(s.output_name) + .child(self.output_name.clone()), + ) + .child(options); + if let Some(password) = password { + body = body.child(password); + } + body = body + .child(format!("{count} {}", s.files_word)) + .child(div().flex().gap_2().child(start).child(cancel)); + self.dialog_overlay(ModalKind::Add, s.add_to_archive, s.defaults_title, body, cx) + } + + fn dialogs(&mut self, cx: &mut Context) -> Option> { + let s = self.controller.s(); + if matches!(self.controller.state.view, View::Add) { + return Some(self.add_dialog(cx)); + } + match self.modal_kind()? { + ModalKind::Password => { + let setting = matches!( + self.controller.state.waiting_on_password, + Some(Pending::CurrentPassword(_)) + ); + let title = if setting { + s.set_password + } else { + s.password_needed + }; + let hint = if setting { + s.new_password + } else { + s.password_hint + }; + let password = self.password.clone(); + let show = !self.controller.state.show_password; + let toggle = Self::dialog_button( + "password-visibility", + if show { s.show_password } else { s.hide_word }, + &self.password_toggle_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.controller + .dispatch(AppAction::TogglePasswordVisibility); + cx.notify(); + })); + let submit = Self::dialog_button( + "password-submit", + if setting { s.set_password } else { s.start }, + &self.dialog_primary_focus, + true, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + let password = this.password.read(cx).content.clone(); + this.controller + .dispatch(AppAction::SubmitPassword(password)); + cx.notify(); + })); + let cancel = Self::dialog_button( + "password-cancel", + s.cancel, + &self.dialog_cancel_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.controller.dispatch(AppAction::CancelPassword); + cx.notify(); + })); + Some( + self.dialog_overlay( + ModalKind::Password, + title, + hint, + div() + .id("password-dialog-body") + .flex() + .flex_col() + .gap_3() + .child(password) + .child(toggle) + .child(div().flex().gap_2().child(submit).child(cancel)), + cx, + ), + ) + } + ModalKind::Conflict => { + let path = self.controller.state.conflict.clone().unwrap_or_default(); + let overwrite = Self::dialog_button( + "conflict-replace", + s.yes, + &self.dialog_primary_focus, + true, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.answer_conflict(Answer::Replace); + cx.notify(); + })); + let overwrite_all = Self::dialog_button( + "conflict-replace-all", + s.yes_all, + &self.dialog_secondary_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.answer_conflict(Answer::ReplaceAll); + cx.notify(); + })); + let skip = Self::dialog_button( + "conflict-skip", + s.no, + &self.dialog_tertiary_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.answer_conflict(Answer::Skip); + cx.notify(); + })); + let skip_all = Self::dialog_button( + "conflict-skip-all", + s.no_all, + &self.dialog_quaternary_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.answer_conflict(Answer::SkipAll); + cx.notify(); + })); + let keep = Self::dialog_button( + "conflict-keep-both", + s.rename, + &self.dialog_rename_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.answer_conflict(Answer::Rename); + cx.notify(); + })); + let rename_all = Self::dialog_button( + "conflict-keep-both-all", + s.rename_all, + &self.dialog_rename_all_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.answer_conflict(Answer::RenameAll); + cx.notify(); + })); + let cancel = Self::dialog_button( + "conflict-cancel", + s.cancel, + &self.dialog_cancel_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.answer_conflict(Answer::Cancel); + cx.notify(); + })); + Some( + self.dialog_overlay( + ModalKind::Conflict, + s.conflict_title, + format!("{} {path}", s.already_there), + div() + .id("conflict-dialog-body") + .flex() + .flex_wrap() + .gap_2() + .children([ + overwrite, + overwrite_all, + skip, + skip_all, + keep, + rename_all, + cancel, + ]), + cx, + ), + ) + } + ModalKind::Delete => { + let names = self + .controller + .state + .confirm_delete + .clone() + .unwrap_or_default(); + let confirm = Self::dialog_button( + "delete-confirm", + s.delete_word, + &self.dialog_primary_focus, + true, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.controller.dispatch(AppAction::ConfirmDelete(true)); + cx.notify(); + })); + let cancel = Self::dialog_button( + "delete-cancel", + s.cancel, + &self.dialog_cancel_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.controller.dispatch(AppAction::ConfirmDelete(false)); + cx.notify(); + })); + Some( + self.dialog_overlay( + ModalKind::Delete, + s.delete_word, + fill(s.confirm_delete, &[("n", &names.len().to_string())]), + div() + .id("delete-dialog-body") + .flex() + .gap_2() + .children([confirm, cancel]), + cx, + ), + ) + } + ModalKind::Drop => { + let paths = self + .controller + .state + .confirm_drop + .clone() + .unwrap_or_default(); + let open = Self::dialog_button( + "drop-open", + s.open_word, + &self.dialog_primary_focus, + true, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.controller + .dispatch(AppAction::AnswerDrop(DropChoice::Open)); + cx.notify(); + })); + let add = Self::dialog_button( + "drop-add", + s.add_to_archive, + &self.dialog_secondary_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.controller + .dispatch(AppAction::AnswerDrop(DropChoice::Add)); + cx.notify(); + })); + let cancel = Self::dialog_button( + "drop-cancel", + s.cancel, + &self.dialog_cancel_focus, + false, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.controller + .dispatch(AppAction::AnswerDrop(DropChoice::Cancel)); + cx.notify(); + })); + let names = paths + .iter() + .take(8) + .filter_map(|path| path.file_name()) + .map(|name| name.to_string_lossy().to_string()) + .collect::>() + .join(", "); + Some( + self.dialog_overlay( + ModalKind::Drop, + s.drop_title, + format!("{} {names}", s.dropped_word), + div() + .id("drop-dialog-body") + .flex() + .gap_2() + .children([open, add, cancel]), + cx, + ), + ) + } + kind @ (ModalKind::NewFolder | ModalKind::Rename | ModalKind::Mask) => { + Some(self.name_dialog(kind, cx)) + } + ModalKind::Viewer => Some(self.viewer_dialog(cx)), + ModalKind::DefaultPassword => Some(self.default_password_dialog(cx)), + ModalKind::Settings => Some(self.settings_dialog(cx)), + ModalKind::Shortcuts => Some(self.shortcuts_dialog(cx)), + ModalKind::Add => unreachable!("add dialog is rendered above"), + } + } + + /// Runs what the row menu was asked for and shuts it. + fn row_action( + &mut self, + action: RowAction, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + self.row_menu = None; + if !self.background_idle() { + return; + } + let rows = self.controller.visible_rows(); + let row = rows.get(index).cloned(); + match action { + RowAction::Open => { + if let Some(row) = row { + if row.is_dir { + self.controller.dispatch(AppAction::Navigate(row.path)); + self.route_changed(cx); + } else if let Some(entry) = row.entry { + self.controller.dispatch(AppAction::OpenFile(entry)); + } + } + } + RowAction::ExtractSelection => { + self.begin_dialog(DialogKind::Extract { only_checked: true }, cx) + } + RowAction::ExtractHere => self.controller.extract_here(), + // Only a file has anything to look at. A folder is a prefix on + // some names, not a thing with bytes. + RowAction::View => { + if let Some(entry) = row.as_ref().and_then(|row| row.entry) { + self.controller.view_entry(entry); + } + } + RowAction::TestSelection => { + let names = self.controller.selected_names(); + if let Some(archive) = self.controller.state.archive.clone() { + self.controller.dispatch(AppAction::Run(Job::Test { + archive, + only: (!names.is_empty()).then(|| names.into_iter().collect()), + })); + } + } + // Only a zip can be written to in place, so anywhere else this is + // left out rather than offered and refused. + RowAction::Rename => { + if let Some(row) = row { + if self.controller.state.format == super::Format::Zip { + self.name_value = row.label.clone(); + self.controller.state.renaming = Some((row.path, row.label)); + } + } + } + RowAction::Delete => { + self.dialog_return_focus = self.delete_trigger_focus.clone(); + self.controller.dispatch(AppAction::RequestDelete); + } + RowAction::Copy => self.dispatch_clipboard(false, window, cx), + RowAction::Cut => self.dispatch_clipboard(true, window, cx), + RowAction::Paste => self.dispatch_paste(window, cx), + RowAction::CopyNames => { + let names = self.controller.selected_names(); + if !names.is_empty() { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(names.join("\r\n"))); + } + } + RowAction::SelectAll => self.controller.dispatch(AppAction::SelectAllVisible), + } + cx.notify(); + } + + fn row_menu_key_down( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + if event.keystroke.key == "escape" { + self.row_menu = None; + self.list_focus.focus(window, cx); + cx.stop_propagation(); + cx.notify(); + return; + } + let writable = self.controller.state.format == super::Format::Zip; + let items: Vec = RowAction::ALL + .iter() + .filter(|action| action.offered() && (**action != RowAction::Rename || writable)) + .map(|action| self.row_menu_item_focus[*action as usize].clone()) + .collect(); + Self::menu_key_down(event, &items, window, cx); + } + + /// The menu the right button opens on a row, floating where the pointer is. + fn row_menu_view( + &mut self, + index: usize, + at: gpui::Point, + cx: &mut Context, + ) -> Stateful { + let s = self.controller.s(); + let mut menu = div() + .id("row-menu") + .role(Role::Menu) + .aria_label(s.archive_contents) + .absolute() + .left(at.x) + .top(at.y) + .w(px(240.)) + .flex() + .flex_col() + .gap_px() + .p_1() + .bg(cx.theme().popover) + .text_color(cx.theme().popover_foreground) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius_lg) + .shadow_lg() + .occlude() + .track_focus(&self.row_menu_focus) + .tab_group() + .focus_visible(focus_ring(cx)) + .on_key_down(cx.listener(Self::row_menu_key_down)); + let writable = self.controller.state.format == super::Format::Zip; + // Only a file has anything to look at, so a folder is not offered a + // viewer it would refuse. + let is_file = self + .controller + .visible_rows() + .get(index) + .is_some_and(|row| row.entry.is_some()); + let mut drawn = false; + for action in RowAction::ALL.into_iter().filter(|a| { + a.offered() + && (*a != RowAction::Rename || writable) + && (*a != RowAction::View || is_file) + }) { + // Never as the first thing in the menu: a rule with nothing above + // it is a line, not a grouping. + if action.starts_group() && drawn { + menu = menu.child(div().py_1().child(Separator::horizontal())); + } + drawn = true; + let (label, keys) = action.label(s); + let item_focus = self.row_menu_item_focus[action as usize].clone(); + let item = Self::menu_item( + ("row-menu-item", action as usize), + label, + keys, + label.to_string(), + true, + cx, + ) + .track_focus(&item_focus) + .on_click(cx.listener(move |this, _, window, cx| { + this.row_action(action, index, window, cx); + })); + menu = menu.child(item); + } + menu + } + + /// The list's geometry, or nothing before it has been laid out once. + fn list_view(&self, count: usize) -> Option { + let state = self.list_scroll.0.borrow(); + let row = row_height(f32::from(state.last_item_size?.contents.height), count)?; + let bounds = state.base_handle.bounds(); + let top = f32::from(bounds.origin.y); + let left = f32::from(bounds.origin.x); + let height = f32::from(bounds.size.height); + if height <= 0.0 { + return None; + } + Some(ListView { + top, + bottom: top + height, + left, + right: left + f32::from(bounds.size.width), + row, + offset: -f32::from(state.base_handle.offset().y), + reach: f32::from(state.base_handle.max_offset().y), + }) + } + + /// Whether the pointer has left the list. What is being carried goes to the + /// system from here; inside, it is still a move between folders. + fn left_the_list(&self, at: gpui::Point) -> bool { + let Some(view) = self.list_view(self.row_count) else { + return false; + }; + let (x, y) = (f32::from(at.x), f32::from(at.y)); + y < view.top || y > view.bottom || x < view.left || x > view.right + } + + fn scroll_to(&self, view: &ListView, offset: f32) { + let state = self.list_scroll.0.borrow(); + let x = state.base_handle.offset().x; + state + .base_handle + .set_offset(point(x, px(-offset.clamp(0.0, view.reach)))); + } + + /// The row under a point, or nothing if that is past the last one. + fn row_under(view: &ListView, y: f32, len: usize) -> Option { + let local = y - view.top + view.offset; + if local < 0.0 || view.row <= 0.0 { + return None; + } + let index = (local / view.row) as usize; + (index < len).then_some(index) + } + + /// Pressing the left button inside the list, which is where a band begins. + /// + /// Pressing on a row that is already picked and pulling is how you take the + /// selection somewhere else, so that one is left to the drag; pressing + /// anywhere else and pulling draws a new band. That is the rule in the + /// Explorer, and it is the only one that lets both gestures share a button. + fn begin_band(&mut self, at: gpui::Point, secondary: bool, shift: bool) { + if !self.background_idle() || shift { + return; + } + let rows = self.controller.visible_rows(); + let Some(view) = self.list_view(rows.len()) else { + return; + }; + let (x, y) = (f32::from(at.x), f32::from(at.y)); + if y < view.top || y > view.bottom || x < view.left || x > view.right { + return; + } + let anchor = Self::row_under(&view, y, rows.len()); + if let Some(index) = anchor { + if !secondary && self.controller.is_checked(&rows[index]) { + return; + } + } + self.band = Some(Band { + origin: at, + rows: rows.clone(), + // Begun in the empty space under the list, where there is no row to + // hang the band on: it still picks everything between there and + // wherever it goes. + anchor: anchor.unwrap_or(rows.len().saturating_sub(1)), + base: if secondary { + self.controller.state.checked.clone() + } else { + vec![false; self.controller.state.checked.len()] + }, + head: at, + live: false, + }); + } + + /// The band following the pointer, and the list following it past an edge. + fn drag_band(&mut self, at: gpui::Point) -> bool { + // Taken out and put back, so the rows frozen inside it can be read + // while the controller is being written to. + let Some(mut band) = self.band.take() else { + return false; + }; + band.head = at; + let travelled = (f32::from(at.x) - f32::from(band.origin.x)) + .hypot(f32::from(at.y) - f32::from(band.origin.y)); + if !band.live && travelled < DRAG_SLOP { + self.band = Some(band); + return false; + } + band.live = true; + let rows = &band.rows; + let Some(view) = self.list_view(rows.len()) else { + self.band = Some(band); + return false; + }; + let y = f32::from(at.y); + let head = Self::row_under(&view, y.clamp(view.top, view.bottom), rows.len()) + .unwrap_or(rows.len() - 1); + let (lo, hi) = if band.anchor <= head { + (band.anchor, head) + } else { + (head, band.anchor) + }; + self.controller.state.checked.clone_from(&band.base); + for row in &rows[lo..=hi.min(rows.len() - 1)] { + self.controller.set_checked(row, true); + } + // Past either edge the list follows the pointer, the way the Explorer + // does it. Without this a selection could never be longer than the + // window, because dragging no longer scrolls. + let over = if y < view.top { + y - view.top + } else if y > view.bottom { + y - view.bottom + } else { + 0.0 + }; + if over != 0.0 { + self.scroll_to(&view, view.offset + over.clamp(-24.0, 24.0)); + } + self.band = Some(band); + true + } + + /// Drops the anchor, or picks it back up. + fn toggle_wheel(&mut self, at: gpui::Point) { + if self.wheel.is_some() || !self.background_idle() { + self.wheel = None; + return; + } + let Some(view) = self.list_view(self.row_count) else { + return; + }; + let (x, y) = (f32::from(at.x), f32::from(at.y)); + if y < view.top || y > view.bottom || x < view.left || x > view.right { + return; + } + self.wheel = Some(WheelPan { + anchor: at, + moved: false, + }); + } + + /// One step of the list running towards the pointer, for the tick that + /// keeps a gesture moving while the hand is still. + /// + /// ponytail: driven by the shell's existing 100 ms poll rather than by a + /// frame callback, so the run is ten steps a second. Move it onto a frame + /// request if the stepping ever reads as stutter. + fn tick_wheel(&mut self, at: gpui::Point) -> bool { + let Some(wheel) = &mut self.wheel else { + return false; + }; + let speed = super::wheel_speed(f32::from(at.y) - f32::from(wheel.anchor.y)); + wheel.moved |= speed != 0.0; + if speed == 0.0 { + return false; + } + let Some(view) = self.list_view(self.row_count) else { + return false; + }; + self.scroll_to(&view, view.offset + speed * 0.1); + true + } + + /// Whether the keyboard is inside a text field. + /// + /// A bare key means something different there -- F5 in a filter box is a + /// key, not a command -- so the shortcuts that carry no modifier stand + /// aside while one has the focus. + fn typing(&self, window: &Window, cx: &App) -> bool { + self.filter.read(cx).focus_handle(cx).is_focused(window) + || [&self.password, &self.output_name, &self.add_password] + .iter() + .any(|input| input.read(cx).focus_handle.is_focused(window)) + } + + /// The shortcuts that belong to the window rather than to the list. + /// + /// One handler rather than a dozen `actions!` entries and twice as many + /// key bindings: every one of these is the same shape -- a key, a guard, + /// and an action already written -- and a table of them reads in one go. + fn global_key_down( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + if self.background_blocked() { + return; + } + let typing = self.typing(window, cx); + let Some(shortcut) = shortcut_for( + event.keystroke.modifiers.secondary(), + event.keystroke.modifiers.shift, + event.keystroke.modifiers.alt, + &event.keystroke.key.to_ascii_lowercase(), + typing, + ) else { + return; + }; + let archive = self.controller.state.archive.clone(); + let idle = self.background_idle(); + // The shortcuts window is the one that answers while an archive is + // being read, because it is the one that says what to press. + if !idle && shortcut != Shortcut::Shortcuts { + return; + } + match shortcut { + Shortcut::Open => self.begin_dialog(DialogKind::Open, cx), + Shortcut::Compress => self.begin_dialog(DialogKind::Compress, cx), + Shortcut::ExtractAll if archive.is_some() => self.begin_dialog( + DialogKind::Extract { + only_checked: false, + }, + cx, + ), + // Everything out, beside the archive, without asking where: the + // folder the archive is in is where an extraction goes nine times + // out of ten, and the whole point is that it is one keystroke. + Shortcut::ExtractHere if archive.is_some() => self.controller.extract_here(), + // Verifying an archive was reachable from the shell menu and the + // command line and from nowhere inside the window. + Shortcut::Test => { + if let Some(archive) = archive { + self.controller.dispatch(AppAction::Run(Job::Test { + archive, + only: None, + })); + } + } + Shortcut::Refresh => { + if let Some(path) = archive { + // Rereading must not ask again for the password of an + // archive that has already been unlocked. + let keep = self.controller.state.archive_password.clone(); + self.controller.dispatch(AppAction::Open(path)); + self.controller.state.archive_password = keep; + } + } + Shortcut::Invert => self.controller.dispatch(AppAction::InvertVisible), + // Escape backs out of the innermost thing there is to back out of, + // and while the list is running itself that is the running. + Shortcut::ClearSelection => { + if self.wheel.take().is_none() { + self.controller.dispatch(AppAction::ClearSelection); + } + } + // The names as text, which is all a desktop without a file + // clipboard can be given, and useful on one that has it too. + Shortcut::CopyNames => { + let names = self.controller.selected_names(); + if !names.is_empty() { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(names.join("\r\n"))); + } + } + Shortcut::Shortcuts => { + self.controller.state.show_shortcuts = !self.controller.state.show_shortcuts; + } + // One step back from the last change to the archive, which is the + // step anybody wants: the one they just took by mistake. + Shortcut::Undo if self.controller.state.undo.is_some() => self.controller.undo_last(), + Shortcut::DefaultPassword => { + self.controller.state.password_input.clear(); + self.controller.state.asking_default_password = true; + } + Shortcut::Rename if archive.is_some() => { + let cursor = self.controller.state.cursor; + let rows = self.controller.visible_rows(); + match cursor.and_then(|index| rows.get(index)) { + Some(row) if self.controller.state.format == super::Format::Zip => { + self.name_value = row.label.clone(); + self.controller.state.renaming = + Some((row.path.clone(), row.label.clone())); + } + _ => return, + } + } + Shortcut::PickGroup(adding) if archive.is_some() => { + self.name_value.clear(); + self.controller.state.picking_group = Some(adding); + } + Shortcut::View if archive.is_some() => { + let cursor = self.controller.state.cursor; + let rows = self.controller.visible_rows(); + match cursor + .and_then(|index| rows.get(index)) + .and_then(|row| row.entry) + { + Some(entry) => self.controller.view_entry(entry), + None => return, + } + } + Shortcut::ExtractAll + | Shortcut::ExtractHere + | Shortcut::Undo + | Shortcut::Rename + | Shortcut::View + | Shortcut::PickGroup(_) => return, + } + cx.stop_propagation(); + cx.notify(); + } + + /// The keys, and what each one does, in the two columns they are read in. + fn shortcuts_dialog(&mut self, cx: &mut Context) -> Stateful { + let s = self.controller.s(); + let left = [ + ("Ctrl+O", s.open), + ("Ctrl+N", s.compress), + ("Ctrl+E", s.extract_all), + ("Alt+W", s.extract_here), + ("F3", s.view_word), + ("Ctrl+T", s.test_word), + ("F5", s.refresh_word), + ("Ctrl+F", s.find_word), + ("", ""), + ("Ctrl+Z", s.undo_word), + ("Ctrl+P", s.default_password), + ("Ctrl+A", s.select_all), + ("Ctrl+I", s.invert_selection), + ("Esc", s.clear_selection), + ("Space", s.toggle_word), + ("Num + -", s.select_group), + ("F2", s.rename_word), + ("Supr", s.delete_word), + ("F1", s.shortcuts_title), + ]; + let right = [ + ("Ctrl+C", s.copy_word), + ("Ctrl+X", s.cut_word), + ("Ctrl+V", s.paste_word), + ("Ctrl+Shift+C", s.copy_names), + ("", ""), + ("Enter", s.open_word), + ("Backspace", s.up), + ("\u{2191} \u{2193}", s.move_word), + ("Home End", s.move_word), + ("PageUp PageDown", s.move_word), + ("Tab", s.jump_word), + ]; + let column = |rows: &[(&str, &str)]| { + rows.iter().filter(|(key, _)| !key.is_empty()).fold( + div().flex().flex_col().gap_1(), + |column, (key, what)| { + column.child( + div() + .flex() + .gap_3() + .text_sm() + .child( + div() + .w(px(130.)) + .flex_none() + .text_color(cx.theme().muted_foreground) + .child(key.to_string()), + ) + .child(what.to_string()), + ) + }, + ) + }; + let close = Self::dialog_button( + "shortcuts-close", + s.close, + &self.dialog_cancel_focus, + true, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.controller.state.show_shortcuts = false; + cx.notify(); + })); + let body = div() + .id("shortcuts-dialog-body") + .flex() + .flex_col() + .gap_4() + .child( + div() + .flex() + .gap_8() + .child(column(&left)) + .child(column(&right)), + ) + .child(close); + self.dialog_overlay( + ModalKind::Shortcuts, + s.shortcuts_title, + s.shortcuts_title, + body, + cx, + ) + } + + fn background_idle(&self) -> bool { + !self.controller.state.busy + && self.modal_kind().is_none() + && self.dialog.is_none() + && !self.overflow_open + && !self.breadcrumbs_open + && self.row_menu.is_none() + } + + /// How many of the recent archives the menu actually draws. + fn recent_shown(&self) -> usize { + self.controller.state.settings.recent.len().min(RECENT_MAX) + } + + fn menu_enabled(&self) -> bool { + !self.controller.state.busy && self.modal_kind().is_none() && self.dialog.is_none() + } + + fn selected_count(&self) -> usize { + self.controller + .state + .checked + .iter() + .filter(|checked| **checked) + .count() + } + + fn can_copy_files(&self) -> bool { + clipboard_action_allowed( + clipboard::AVAILABLE, + self.menu_enabled(), + self.controller.state.archive.is_some(), + self.selected_count(), + true, + ) + } + + fn can_paste_files(&self) -> bool { + clipboard_action_allowed( + clipboard::AVAILABLE, + self.menu_enabled(), + self.controller.state.archive.is_some(), + self.selected_count(), + false, + ) + } + + fn clipboard_focus_is_safe(&self, window: &Window, cx: &mut Context) -> bool { + !self.filter.read(cx).focus_handle(cx).is_focused(window) && self.modal_kind().is_none() + } + + fn dispatch_clipboard(&mut self, cut: bool, window: &Window, cx: &mut Context) { + if !self.clipboard_focus_is_safe(window, cx) || !self.menu_enabled() { + return; + } + if self.controller.state.archive.is_none() || self.selected_count() == 0 { + return; + } + if !clipboard::AVAILABLE { + self.controller.state.notice = + "File clipboard integration is available on Windows only.".into(); + self.controller.state.error = true; + cx.notify(); + return; + } + self.controller.state.notice = if cut { + "Preparing selected files to move…".into() + } else { + "Preparing selected files to copy…".into() + }; + self.controller.state.error = false; + self.controller.dispatch(AppAction::Copy { cut }); + cx.stop_propagation(); + cx.notify(); + } + + fn dispatch_paste(&mut self, window: &Window, cx: &mut Context) { + if !self.clipboard_focus_is_safe(window, cx) || !self.menu_enabled() { + return; + } + if self.controller.state.archive.is_none() { + return; + } + if !clipboard::AVAILABLE { + self.controller.state.notice = + "File clipboard integration is available on Windows only.".into(); + self.controller.state.error = true; + cx.notify(); + return; + } + self.controller.dispatch(AppAction::Paste); + cx.stop_propagation(); + cx.notify(); + } + + fn copy_files_action(&mut self, _: &CopyFiles, window: &mut Window, cx: &mut Context) { + self.dispatch_clipboard(false, window, cx); + } + + fn cut_files_action(&mut self, _: &CutFiles, window: &mut Window, cx: &mut Context) { + self.dispatch_clipboard(true, window, cx); + } + + fn paste_files_action(&mut self, _: &PasteFiles, window: &mut Window, cx: &mut Context) { + self.dispatch_paste(window, cx); + } + + fn status(&self) -> String { + let s = self.controller.s(); + let state = &self.controller.state; + if state.busy || (matches!(state.view, View::Running) && state.notice.is_empty()) { + let progress = if state.total_count == 0 { + s.working_word.to_string() + } else { + format!("{} / {}", state.done_count, state.total_count) + }; + return format!("{} · {}", state.title, progress); + } + if !state.notice.is_empty() { + return state.notice.clone(); + } + if matches!(state.view, View::Add) { + return format!("{}: {}", s.add_to_archive, state.pending_inputs.len()); + } + if state.entries.is_empty() { + return s.drop_here.into(); + } + self.controller.summary() + } + + fn crumbs(&self) -> Vec<(String, String)> { + let Some(archive) = &self.controller.state.archive else { + return Vec::new(); + }; + let root = archive + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + let mut crumbs = vec![(root, String::new())]; + let mut walked = String::new(); + for part in self + .controller + .state + .current_dir + .split('/') + .filter(|part| !part.is_empty()) + { + walked.push_str(part); + walked.push('/'); + crumbs.push((part.to_string(), walked.clone())); + } + crumbs + } + + fn visible_crumb_indices(len: usize) -> (Vec, Vec) { + if len <= 4 { + return ((0..len).collect(), Vec::new()); + } + let mut shown = vec![0]; + let hidden_end = len - 3; + let hidden: Vec = (1..hidden_end).collect(); + shown.extend(hidden_end..len); + (shown, hidden) + } + + fn shown_columns(&self) -> Vec { + let columns = self.controller.state.settings.columns; + std::iter::once(SortColumn::Name) + .chain( + Columns::ALL + .iter() + .map(|(column, _)| *column) + .filter(move |column| columns.on(*column)), + ) + .collect() + } + + /// Where a column's width lives in `Settings::widths`: the name first, + /// then the ones that can be turned off, in the order of `Columns::ALL`. + /// A column keeps its width while it is off, so turning one back on does + /// not lose how it was set. + fn column_slot(column: SortColumn) -> usize { + Columns::ALL + .iter() + .position(|(candidate, _)| *candidate == column) + .map_or(0, |index| index + 1) + } + + fn column_width(&self, column: SortColumn) -> f32 { + let slot = Self::column_slot(column); + self.controller + .state + .settings + .widths + .get(slot) + .copied() + .unwrap_or_else(|| Settings::default_widths()[slot]) + } + + /// What a column would have to be to hold what is in it. + /// + /// ponytail: counted in characters against a nominal advance rather than + /// shaped through the text system, which is not reachable from a mouse + /// handler. Fit the real shaped width if a proportional face ever makes + /// this visibly wrong. + fn natural_width(&self, column: SortColumn, rows: &[super::Row]) -> f32 { + let head = Columns::label(column, self.controller.s()).chars().count(); + let widest = rows + .iter() + .map(|row| self.column_text(row, column).chars().count()) + .max() + .unwrap_or(0); + let slot = Self::column_slot(column); + (widest.max(head) as f32 * 7.2 + 24.0).clamp(Settings::least(slot), 640.0) + } + + fn column_header( + &self, + column: SortColumn, + label: &'static str, + enabled: bool, + cx: &mut Context, + ) -> Stateful { + let s = self.controller.s(); + let active = self.controller.state.order.0 == column; + let ascending = self.controller.state.order.1; + let direction = if ascending { s.ascending } else { s.descending }; + let text = if active { + format!("{} {}", label, if ascending { "↑" } else { "↓" }) + } else { + label.to_string() + }; + let accessible = if active { + format!("{} {label} ({direction})", s.sort_by) + } else { + format!("{} {label}", s.sort_by) + }; + let mut cell = div() + .id(label) + .aria_label(accessible) + .aria_keyshortcuts("Enter") + .tab_stop(enabled) + .focus_visible(focus_ring(cx)) + .px_2() + .items_center() + .flex() + .text_sm() + .text_color(if active { + cx.theme().foreground + } else { + cx.theme().table_head_foreground + }) + .child(text); + if enabled { + cell = cell.role(Role::Button).focusable(); + } + if column == SortColumn::Name { + cell = cell.flex_1(); + } else { + cell = cell.w(px(self.column_width(column))).flex_none(); + } + if enabled { + cell = cell.on_click(cx.listener(move |this, _, _, cx| { + if this.background_idle() { + this.controller.dispatch(AppAction::Sort(column)); + cx.notify(); + } + })); + } + cell + } + + /// The grab strip down the right edge of a header cell. + /// + /// Absolute inside the cell rather than an element of its own in the flex + /// row: a divider with a width would push every header a few pixels off + /// the column it names. + fn column_edge(&self, column: SortColumn, cx: &mut Context) -> Stateful { + let slot = Self::column_slot(column); + let width = self.column_width(column); + div() + .id(("column-edge", column as usize)) + .absolute() + .top_0() + .bottom_0() + .right(px(-3.)) + .w(px(6.)) + .cursor(gpui::CursorStyle::ResizeLeftRight) + .hover(|style| style.bg(cx.theme().ring)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, event: &gpui::MouseDownEvent, _, cx| { + if event.click_count >= 2 { + // Fitting the column to what is in it, which is what the + // same gesture does in WinRAR and in the Explorer. + let rows = this.controller.visible_rows(); + let fitted = this.natural_width(column, &rows); + this.set_column_width(slot, fitted); + this.controller.state.settings.save(); + } else { + this.resizing = Some((slot, f32::from(event.position.x), width)); + } + cx.stop_propagation(); + cx.notify(); + }), + ) + } + + fn set_column_width(&mut self, slot: usize, width: f32) { + if self.controller.state.settings.widths.len() <= slot { + self.controller.state.settings.widths = Settings::default_widths(); + } + self.controller.state.settings.widths[slot] = width.max(Settings::least(slot)); + } + + fn kind_mark(kind: Kind) -> &'static str { + match kind { + Kind::Dir => "▰", + Kind::Image => "▧", + Kind::Text => "▤", + Kind::Archive => "▱", + Kind::Audio => "♫", + Kind::Video => "▶", + Kind::Other => "□", + } + } + + fn text_cell( + text: impl Into, + column: SortColumn, + width: f32, + index: usize, + column_index: usize, + accessible: bool, + ) -> Stateful { + let mut cell = div() + .id(("file-cell", index * 10 + column as usize)) + .w(px(width)) + .flex_none() + .px_2() + .items_center() + .flex() + .text_sm() + .child(text.into()); + if accessible { + cell = cell.role(Role::Cell).aria_column_index(column_index); + } + cell + } + + fn column_text(&self, row: &super::Row, column: SortColumn) -> String { + match column { + SortColumn::Name => row.label.clone(), + SortColumn::Size => human(row.size), + SortColumn::Packed => human(row.packed), + SortColumn::Method => { + if row.is_dir { + format!("{} {}", row.count, self.controller.s().items_word) + } else if row.encrypted { + format!("AES-256 {}", row.method) + } else { + row.method.to_string() + } + } + SortColumn::Saved => format!("{:.0}%", saved_of(row) * 100.0), + SortColumn::Modified => when(row.mtime), + SortColumn::Created => when(row.created), + SortColumn::Accessed => when(row.accessed), + SortColumn::Attributes => super::attribute_letters(row.attributes), + SortColumn::Crc => { + if row.is_dir { + "—".to_string() + } else { + format!("{:08X}", row.crc32) + } + } + SortColumn::Type => arca_icons::cache_key(&row.label, row.is_dir), + SortColumn::Path => super::folder_of(&row.path).to_string(), + } + } + + fn file_row( + &self, + index: usize, + row: &super::Row, + columns: &[SortColumn], + cx: &mut Context, + ) -> Stateful { + let selected = self.controller.is_checked(row); + let cursor = self.controller.state.cursor == Some(index); + let muted = row.entry.is_some_and(|entry| { + self.controller + .state + .cut_names + .contains(&self.controller.state.entries[entry].name) + }); + let s = self.controller.s(); + let mut description = format!("{} {}", s.col_name, row.label); + for column in columns.iter().copied().skip(1) { + let label = Columns::label(column, s); + description.push_str(&format!("; {label} {}", self.column_text(row, column))); + } + description.push_str("; "); + description.push_str(if selected { s.checked } else { s.not_checked }); + let accessible = self.background_idle(); + // A cut entry is still there until it lands somewhere; it is drawn in + // the muted ink so it reads as "about to leave" rather than as gone. + let name_color = if muted { + cx.theme().muted_foreground + } else { + cx.theme().foreground + }; + let mut name = div() + .id(("file-name-cell", index)) + .flex_1() + .px_2() + .items_center() + .flex() + .gap_2() + .min_w(px(140.)) + .text_sm() + .text_color(name_color) + .child( + div() + .w(px(18.)) + .flex_none() + // The type mark is the one place a folder is allowed to + // out-shout a file, and in a monochrome window that is done + // with weight, not hue: full ink for a folder, muted for + // everything else. + .text_color(if row.is_dir { + name_color + } else { + cx.theme().muted_foreground + }) + .child(Self::kind_mark(row.kind)), + ) + .child(div().flex_1().truncate().child(row.label.clone())); + if accessible { + name = name.role(Role::Cell).aria_column_index(1); + } + + let mut item = div() + .id(("file-row", index)) + .aria_label(description) + .aria_selected(selected) + .aria_row_index(index + 2) + .h(px(26.)) + .w_full() + .px_1() + .flex() + .items_center() + // Where the keyboard is and what is picked are two different + // things, so they get two strengths of the same ink rather than two + // colours: moving the cursor onto a picked row has to leave both + // still visible. + .border_1() + .border_color(if cursor { + cx.theme().table_active_border + } else { + cx.theme().table_row_border + }) + .bg(if selected { + cx.theme().table_active + } else if index % 2 == 1 { + cx.theme().table_even + } else { + cx.theme().table + }) + .hover(|style| style.bg(cx.theme().table_hover)) + .tab_stop(false) + .focus_visible(focus_ring(cx)) + .child(name); + if accessible { + item = item.role(Role::Row).focusable(); + if cursor { + item = item.aria_active_descendant(); + } + } + for (offset, column) in columns.iter().copied().skip(1).enumerate() { + item = item.child(Self::text_cell( + self.column_text(row, column), + column, + self.column_width(column), + index, + offset + 2, + accessible, + )); + } + if accessible { + item = item.on_click(cx.listener(move |this, event: &ClickEvent, window, cx| { + this.select_row(index, event, window, cx); + })); + + // Right clicking something that is not picked picks it, which is + // what every file list does; right clicking inside a selection + // leaves the selection alone. + item = item.on_mouse_down( + gpui::MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + if !this.background_idle() { + return; + } + if !selected { + if let Some(row) = this.controller.visible_rows().get(index) { + this.controller.dispatch(AppAction::SetChecked { + row: row.clone(), + value: true, + }); + } + } + this.controller.state.cursor = Some(index); + this.row_menu = Some((index, event.position)); + let menu_focus = this.row_menu_item_focus[0].clone(); + window.on_next_frame(move |window, cx| window.focus(&menu_focus, cx)); + cx.stop_propagation(); + cx.notify(); + }), + ); + + // A folder takes what is dropped on it and the entries move there, + // which is a rewrite of the archive and not a copy out of it. + // Dropping a folder into itself is not a move, so it is refused. + if row.is_dir { + let target = row.path.clone(); + item = item.on_drop(cx.listener( + move |this: &mut Self, _: &DraggedRows, _window, cx| { + let carried = this.controller.selected_roots(); + let into_itself = carried.iter().any(|carried| { + carried.trim_end_matches('/') == target.trim_end_matches('/') + }); + if !carried.is_empty() && !into_itself { + this.controller.move_into(&carried, &target); + } + cx.notify(); + }, + )); + } + + // GPUI owns the threshold and the gesture's lifetime. Where the + // drag is going is not decided here: a folder of this archive takes + // it as a move, and leaving the list hands it to arca-drag's lazy + // IDataObject, so no archive bytes are extracted merely to begin a + // drag. That second half is watched on the window rather than on + // the row -- the pointer leaves the row it started on as soon as it + // reaches the next one, which is not leaving the list. + if selected { + let shell = cx.entity(); + item = item.on_drag(DraggedRows, move |_, _, _window, app| { + shell.update(app, |shell, _| shell.carrying = true); + app.new(|_| gpui::Empty) + }); + } + } + item + } + + fn select_row( + &mut self, + index: usize, + event: &ClickEvent, + window: &mut Window, + cx: &mut Context, + ) { + if self.background_blocked() || !event.standard_click() { + return; + } + // A click is a drag of no distance. Anything further than that was a + // band or a carry, and the row it started on is not being clicked. + if let ClickEvent::Mouse(mouse) = event { + let travelled = (f32::from(mouse.up.position.x) - f32::from(mouse.down.position.x)) + .hypot(f32::from(mouse.up.position.y) - f32::from(mouse.down.position.y)); + if travelled >= DRAG_SLOP { + return; + } + } + let rows = self.controller.visible_rows(); + let Some(target) = rows.get(index).cloned() else { + return; + }; + let modifiers = event.modifiers(); + if modifiers.shift { + let from = self.controller.state.cursor.unwrap_or(index); + let (lo, hi) = if from <= index { + (from, index) + } else { + (index, from) + }; + for row in &rows[lo..=hi] { + self.controller.dispatch(AppAction::SetChecked { + row: row.clone(), + value: true, + }); + } + } else if modifiers.secondary() { + let value = !self.controller.is_checked(&target); + self.controller.dispatch(AppAction::SetChecked { + row: target.clone(), + value, + }); + } else { + self.controller.state.checked.fill(false); + self.controller.dispatch(AppAction::SetChecked { + row: target.clone(), + value: true, + }); + } + self.controller.state.cursor = Some(index); + self.list_scroll + .scroll_to_item(index, ScrollStrategy::Nearest); + window.focus(&self.list_focus, cx); + if event.click_count() >= 2 && !modifiers.modified() { + if target.is_dir { + self.controller.dispatch(AppAction::Navigate(target.path)); + self.route_changed(cx); + } else if let Some(entry) = target.entry { + self.controller.dispatch(AppAction::OpenFile(entry)); + } + } + cx.notify(); + } + + fn list_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + if self.background_blocked() { + cx.stop_propagation(); + return; + } + let modifiers = event.keystroke.modifiers; + let key = event.keystroke.key.to_ascii_lowercase(); + if key == "delete" { + self.dialog_return_focus = self.delete_trigger_focus.clone(); + window.focus(&self.delete_trigger_focus, cx); + self.controller.dispatch(AppAction::RequestDelete); + cx.stop_propagation(); + cx.notify(); + return; + } + let rows = self.controller.visible_rows(); + if rows.is_empty() { + if key == "backspace" && !self.controller.state.current_dir.is_empty() { + let parent = parent_of(&self.controller.state.current_dir); + self.controller.dispatch(AppAction::Navigate(parent)); + self.route_changed(cx); + cx.stop_propagation(); + } + return; + } + if modifiers.secondary() && key == "a" { + self.controller.dispatch(AppAction::SelectAllVisible); + cx.stop_propagation(); + cx.notify(); + return; + } + if key == "space" { + if let Some(index) = self.controller.state.cursor { + if let Some(row) = rows.get(index) { + let value = !self.controller.is_checked(row); + self.controller.dispatch(AppAction::SetChecked { + row: row.clone(), + value, + }); + } + } + cx.stop_propagation(); + cx.notify(); + return; + } + if key == "enter" { + if let Some(row) = self + .controller + .state + .cursor + .and_then(|index| rows.get(index)) + { + if row.is_dir { + self.controller + .dispatch(AppAction::Navigate(row.path.clone())); + self.route_changed(cx); + } else if let Some(entry) = row.entry { + self.controller.dispatch(AppAction::OpenFile(entry)); + } + } + cx.stop_propagation(); + return; + } + if key == "backspace" { + if !self.controller.state.current_dir.is_empty() { + let parent = parent_of(&self.controller.state.current_dir); + self.controller.dispatch(AppAction::Navigate(parent)); + self.route_changed(cx); + } + cx.stop_propagation(); + return; + } + + let last = rows.len() - 1; + let current = self.controller.state.cursor; + let page = 12; + let next = match key.as_str() { + "down" | "arrowdown" => Some(current.map_or(0, |index| (index + 1).min(last))), + "up" | "arrowup" => Some(current.map_or(0, |index| index.saturating_sub(1))), + "pagedown" | "page-down" => Some(current.map_or(0, |index| (index + page).min(last))), + "pageup" | "page-up" => Some(current.map_or(0, |index| index.saturating_sub(page))), + "home" => Some(0), + "end" => Some(last), + _ => None, + }; + let Some(index) = next else { return }; + if modifiers.shift { + let from = current.unwrap_or(index); + let (lo, hi) = if from <= index { + (from, index) + } else { + (index, from) + }; + for row in &rows[lo..=hi] { + self.controller.dispatch(AppAction::SetChecked { + row: row.clone(), + value: true, + }); + } + } + self.controller.state.cursor = Some(index); + self.list_scroll + .scroll_to_item(index, ScrollStrategy::Nearest); + cx.stop_propagation(); + cx.notify(); + } + + fn file_table(&self, rows: Vec, cx: &mut Context) -> Stateful { + let enabled = self.background_idle(); + let columns = self.shown_columns(); + let strings = self.controller.s(); + let labels: Vec<(&'static str, SortColumn)> = columns + .iter() + .map(|column| (Columns::label(*column, strings), *column)) + .collect(); + let mut header = labels.iter().enumerate().fold( + div() + .id("file-header") + .aria_row_index(1) + .h(px(32.)) + .w_full() + .flex() + .items_center() + .px_1() + .bg(cx.theme().table_head) + .border_b_1() + .border_color(cx.theme().border), + |header, (position, (label, column))| { + let mut cell = div() + .id(("header-cell", *column as usize)) + .aria_label(*label) + .aria_column_index(position + 1) + .h_full(); + if *column == SortColumn::Name { + cell = cell.flex_1(); + } else { + cell = cell.w(px(self.column_width(*column))).flex_none(); + // The rule that pulls the column wider, sitting in the gap + // between two cells rather than taking a place in the row, + // so the header and the rows below it stay lined up. + cell = cell.relative().child(self.column_edge(*column, cx)); + } + if enabled { + cell = cell + .role(Role::ColumnHeader) + .aria_label(*label) + .aria_column_index(position + 1); + } + header.child(cell.child(self.column_header(*column, label, enabled, cx))) + }, + ); + if enabled { + header = header.role(Role::Row).aria_row_index(1); + } + let total = rows.len(); + let column_count = columns.len(); + let row_data = rows; + let row_columns = columns; + let list = uniform_list( + "file-rows", + total, + cx.processor(move |this, range: Range, _window, row_cx| { + range + .map(|index| this.file_row(index, &row_data[index], &row_columns, row_cx)) + .collect::>() + }), + ) + .track_scroll(&self.list_scroll) + .size_full(); + let mut table = div() + .id("file-table") + .aria_label(strings.archive_contents) + .aria_row_count(total + 1) + .aria_column_count(column_count) + .track_focus(&self.list_focus) + .tab_stop(enabled) + .focus_visible(focus_ring(cx)) + // No border and no radius: the list is the content pane, not a card + // floating inside it, so it runs to the edges and the bars above and + // below draw the only lines. + .flex_1() + .min_h(px(1.)) + .flex() + .flex_col() + .child(header) + .child(div().id("file-list").flex_1().min_h(px(1.)).child(list)) + .child( + div() + .id("delete-trigger-focus") + .track_focus(&self.delete_trigger_focus) + .size_0(), + ); + if enabled { + table = table + .role(Role::Table) + .focusable() + .on_key_down(cx.listener(Self::list_key_down)); + } + table + } +} + +#[derive(Clone)] +enum DialogKind { + Open, + Compress, + Extract { + only_checked: bool, + }, + /// Files to put inside the archive that is already open. + AddFiles, + /// A copy of the open archive under another name, which is the thing to do + /// before a change nobody is sure about. + SaveCopy { + name: String, + directory: PathBuf, + }, +} + +impl Focusable for GpuiShell { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for GpuiShell { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + window.set_window_title(&self.controller.state.window_title); + self.remember_background_focus(window, cx); + let s = self.controller.s(); + let modal = self.modal_kind(); + let idle = self.background_idle(); + self.sync_modal_focus(window, cx); + let password_value = self.controller.state.password_input.clone(); + let add_password_value = self.controller.state.add_password.clone(); + let password_masked = !self.controller.state.show_password; + self.password.update(cx, |input, _| { + input.strings = s; + input.label = s.password_word; + input.enabled = matches!( + modal, + Some(ModalKind::Password | ModalKind::DefaultPassword) + ); + input.masked = password_masked; + if input.content != password_value { + input.sync_from_state(&password_value); + } + }); + // The shared text field takes the name of whichever dialog is asking. + let name_label = match modal { + Some(ModalKind::Rename) => s.rename_word, + Some(ModalKind::Mask) => s.mask_hint, + _ => s.folder_name, + }; + let name_value = self.name_value.clone(); + self.name_input.update(cx, |input, _| { + input.strings = s; + input.label = name_label; + input.enabled = matches!( + modal, + Some(ModalKind::NewFolder | ModalKind::Rename | ModalKind::Mask) + ); + if input.content != name_value { + input.sync_from_state(&name_value); + } + }); + self.output_name.update(cx, |input, _| { + input.strings = s; + input.label = s.output_name; + input.enabled = matches!(modal, Some(ModalKind::Add)); + if input.content != self.controller.state.output_name { + input.sync_from_state(&self.controller.state.output_name); + } + }); + self.add_password.update(cx, |input, _| { + input.strings = s; + input.label = s.password_optional; + input.enabled = matches!(modal, Some(ModalKind::Add)) + && self.controller.state.format == super::Format::Zip; + input.masked = password_masked; + if input.content != add_password_value { + input.sync_from_state(&add_password_value); + } + }); + let state_filter = self.controller.state.filter.clone(); + if self.filter.read(cx).value().as_ref() != state_filter { + self.filter.update(cx, |input, cx| { + input.set_value(state_filter.clone(), window, cx); + }); + } + let has_archive = self.controller.state.archive.is_some(); + let selected = self.selected_count(); + let rows = self.controller.visible_rows(); + let visible = rows.len(); + // The one place that knows how long the list is. Everything the pointer + // gestures need from it reads this instead of building the list again. + self.row_count = visible; + let can_extract = has_archive && idle; + let can_extract_selected = can_extract && selected > 0; + let password_available = can_extract && self.controller.state.format == super::Format::Zip; + let breadcrumbs = self.crumbs(); + let (shown, hidden) = Self::visible_crumb_indices(breadcrumbs.len()); + self.sync_breadcrumb_item_focus(cx); + + let mut toolbar = div() + .id("toolbar") + .aria_label(s.toolbar_region) + .w_full() + .flex() + .items_center() + .gap_2(); + if !self.background_blocked() { + toolbar = toolbar.role(Role::Toolbar); + } + + let open = Self::icon_button( + "open", + IconName::FolderOpen, + format!("{} (Ctrl+O)", s.open), + idle, + cx, + ) + .aria_keyshortcuts("Control+O") + .track_focus(&self.open_trigger_focus); + toolbar = toolbar.child(open.on_click(cx.listener(|this, _, window, cx| { + if this.background_idle() { + this.dialog_return_focus = this.open_trigger_focus.clone(); + this.begin_dialog(DialogKind::Open, cx); + window.focus(&this.open_trigger_focus, cx); + } + }))); + let compress = Self::icon_button( + "compress", + IconName::Inbox, + format!("{} (Ctrl+N)", s.compress), + idle, + cx, + ) + .aria_keyshortcuts("Control+N") + .track_focus(&self.compress_trigger_focus); + toolbar = toolbar.child(compress.on_click(cx.listener(|this, _, window, cx| { + if this.background_idle() { + this.dialog_return_focus = this.compress_trigger_focus.clone(); + this.begin_dialog(DialogKind::Compress, cx); + window.focus(&this.compress_trigger_focus, cx); + } + }))); + toolbar = toolbar.child(div().px_1().child(Separator::vertical().h(px(16.)))); + + let extract_all = Self::icon_button( + "extract-all", + IconName::PanelBottomOpen, + format!("{} (Ctrl+E)", s.extract_all), + can_extract, + cx, + ) + .aria_keyshortcuts("Control+E") + .track_focus(&self.extract_all_trigger_focus); + toolbar = toolbar.child(extract_all.on_click(cx.listener(|this, _, window, cx| { + if !this.background_idle() { + return; + } + this.dialog_return_focus = this.extract_all_trigger_focus.clone(); + window.focus(&this.extract_all_trigger_focus, cx); + this.begin_dialog( + DialogKind::Extract { + only_checked: false, + }, + cx, + ); + }))); + let extract_selected = Self::icon_button( + "extract-selected", + IconName::File, + s.extract_selected.to_string(), + can_extract_selected, + cx, + ) + .track_focus(&self.extract_selected_trigger_focus); + toolbar = toolbar.child( + extract_selected.on_click(cx.listener(|this, _, window, cx| { + if !this.background_idle() { + return; + } + this.dialog_return_focus = this.extract_selected_trigger_focus.clone(); + window.focus(&this.extract_selected_trigger_focus, cx); + this.begin_dialog(DialogKind::Extract { only_checked: true }, cx); + })), + ); + let password = Self::icon_button( + "password", + IconName::EyeOff, + format!("{} / {}", s.set_password, s.remove_password), + password_available, + cx, + ) + .track_focus(&self.password_trigger_focus); + toolbar = toolbar.child(password.on_click(cx.listener(|this, _, window, cx| { + if this.background_idle() { + this.dialog_return_focus = this.password_trigger_focus.clone(); + window.focus(&this.password_trigger_focus, cx); + this.controller.dispatch(AppAction::BeginPasswordChange); + cx.notify(); + } + }))); + toolbar = toolbar.child(div().px_1().child(Separator::vertical().h(px(16.)))); + let owner = cx.entity().downgrade(); + let recent = self + .controller + .state + .settings + .recent + .iter() + .take(RECENT_MAX) + .cloned() + .collect::>(); + let writable = has_archive && self.controller.state.format == super::Format::Zip; + let can_undo = has_archive && self.controller.state.undo.is_some(); + let can_copy = self.can_copy_files(); + let can_paste = self.can_paste_files(); + let release = self + .controller + .state + .update + .as_ref() + .map(|release| fill(s.update_ready, &[("version", &release.tag)])); + let flat_view = self.controller.state.settings.flat; + let visible_columns = Columns::ALL + .iter() + .map(|(column, _)| (*column, self.controller.state.settings.columns.on(*column))) + .collect::>(); + let overflow = Button::new("overflow") + .icon(IconName::Ellipsis) + .accessibility_label(s.more_word) + .tooltip(s.more_word) + .ghost() + .compact() + .disabled(!idle) + .dropdown_menu(move |menu, window, popup_cx| { + let mut menu = menu; + let archive_owner = owner.clone(); + let archive_release = release.clone(); + menu = menu.submenu_with_icon( + Some(Icon::new(IconName::Inbox)), + s.archive_group, + window, + popup_cx, + move |submenu, _, _| { + let test_owner = archive_owner.clone(); + let add_owner = archive_owner.clone(); + let folder_owner = archive_owner.clone(); + let undo_owner = archive_owner.clone(); + let save_owner = archive_owner.clone(); + let password_owner = archive_owner.clone(); + let mut submenu = submenu + .item( + PopupMenuItem::new(s.test_word) + .disabled(!has_archive) + .on_click(move |_, _, cx| { + let _ = test_owner.update(cx, |this, cx| { + if let Some(archive) = + this.controller.state.archive.clone() + { + this.controller.dispatch(AppAction::Run( + Job::Test { + archive, + only: None, + }, + )); + } + cx.notify(); + }); + }), + ) + .separator() + .item( + Self::popup_action( + add_owner, + s.add_to_archive, + OverflowAction::AddFiles, + ) + .disabled(!writable), + ) + .item( + Self::popup_action( + folder_owner, + s.new_folder, + OverflowAction::NewFolder, + ) + .disabled(!writable), + ) + .item( + Self::popup_action(undo_owner, s.undo_word, OverflowAction::Undo) + .disabled(!can_undo), + ) + .item( + Self::popup_action( + save_owner, + s.save_copy, + OverflowAction::SaveCopy, + ) + .disabled(!has_archive), + ) + .item( + Self::popup_action( + password_owner, + s.default_password, + OverflowAction::DefaultPassword, + ) + .disabled(!idle), + ); + if let Some(label) = archive_release.clone() { + let release_owner = archive_owner.clone(); + submenu = submenu.item(Self::popup_action( + release_owner, + label, + OverflowAction::Release, + )); + } + submenu + }, + ); + + let selection_owner = owner.clone(); + menu = menu.submenu_with_icon( + Some(Icon::new(IconName::Check)), + s.selection_group, + window, + popup_cx, + move |submenu, _, _| { + let select_owner = selection_owner.clone(); + let invert_owner = selection_owner.clone(); + let clear_owner = selection_owner.clone(); + submenu + .item( + PopupMenuItem::new(s.select_all) + .disabled(!has_archive) + .on_click(move |_, _, cx| { + let _ = select_owner.update(cx, |this, cx| { + this.controller.dispatch(AppAction::SelectAllVisible); + cx.notify(); + }); + }), + ) + .item( + PopupMenuItem::new(s.invert_selection) + .disabled(!has_archive) + .on_click(move |_, _, cx| { + let _ = invert_owner.update(cx, |this, cx| { + this.controller.dispatch(AppAction::InvertVisible); + cx.notify(); + }); + }), + ) + .item( + PopupMenuItem::new(s.clear_selection) + .disabled(!has_archive) + .on_click(move |_, _, cx| { + let _ = clear_owner.update(cx, |this, cx| { + this.controller.dispatch(AppAction::ClearSelection); + cx.notify(); + }); + }), + ) + }, + ); + + let clipboard_owner = owner.clone(); + menu = menu.submenu_with_icon( + Some(Icon::new(IconName::Copy)), + s.clipboard_group, + window, + popup_cx, + move |submenu, _, _| { + let copy_owner = clipboard_owner.clone(); + let cut_owner = clipboard_owner.clone(); + let paste_owner = clipboard_owner.clone(); + submenu + .item( + PopupMenuItem::new(s.copy_word) + .disabled(!can_copy) + .on_click(move |_, _, cx| { + let _ = copy_owner.update(cx, |this, cx| { + this.controller + .dispatch(AppAction::Copy { cut: false }); + cx.notify(); + }); + }), + ) + .item(PopupMenuItem::new(s.cut_word).disabled(!can_copy).on_click( + move |_, _, cx| { + let _ = cut_owner.update(cx, |this, cx| { + this.controller.dispatch(AppAction::Copy { cut: true }); + cx.notify(); + }); + }, + )) + .item( + PopupMenuItem::new(s.paste_word) + .disabled(!can_paste) + .on_click(move |_, _, cx| { + let _ = paste_owner.update(cx, |this, cx| { + this.controller.dispatch(AppAction::Paste); + cx.notify(); + }); + }), + ) + }, + ); + + let recent_owner = owner.clone(); + let recent_menu = recent.clone(); + menu = menu.submenu(s.recent_group, window, popup_cx, move |submenu, _, _| { + let mut submenu = submenu; + for path in recent_menu.iter() { + let open_owner = recent_owner.clone(); + let target = PathBuf::from(path); + let leaf = target + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or(path.clone()); + submenu = + submenu.item(PopupMenuItem::new(leaf).on_click(move |_, _, cx| { + let _ = open_owner.update(cx, |this, cx| { + this.controller.dispatch(AppAction::Open(target.clone())); + cx.notify(); + }); + })); + } + submenu.item( + PopupMenuItem::new(s.clear_history) + .disabled(recent_menu.is_empty()) + .on_click({ + let history_owner = recent_owner.clone(); + move |_, _, cx| { + let _ = history_owner.update(cx, |this, cx| { + this.controller.state.settings.recent.clear(); + this.controller.state.settings.save(); + cx.notify(); + }); + } + }), + ) + }); + + let view_owner = owner.clone(); + let columns_menu = visible_columns.clone(); + menu = menu.submenu(s.view_group, window, popup_cx, move |submenu, _, _| { + let flat_owner = view_owner.clone(); + let column_owner = view_owner.clone(); + let mut submenu = submenu.item( + PopupMenuItem::new(s.flat_view) + .disabled(!has_archive) + .checked(flat_view) + .on_click(move |_, _, cx| { + let _ = flat_owner.update(cx, |this, cx| { + let settings = &mut this.controller.state.settings; + settings.flat = !settings.flat; + if settings.flat && !settings.columns.on(SortColumn::Path) { + settings.columns.set(SortColumn::Path, true); + } + settings.save(); + this.controller.dispatch(AppAction::ClearSelection); + cx.notify(); + }); + }), + ); + for (column, shown) in columns_menu.iter().copied() { + let label = Columns::label(column, s); + let column_owner = column_owner.clone(); + submenu = submenu.item( + PopupMenuItem::new(format!( + "{} {label}", + if shown { s.hide_word } else { s.show_word } + )) + .checked(shown) + .on_click(move |_, _, cx| { + let _ = column_owner.update(cx, |this, cx| { + this.controller.dispatch(AppAction::ToggleColumn(column)); + cx.notify(); + }); + }), + ); + } + submenu + }); + + let app_owner = owner.clone(); + menu.submenu( + s.application_group, + window, + popup_cx, + move |submenu, _, _| { + let shortcuts_owner = app_owner.clone(); + let settings_owner = app_owner.clone(); + submenu + .item(PopupMenuItem::new(s.shortcuts_title).on_click( + move |_, _, cx| { + let _ = shortcuts_owner.update(cx, |this, cx| { + this.controller.state.show_shortcuts = true; + cx.notify(); + }); + }, + )) + .item(PopupMenuItem::new(s.settings).on_click(move |_, _, cx| { + let _ = settings_owner.update(cx, |this, cx| { + this.controller.state.show_settings = true; + cx.notify(); + }); + })) + }, + ) + }); + toolbar = toolbar.child(overflow); + + // The filter sits at the far end of the bar, the way a search field + // does in every file manager on the desktop, instead of stretching + // across whatever room the buttons left over. + let filter_input = Input::new(&self.filter) + .aria_label(s.find_word) + .focus_bordered(false) + .cleanable(true) + .disabled(!idle); + toolbar = toolbar + .child(div().flex_1().min_w(px(8.))) + .child(div().w(px(220.)).flex_none().child(filter_input)); + + let mut nav = div() + .id("navigation") + .w_full() + .relative() + .flex() + .items_center() + .gap_1() + .text_xs(); + let at_root = self.controller.state.current_dir.is_empty(); + let back = Self::icon_button( + "back", + IconName::ArrowLeft, + s.back.to_string(), + idle && self.controller.can_go_back(), + cx, + ); + nav = nav.child(back.on_click(cx.listener(|this, _, _, cx| { + if this.background_idle() && this.controller.can_go_back() { + this.controller.dispatch(AppAction::Back); + this.route_changed(cx); + } + }))); + let forward = Self::icon_button( + "forward", + IconName::ArrowRight, + s.forward.to_string(), + idle && self.controller.can_go_forward(), + cx, + ); + nav = nav.child(forward.on_click(cx.listener(|this, _, _, cx| { + if this.background_idle() && this.controller.can_go_forward() { + this.controller.dispatch(AppAction::Forward); + this.route_changed(cx); + } + }))); + let up = Self::icon_button( + "up", + IconName::ArrowUp, + s.up.to_string(), + idle && !at_root, + cx, + ); + nav = nav.child(up.on_click(cx.listener(|this, _, _, cx| { + if this.background_idle() && !this.controller.state.current_dir.is_empty() { + let parent = parent_of(&this.controller.state.current_dir); + this.controller.dispatch(AppAction::Navigate(parent)); + this.route_changed(cx); + } + }))); + // The counts moved to the status bar, where a count belongs; the + // breadcrumbs move up against the arrows, which is the only place a + // path reads as "where you are" rather than as a right-hand caption. + nav = nav.child(div().px_1().child(Separator::vertical().h(px(14.)))); + + for (position, index) in shown.iter().enumerate() { + if position > 0 { + nav = nav.child(div().text_color(cx.theme().muted_foreground).child("/")); + } + if position == 1 && !hidden.is_empty() { + let more = Self::button("crumb-more", "…", s.hidden_folders.to_string(), idle, cx) + .track_focus(&self.breadcrumbs_trigger_focus) + .aria_expanded(self.breadcrumbs_open); + nav = nav.child(more.on_click(cx.listener(|this, _, window, cx| { + if !this.background_idle() { + return; + } + this.sync_breadcrumb_item_focus(cx); + this.breadcrumbs_open = !this.breadcrumbs_open; + this.overflow_open = false; + if this.breadcrumbs_open { + let menu_focus = this.breadcrumbs_item_focus[0].clone(); + window.on_next_frame(move |window, cx| window.focus(&menu_focus, cx)); + } + cx.notify(); + }))); + nav = nav.child(div().text_color(cx.theme().muted_foreground).child("/")); + } + let (name, path) = &breadcrumbs[*index]; + if *index + 1 == breadcrumbs.len() { + nav = nav.child(div().text_color(cx.theme().foreground).child(name.clone())); + } else { + let path = path.clone(); + let crumb = Self::button( + ("crumb", *index), + name, + fill(s.open_folder, &[("name", name)]), + idle, + cx, + ); + nav = nav.child(crumb.on_click(cx.listener(move |this, _, _, cx| { + if !this.background_idle() { + return; + } + this.controller.dispatch(AppAction::Navigate(path.clone())); + this.breadcrumbs_open = false; + this.route_changed(cx); + }))); + } + } + + if self.breadcrumbs_open && !hidden.is_empty() { + let mut hidden_menu = div() + .id("hidden-breadcrumbs") + .role(Role::Menu) + .aria_label(s.hidden_folders) + .absolute() + .top(px(30.)) + .left(px(70.)) + .w(px(220.)) + .flex() + .flex_col() + .gap_px() + .p_1() + .bg(cx.theme().popover) + .text_color(cx.theme().popover_foreground) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius_lg) + .shadow_lg() + .track_focus(&self.breadcrumbs_menu_focus) + .tab_group() + .focus_visible(focus_ring(cx)) + .on_key_down(cx.listener(Self::breadcrumbs_key_down)); + for (position, index) in hidden.into_iter().enumerate() { + let (name, path) = &breadcrumbs[index]; + let path = path.clone(); + let item_focus = self.breadcrumbs_item_focus[position].clone(); + hidden_menu = hidden_menu.child( + Self::menu_item( + ("hidden-crumb", index), + name, + "", + fill(s.open_folder, &[("name", name)]), + true, + cx, + ) + .track_focus(&item_focus) + .on_click(cx.listener(move |this, _, _, cx| { + if !this.background_idle() { + return; + } + this.controller.dispatch(AppAction::Navigate(path.clone())); + this.breadcrumbs_open = false; + this.route_changed(cx); + })), + ); + } + nav = nav.child(hidden_menu); + } + + let notice_color = if self.controller.state.error { + cx.theme().danger + } else { + cx.theme().muted_foreground + }; + let status = self.status(); + + // The window is regions divided by hairlines, not strips floating in + // padding: a toolbar bar, a navigation bar, the body, and a status bar + // welded to the bottom edge. Padding lives inside each bar, so every + // divider runs the full width and the list reaches both edges. + let toolbar_bar = div() + .id("toolbar-bar") + .flex_none() + .h(px(40.)) + .px_2() + .flex() + .items_center() + .bg(cx.theme().title_bar) + .border_b_1() + .border_color(cx.theme().border) + .child(toolbar); + let nav_bar = div() + .id("nav-bar") + .flex_none() + .h(px(34.)) + .px_2() + .flex() + .items_center() + .bg(cx.theme().background) + .border_b_1() + .border_color(cx.theme().border) + .child(nav); + + let mut status_view = div() + .id("status") + .aria_label(s.status_region) + .text_xs() + .text_color(notice_color) + .truncate() + .child(status); + if !self.background_blocked() { + status_view = status_view.role(if self.controller.state.error { + Role::Alert + } else { + Role::Status + }); + } + + // Everything that used to sit in the flow — the list, the empty states, + // the progress row — now goes inside the content pane beside the + // sidebar, so the sidebar runs the full height of the window. + let mut content = div() + .id("archive-content") + .flex_1() + .min_w(px(1.)) + .min_h(px(1.)) + .flex() + .flex_col() + .bg(cx.theme().background); + + // GPUI/AccessKit has no aria-hidden builder. The supported equivalent + // is a role-less background subtree; every actionable descendant also + // drops its role, tab stop, and listener while the sibling overlay + // owns focus and input. + // The name of what is open, where a title bar puts it. Nothing in here + // can be pressed on purpose: the bar owns the drag, and a control + // inside it would move the window when the hand wobbled on the way to + // pressing it. + let title_bar = TitleBar::new().child( + div() + .id("window-title") + .role(Role::Heading) + .flex() + .items_center() + .h_full() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(self.controller.state.window_title.clone()), + ); + + let mut root = div() + .id("arca-gpui-background") + .on_action(cx.listener(Self::focus_filter)) + // Pressing anywhere that is not the menu shuts the menu. The menus + // themselves are `occlude`d, so their own clicks never arrive here. + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + if this.row_menu.take().is_some() { + cx.notify(); + } + }), + ) + .size_full() + .flex() + .flex_col() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child(title_bar) + .child(toolbar_bar) + .child(nav_bar); + + let drop_probe = { + let view = cx.entity(); + canvas( + |_, _, _| (), + move |_, _, window, _| { + let dropped = view.clone(); + window.on_mouse_event(move |event: &gpui::FileDropEvent, _, window, app| { + let current_focus = window.focused(app); + dropped.update(app, |shell, cx| match event { + gpui::FileDropEvent::Entered { paths, .. } => { + shell.drop_paths = + drop_paths_for_enter(paths.paths(), shell.background_idle()); + } + gpui::FileDropEvent::Submit { .. } => { + let paths = std::mem::take(&mut shell.drop_paths); + if !paths.is_empty() && shell.background_idle() { + // Keep the element that had focus before + // the drop. The existing modal focus sync + // will move into confirmation and return + // here when it is answered. + if let Some(focus) = current_focus { + shell.dialog_return_focus = focus; + } + shell.controller.dispatch(AppAction::Drop(paths)); + cx.notify(); + } + } + gpui::FileDropEvent::Exited | gpui::FileDropEvent::Ended => { + shell.drop_paths.clear(); + } + gpui::FileDropEvent::Pending { .. } => {} + }); + }); + + // A column edge in hand, a band being pulled, and the list + // running after the pointer all have to keep working once + // the pointer has left the thing it started on, so the move + // and the release are watched on the window. + let dragging = view.clone(); + window.on_mouse_event( + move |event: &gpui::MouseMoveEvent, phase, window, app| { + if !phase.bubble() { + return; + } + let leaving = dragging.update(app, |shell, cx| { + shell.pointer = event.position; + let mut moved = false; + if let Some((slot, from, width)) = shell.resizing { + shell.set_column_width( + slot, + width + f32::from(event.position.x) - from, + ); + moved = true; + } + moved |= shell.drag_band(event.position); + moved |= shell.wheel.is_some(); + if moved { + cx.notify(); + } + shell.carrying && shell.left_the_list(event.position) + }); + // Out of the list is out of the archive. Started here + // and not at the press, because the instant the native + // drag begins the system takes the pointer and there is + // no way back into the list to drop on a folder. + if leaving { + app.stop_active_drag(window); + dragging.update(app, |shell, cx| { + shell.carrying = false; + shell.controller.drag_out(); + cx.notify(); + }); + } + }, + ); + let pressed = view.clone(); + window.on_mouse_event(move |event: &gpui::MouseDownEvent, phase, _, app| { + if !phase.bubble() { + return; + } + pressed.update(app, |shell, cx| { + match event.button { + // Pressing the wheel again puts it away, the way + // it does in a browser. + gpui::MouseButton::Middle => { + shell.toggle_wheel(event.position); + cx.notify(); + } + // Any other button is somebody asking for + // something else. + _ if shell.wheel.is_some() => { + shell.wheel = None; + cx.notify(); + } + gpui::MouseButton::Left => { + shell.begin_band( + event.position, + event.modifiers.secondary(), + event.modifiers.shift, + ); + } + _ => {} + } + }); + }); + let released = view.clone(); + window.on_mouse_event(move |event: &gpui::MouseUpEvent, phase, _, app| { + if !phase.bubble() { + return; + } + released.update(app, |shell, cx| { + let mut changed = shell.band.take().is_some_and(|band| band.live); + shell.carrying = false; + if shell.resizing.take().is_some() { + // Written when the hand lets go rather than on + // the way, so pulling an edge across the window + // is one visit to the disk and not one a frame. + shell.controller.state.settings.save(); + changed = true; + } + // Held down and pulled: the gesture ends where the + // hand lets go. Let go without having pulled and it + // stays on, waiting. + if event.button == gpui::MouseButton::Middle + && shell.wheel.as_ref().is_some_and(|wheel| wheel.moved) + { + shell.wheel = None; + changed = true; + } + if changed { + cx.notify(); + } + }); + }); + // The wheel turning is somebody scrolling by hand, which is + // asking for something other than the list running itself. + let spun = view.clone(); + window.on_mouse_event(move |_: &gpui::ScrollWheelEvent, phase, _, app| { + if !phase.bubble() { + return; + } + spun.update(app, |shell, cx| { + if shell.wheel.take().is_some() { + cx.notify(); + } + }); + }); + }, + ) + .size_0() + }; + root = root + .child(drop_probe) + .child( + div() + .id("drop-trigger-focus") + .track_focus(&self.drop_trigger_focus) + .size_0(), + ) + .child( + div() + .id("conflict-trigger-focus") + .track_focus(&self.conflict_trigger_focus) + .size_0(), + ) + .child( + div() + .id("add-start-focus") + .track_focus(&self.add_start_focus) + .size_0(), + ); + + if !self.drop_paths.is_empty() && self.background_idle() { + let count = self.drop_paths.len(); + root = root.child( + div() + .id("drop-feedback") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(cx.theme().drop_target) + .border_2() + .border_color(cx.theme().drag_border) + .rounded(cx.theme().radius_lg) + .role(Role::Status) + .aria_label(format!("{} ({count})", s.dropped_word)) + .child(format!("{} ({count})", s.dropped_word)), + ); + } + + if self.controller.state.busy { + use std::sync::atomic::Ordering; + let total = self.controller.state.total_count; + let done = self.controller.state.done_count; + let fraction = if total == 0 { + 0.0 + } else { + done as f64 / total as f64 + }; + let progress = match (total, self.controller.state.in_bytes) { + (0, _) => format!("{}…", s.working_word), + // A download counts bytes, not files, and nobody reads + // "2481152 of 4627170". + (total, true) => format!("{} / {}", human(done as u64), human(total as u64)), + (total, false) => format!("{done} / {total}"), + }; + let held = self.controller.state.hold.load(Ordering::Relaxed); + let asked = self.controller.state.stop.load(Ordering::Relaxed); + // The clock, and the guess of what is left made from how long the + // part already done took. Only once enough of it is done for the + // guess to be worth reading: at two per cent it would say an hour + // and then a minute. A paused job is not going anywhere. + let mut timing = self + .controller + .state + .started + .map(|started| { + let gone = started.elapsed().as_secs_f64(); + let mut text = format!("{} {}", s.elapsed_word, super::clock(gone)); + if !held && fraction > 0.05 { + text.push_str(&format!( + " · {} {}", + s.time_left, + super::clock(gone / fraction - gone) + )); + } + text + }) + .unwrap_or_default(); + if asked { + timing.push_str(&format!(" · {}", s.stopping)); + } else if held { + timing.push_str(&format!(" · {}", s.paused_word)); + } + // Pausing lets go at the end of an entry, not the end of a byte, so + // a file that has started still has to finish. + let hold_button = Self::button( + "pause-job", + if held { s.resume_word } else { s.pause_word }, + if held { s.resume_word } else { s.pause_word }.to_string(), + !self.background_blocked() && !asked, + cx, + ) + .on_click(cx.listener(move |this, _, _, cx| { + if !this.background_blocked() { + this.controller.state.hold.store(!held, Ordering::Relaxed); + cx.notify(); + } + })); + let cancel = Self::button( + "cancel-job", + s.cancel, + format!("{} · {}", s.cancel, s.progress_region), + !self.background_blocked() && !asked, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + if !this.background_blocked() { + this.controller.dispatch(AppAction::CancelJob); + cx.notify(); + } + })); + // A strip across the top of the content pane rather than another + // floating row: work happening to the archive belongs above the + // archive, and it must not shove the list down a line when it + // appears. + let mut progress_view = div() + .id("progress") + .aria_label(s.progress_region) + .aria_value(progress.clone()) + .flex_none() + .h(px(30.)) + .px_2() + .flex() + .items_center() + .gap_2() + .text_xs() + .bg(cx.theme().secondary) + .border_b_1() + .border_color(cx.theme().border) + .child(progress) + .child( + div() + .flex_1() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(self.controller.state.current_file.clone()), + ) + .child( + div() + .flex_none() + .text_color(cx.theme().muted_foreground) + .child(timing), + ) + .child(hold_button) + .child(cancel); + if !self.background_blocked() { + progress_view = progress_view.role(Role::Status); + } + content = content.child(progress_view); + } + + if self.controller.state.busy && self.controller.state.entries.is_empty() { + content = content.child({ + let mut loading = div() + .id("loading-state") + .aria_label(s.opening) + .flex_1() + .flex() + .items_center() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child(format!("{}…", s.opening)); + if !self.background_blocked() { + loading = loading.role(Role::Status); + } + loading + }); + } else if !has_archive { + content = content.child({ + let mut empty = div() + .id("empty-state") + .aria_label(if self.controller.state.error { + s.cannot_open + } else { + s.drop_here + }) + .flex_1() + .flex() + .items_center() + .justify_center() + .text_color(if self.controller.state.error { + cx.theme().danger + } else { + cx.theme().muted_foreground + }) + .child(if self.controller.state.error { + s.cannot_open + } else { + s.drop_here + }); + if !self.background_blocked() { + empty = empty.role(Role::Region); + } + empty + }); + } else if visible == 0 { + let message = if self.controller.state.error { + s.cannot_open + } else if self.controller.state.filter.trim().is_empty() { + if self.controller.state.entries.is_empty() { + s.empty_archive + } else { + s.empty_folder + } + } else { + s.no_matches + }; + content = content.child({ + let mut empty = div() + .id("empty-state") + .aria_label(empty_state_aria_label( + self.controller.state.error, + &self.controller.state.filter, + s, + )) + .flex_1() + .flex() + .items_center() + .justify_center() + .text_color(if self.controller.state.error { + cx.theme().danger + } else { + cx.theme().muted_foreground + }) + .child(message); + if !self.background_blocked() { + empty = empty.role(Role::Region); + } + empty + }); + } else { + content = content.child(self.file_table(rows, cx)); + } + + // Sidebar beside content, and only once there is an archive: an empty + // folder pane next to an empty file list is two ways of saying nothing. + let mut body = div() + .id("archive-body") + .flex_1() + .min_h(px(1.)) + .flex() + .flex_row(); + if has_archive { + let sidebar = self.sidebar(window, cx); + body = body.child(sidebar); + } + root = root.child(body.child(content)).child( + StatusBar::new() + .left(status_view) + .right( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{visible} {}", s.visible_of)) + .child(Separator::vertical().h(px(10.))) + .child(format!("{selected} {}", s.checked)), + ) + .border_t_1() + .border_color(cx.theme().border), + ); + + let background = root; + let mut root = div() + .id("arca-gpui-shell") + .role(Role::Application) + .aria_label("Arca") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::focus_filter)) + .on_action(cx.listener(Self::copy_files_action)) + .on_action(cx.listener(Self::cut_files_action)) + .on_action(cx.listener(Self::paste_files_action)) + .on_key_down(cx.listener(Self::global_key_down)) + .size_full() + .relative() + .flex() + .flex_col() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child(background); + + if self.overflow_open && !self.background_blocked() { + let menu_enabled = self.menu_enabled(); + let has = has_archive && menu_enabled; + // Floating under the button that opened it, instead of being laid + // out in the column and shoving the whole window down half a page, + // which is what a menu in the flow did. + let mut menu = div() + .id("overflow-menu") + .role(Role::Menu) + .aria_label(s.more_word) + .absolute() + // Under the button that opened it. Measured from the top of the + // window, so the title bar counts. + .top(TITLE_BAR_HEIGHT + px(38.)) + .right(px(232.)) + .w(px(210.)) + .max_h(px(420.)) + .overflow_y_scroll() + .flex() + .flex_col() + .gap_px() + .p_1() + .bg(cx.theme().popover) + .text_color(cx.theme().popover_foreground) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius_lg) + .shadow_lg() + .track_focus(&self.overflow_menu_focus) + .tab_group() + .focus_visible(focus_ring(cx)) + .on_key_down(cx.listener(Self::overflow_key_down)); + let test_focus = self.overflow_item_focus[0].clone().tab_stop(has); + let test = Self::menu_item( + "test", + s.test_word, + "Ctrl+T", + s.test_word.to_string(), + has, + cx, + ) + .track_focus(&test_focus); + menu = menu.child(test.on_click(cx.listener(|this, _, _, cx| { + if this.menu_enabled() { + if let Some(archive) = this.controller.state.archive.clone() { + this.controller.dispatch(AppAction::Run(Job::Test { + archive, + only: None, + })); + } + } + this.overflow_open = false; + cx.notify(); + }))); + let select_focus = self.overflow_item_focus[1].clone().tab_stop(has); + let select = Self::menu_item( + "select-all", + s.select_all, + "Ctrl+A", + s.select_all.to_string(), + has, + cx, + ) + .track_focus(&select_focus); + menu = menu.child(select.on_click(cx.listener(|this, _, _, cx| { + if this.menu_enabled() && this.controller.state.archive.is_some() { + this.controller.dispatch(AppAction::SelectAllVisible); + } + this.overflow_open = false; + cx.notify(); + }))); + let invert_focus = self.overflow_item_focus[2].clone().tab_stop(has); + let invert = Self::menu_item( + "invert", + s.invert_selection, + "Ctrl+I", + s.invert_selection.to_string(), + has, + cx, + ) + .track_focus(&invert_focus); + menu = menu.child(invert.on_click(cx.listener(|this, _, _, cx| { + if this.menu_enabled() && this.controller.state.archive.is_some() { + this.controller.dispatch(AppAction::InvertVisible); + } + this.overflow_open = false; + cx.notify(); + }))); + let clear_focus = self.overflow_item_focus[3].clone().tab_stop(has); + let clear = Self::menu_item( + "clear", + s.clear_selection, + "Esc", + s.clear_selection.to_string(), + has, + cx, + ) + .track_focus(&clear_focus); + menu = menu.child(clear.on_click(cx.listener(|this, _, _, cx| { + if this.menu_enabled() && this.controller.state.archive.is_some() { + this.controller.dispatch(AppAction::ClearSelection); + } + this.overflow_open = false; + cx.notify(); + }))); + let copy_enabled = self.can_copy_files(); + let copy_focus = self.overflow_item_focus[4].clone().tab_stop(copy_enabled); + let copy = Self::menu_item( + "copy-files", + s.copy_word, + "Ctrl+C", + s.copy_word.to_string(), + copy_enabled, + cx, + ) + .aria_keyshortcuts("Control+C Meta+C") + .track_focus(©_focus); + menu = menu.child(copy.on_click(cx.listener(|this, _, window, cx| { + if this.can_copy_files() { + this.dispatch_clipboard(false, window, cx); + } + this.overflow_open = false; + cx.notify(); + }))); + + let cut_focus = self.overflow_item_focus[5].clone().tab_stop(copy_enabled); + let cut = Self::menu_item( + "cut-files", + s.cut_word, + "Ctrl+X", + s.cut_word.to_string(), + copy_enabled, + cx, + ) + .aria_keyshortcuts("Control+X Meta+X") + .track_focus(&cut_focus); + menu = menu.child(cut.on_click(cx.listener(|this, _, window, cx| { + if this.can_copy_files() { + this.dispatch_clipboard(true, window, cx); + } + this.overflow_open = false; + cx.notify(); + }))); + + let paste_enabled = self.can_paste_files(); + let paste_focus = self.overflow_item_focus[6].clone().tab_stop(paste_enabled); + let paste = Self::menu_item( + "paste-files", + s.paste_word, + "Ctrl+V", + s.paste_word.to_string(), + paste_enabled, + cx, + ) + .aria_keyshortcuts("Control+V Meta+V") + .track_focus(&paste_focus); + menu = menu.child(paste.on_click(cx.listener(|this, _, window, cx| { + if this.can_paste_files() { + this.dispatch_paste(window, cx); + } + this.overflow_open = false; + cx.notify(); + }))); + + // Only when there is one, and at the top, where something that was + // not there yesterday belongs. + if let Some(release) = self.controller.state.update.clone() { + let release_focus = self.overflow_item_focus[RELEASE_SLOT] + .clone() + .tab_stop(menu_enabled); + let label = fill(s.update_ready, &[("version", &release.tag)]); + let item = Self::menu_item("release", label.clone(), "", label, menu_enabled, cx) + .track_focus(&release_focus); + menu = menu.child(item.on_click(cx.listener(|this, _, _, cx| { + if this.menu_enabled() { + this.overflow_action(OverflowAction::Release, cx); + } + this.overflow_open = false; + cx.notify(); + }))); + } + + let writable = has && self.controller.state.format == super::Format::Zip; + let can_undo = has && self.controller.state.undo.is_some(); + for (slot, id, label, keys, enabled, action) in [ + ( + 7usize, + "add-files", + s.add_to_archive, + "", + writable, + OverflowAction::AddFiles, + ), + ( + 8, + "new-folder", + s.new_folder, + "", + writable, + OverflowAction::NewFolder, + ), + ( + 9, + "undo", + s.undo_word, + "Ctrl+Z", + can_undo, + OverflowAction::Undo, + ), + ( + 10, + "save-copy", + s.save_copy, + "", + has, + OverflowAction::SaveCopy, + ), + ( + 11, + "default-password", + s.default_password, + "Ctrl+P", + menu_enabled, + OverflowAction::DefaultPassword, + ), + ] { + let item_focus = self.overflow_item_focus[slot].clone().tab_stop(enabled); + let item = Self::menu_item(id, label, keys, label.to_string(), enabled, cx) + .track_focus(&item_focus); + menu = menu.child(item.on_click(cx.listener(move |this, _, _, cx| { + if this.menu_enabled() { + this.overflow_action(action, cx); + } + this.overflow_open = false; + cx.notify(); + }))); + } + + // The archives opened lately, newest first, by path rather than by + // name so that two called the same thing are told apart. + let recent: Vec = self + .controller + .state + .settings + .recent + .iter() + .take(RECENT_MAX) + .cloned() + .collect(); + for (position, path) in recent.iter().enumerate() { + let leaf = std::path::Path::new(path) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| path.clone()); + let item_focus = self.overflow_item_focus[RECENT_SLOT + position] + .clone() + .tab_stop(menu_enabled); + let target = PathBuf::from(path); + let item = Self::menu_item( + ("recent", position), + leaf, + "", + format!("{} {path}", s.recent_word), + menu_enabled, + cx, + ) + .track_focus(&item_focus); + menu = menu.child(item.on_click(cx.listener(move |this, _, _, cx| { + if this.menu_enabled() { + this.controller.dispatch(AppAction::Open(target.clone())); + } + this.overflow_open = false; + cx.notify(); + }))); + } + let clear_history_enabled = menu_enabled && !recent.is_empty(); + let clear_history_focus = self.overflow_item_focus[RECENT_SLOT + RECENT_MAX] + .clone() + .tab_stop(clear_history_enabled); + let clear_history = Self::menu_item( + "clear-history", + s.clear_history, + "", + s.clear_history.to_string(), + clear_history_enabled, + cx, + ) + .track_focus(&clear_history_focus); + menu = menu.child(clear_history.on_click(cx.listener(|this, _, _, cx| { + if this.menu_enabled() { + this.controller.state.settings.recent.clear(); + this.controller.state.settings.save(); + } + this.overflow_open = false; + cx.notify(); + }))); + + let flat = self.controller.state.settings.flat; + let flat_focus = self.overflow_item_focus[RECENT_SLOT + RECENT_MAX + 1] + .clone() + .tab_stop(has); + let flat_item = Self::menu_item( + "flat-view", + if flat { + format!("{} ✓", s.flat_view) + } else { + s.flat_view.to_string() + }, + "", + s.flat_view.to_string(), + has, + cx, + ) + .aria_selected(flat) + .track_focus(&flat_focus); + menu = menu.child(flat_item.on_click(cx.listener(|this, _, _, cx| { + if this.menu_enabled() && this.controller.state.archive.is_some() { + let settings = &mut this.controller.state.settings; + settings.flat = !settings.flat; + // A flat list is a list of names with no folder over them, + // so the folder each one came from has to go somewhere. It + // is left on afterwards: turning the view off and on again + // should not keep undoing a column since arranged by hand. + if settings.flat && !settings.columns.on(SortColumn::Path) { + settings.columns.set(SortColumn::Path, true); + } + settings.save(); + this.controller.dispatch(AppAction::ClearSelection); + } + this.overflow_open = false; + cx.notify(); + }))); + + let shortcuts_focus = self.overflow_item_focus[RECENT_SLOT + RECENT_MAX + 2] + .clone() + .tab_stop(menu_enabled); + let shortcuts_item = Self::menu_item( + "shortcuts", + s.shortcuts_title, + "F1", + s.shortcuts_title.to_string(), + menu_enabled, + cx, + ) + .aria_keyshortcuts("F1") + .track_focus(&shortcuts_focus); + menu = menu.child(shortcuts_item.on_click(cx.listener(|this, _, _, cx| { + if this.menu_enabled() { + this.controller.state.show_shortcuts = true; + } + this.overflow_open = false; + cx.notify(); + }))); + + let settings_focus = self.overflow_item_focus[RECENT_SLOT + RECENT_MAX + 3] + .clone() + .tab_stop(menu_enabled); + let settings_item = Self::menu_item( + "settings", + s.settings, + "", + s.settings.to_string(), + menu_enabled, + cx, + ) + .track_focus(&settings_focus); + menu = menu.child(settings_item.on_click(cx.listener(|this, _, _, cx| { + if this.menu_enabled() { + this.controller.state.show_settings = true; + } + this.overflow_open = false; + cx.notify(); + }))); + + let columns_available = menu_enabled; + for (position, (column, _)) in Columns::ALL.iter().enumerate() { + let column = *column; + let label = Columns::label(column, s); + let shown = self.controller.state.settings.columns.on(column); + let action = if shown { s.hide_word } else { s.show_word }; + let item_focus = self.overflow_item_focus[position + RECENT_SLOT + RECENT_MAX + 4] + .clone() + .tab_stop(columns_available); + let item = Self::menu_item( + ("column", position), + format!("{action} {label}"), + "", + format!("{action} {label}"), + columns_available, + cx, + ) + .track_focus(&item_focus); + menu = menu.child(item.on_click(cx.listener(move |this, _, _, cx| { + if this.menu_enabled() { + this.controller.dispatch(AppAction::ToggleColumn(column)); + } + this.overflow_open = false; + cx.notify(); + }))); + } + root = root.child(menu); + } + // The band, and the anchor the wheel dropped. Both are drawn over + // everything rather than inside the list, because the hand is free to + // wander off it while either gesture runs and a mark that vanished at + // the edge would be worse than no mark at all. + if let Some(band) = self.band.as_ref().filter(|band| band.live) { + if let Some(view) = self.list_view(visible) { + let (x0, x1) = minmax(band.origin.x, band.head.x); + let (y0, y1) = minmax(band.origin.y, band.head.y); + let top = y0.max(view.top); + let bottom = y1.min(view.bottom); + if bottom > top { + // A quarter of the selection ink, so the rows underneath + // stay readable while they are being swept: the band says + // what it is reaching, and a solid one would hide it. + // Nothing is occluded either, because the pointer has to + // keep being followed through it. + let mut fill = cx.theme().selection; + fill.a *= 0.25; + root = root.child( + div() + .id("selection-band") + .absolute() + .left(px(x0)) + .top(px(top)) + .w(px((x1 - x0).max(1.0))) + .h(px(bottom - top)) + .bg(fill) + .border_1() + .border_color(cx.theme().ring), + ); + } + } + } + if let Some(wheel) = &self.wheel { + // A ring with a dot in it, left where the wheel went down: the mark + // Windows leaves, so it reads as the same gesture rather than as + // one of ours. + root = root.child( + div() + .id("wheel-anchor") + .absolute() + .left(px(f32::from(wheel.anchor.x) - 10.0)) + .top(px(f32::from(wheel.anchor.y) - 10.0)) + .size(px(20.)) + .rounded_full() + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().muted_foreground) + .flex() + .items_center() + .justify_center() + .child( + div() + .size(px(3.)) + .rounded_full() + .bg(cx.theme().muted_foreground), + ), + ); + } + if let Some((index, at)) = self.row_menu { + root = root.child(self.row_menu_view(index, at, cx)); + } + if let Some(dialog) = self.dialogs(cx) { + root = root.child(dialog); + } + root + } +} + +/// The overflow entries that write to the archive, as a value rather than a +/// closure: the menu is built while the shell is still borrowed. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum OverflowAction { + Release, + AddFiles, + NewFolder, + Undo, + SaveCopy, + DefaultPassword, +} + +/// A selection being drawn by pulling across the list. +struct Band { + /// Where the button went down, in window coordinates. + origin: gpui::Point, + /// The row it went down on. Two row numbers rather than a rectangle: the + /// list moves underneath while the drag happens, and a rectangle frozen + /// where the button went down stops meaning anything the moment it does. + anchor: usize, + /// What was picked before it started, so that Ctrl adds to a selection and + /// a plain drag replaces one. + base: Vec, + /// Where the pointer is now, which is the other end of the band. + head: gpui::Point, + /// The list as it was when the band began. + /// + /// Frozen for the length of the gesture rather than asked for on every + /// pointer move: what the band picks cannot change the list it is picking + /// from, and nothing else can change it either while a button is down. + rows: Vec, + /// Whether it has been pulled far enough to be a gesture rather than a + /// click. A click is a drag of no distance, and under this it is left + /// alone so clicking a row still means clicking a row. + live: bool, +} + +/// The anchor the wheel dropped, and whether the pointer has pulled away from +/// it yet. Letting the wheel go after it has ends the gesture; letting it go +/// before leaves it running until the next click, which is what makes +/// press-and-drag and click-and-go both work off the one button. +struct WheelPan { + anchor: gpui::Point, + moved: bool, +} + +/// Where the list is on screen, how tall a row is and how far down it is +/// scrolled: everything the two pointer gestures need, read off the one scroll +/// handle rather than measured again. +struct ListView { + top: f32, + bottom: f32, + left: f32, + right: f32, + row: f32, + /// How far down the list is. GPUI keeps this as a negative offset; it is + /// turned the right way up here so the arithmetic below reads like the + /// list does. + offset: f32, + reach: f32, +} + +/// How far a click may travel and still be a click. +/// +/// Further than GPUI waits before calling a drag a drag, on purpose: a band +/// that appeared first would flash over the rows for the pixel or two between +/// the two thresholds every time a column was resized. +const DRAG_SLOP: f32 = 10.0; + +/// The selection while it is in the air. An empty marker rather than the rows +/// themselves: what is carried is whatever is picked when it lands, and the +/// selection cannot change while the button is down. +struct DraggedRows; + +/// What a key press means to the window, as opposed to the list. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Shortcut { + Open, + Compress, + ExtractAll, + ExtractHere, + Test, + Refresh, + Invert, + ClearSelection, + CopyNames, + Shortcuts, + Undo, + DefaultPassword, + Rename, + View, + /// A mask that picks names, or one that drops them. + PickGroup(bool), +} + +/// Reading a key press, with no state and no side effects, so the table of +/// shortcuts can be checked without a window. +/// +/// `typing` only silences the keys that carry no modifier: F5 inside a filter +/// box is a key, but Ctrl+O is never text. +fn shortcut_for( + secondary: bool, + shift: bool, + alt: bool, + key: &str, + typing: bool, +) -> Option { + if secondary && shift { + return (key == "c").then_some(Shortcut::CopyNames); + } + if secondary { + return match key { + "o" => Some(Shortcut::Open), + "n" => Some(Shortcut::Compress), + "e" => Some(Shortcut::ExtractAll), + "t" => Some(Shortcut::Test), + "i" => Some(Shortcut::Invert), + "z" => Some(Shortcut::Undo), + "p" => Some(Shortcut::DefaultPassword), + _ => None, + }; + } + if alt { + return (key == "w").then_some(Shortcut::ExtractHere); + } + if typing { + return None; + } + match key { + "f1" => Some(Shortcut::Shortcuts), + "f2" => Some(Shortcut::Rename), + "f3" => Some(Shortcut::View), + "f5" => Some(Shortcut::Refresh), + "escape" => Some(Shortcut::ClearSelection), + // The keypad plus and minus, where WinRAR has kept picking a group by + // name since before there were menus to put it in. Its third one, the + // keypad star for inverting, cannot be told from any other asterisk by + // a toolkit, so that one stays on Ctrl+I alone. + "+" | "plus" | "add" => Some(Shortcut::PickGroup(true)), + "-" | "minus" | "subtract" => Some(Shortcut::PickGroup(false)), + _ => None, + } +} + +/// How tall one row is, from the height of everything and how many there are. +/// +/// Not `last_item_size.item`, whatever that name suggests: that field holds the +/// size of the whole viewport. Reading it as a row height divided by four +/// hundred instead of by twenty-eight, which put every pointer on the first row +/// and left the band picking nothing. +fn row_height(contents: f32, count: usize) -> Option { + if count == 0 || contents <= 0.0 { + return None; + } + let row = contents / count as f32; + (row > 0.0).then_some(row) +} + +/// The two ends of a span, in the order they are drawn in. +fn minmax(a: gpui::Pixels, b: gpui::Pixels) -> (f32, f32) { + let (a, b) = (f32::from(a), f32::from(b)); + if a <= b { + (a, b) + } else { + (b, a) + } +} + +fn visible_menu_items(items: &[T], rendered_len: usize) -> &[T] { + &items[..items.len().min(rendered_len)] +} + +fn focus_cycle_index(current: Option, reverse: bool, len: usize) -> Option { + if len == 0 { + return None; + } + match (current, reverse) { + (None, false) => Some(0), + (None, true) => Some(len - 1), + (Some(index), false) => Some((index + 1) % len), + (Some(index), true) => Some(if index == 0 { len - 1 } else { index - 1 }), + } +} + +fn menu_target(current: usize, key: &str, len: usize) -> Option { + match key { + "down" if current + 1 < len => Some(current + 1), + "up" if current > 0 => Some(current - 1), + _ => None, + } +} + +fn background_event_allowed(modal: bool, native_picker: bool) -> bool { + !modal && !native_picker +} + +fn clipboard_action_allowed( + available: bool, + idle: bool, + has_archive: bool, + selected: usize, + needs_selection: bool, +) -> bool { + available && idle && has_archive && (!needs_selection || selected > 0) +} + +fn drop_paths_for_enter(paths: &[PathBuf], allowed: bool) -> Vec { + if allowed { + paths.to_vec() + } else { + Vec::new() + } +} + +fn empty_state_aria_label(error: bool, filter: &str, s: &'static Strings) -> &'static str { + if error { + s.cannot_open + } else if filter.trim().is_empty() { + s.empty_folder + } else { + s.no_matches + } +} + +fn apply_startup(controller: &mut AppController, startup: Startup) { + match startup { + Startup::Browse(Some(path)) => controller.open(path), + Startup::Browse(None) => {} + Startup::Run(job) => controller.run_job(job), + Startup::Add(files) => controller.dispatch(AppAction::PrepareCompress(files)), + } +} + +fn shell_size(compact: bool) -> (f32, f32) { + if compact { + COMPACT_SIZE + } else { + NORMAL_SIZE + } +} + +pub(crate) fn run() { + let startup = super::parse_args(); + let compact = !matches!(startup, Startup::Browse(_)); + let (width, height) = shell_size(compact); + + // The kit's icons are SVG assets, not glyphs; without an asset source every + // `IconName` resolves to nothing and the toolbar renders blank. + application() + .with_assets(gpui_kit_assets::Assets) + .run(move |cx: &mut App| { + gpui_theme::init(cx); + cx.bind_keys([ + KeyBinding::new("ctrl-f", FocusFilter, None), + KeyBinding::new("cmd-f", FocusFilter, None), + KeyBinding::new("ctrl-c", CopyFiles, None), + KeyBinding::new("cmd-c", CopyFiles, None), + KeyBinding::new("ctrl-x", CutFiles, None), + KeyBinding::new("cmd-x", CutFiles, None), + KeyBinding::new("ctrl-v", PasteFiles, None), + KeyBinding::new("cmd-v", PasteFiles, None), + KeyBinding::new("backspace", Backspace, Some("FilterInput")), + KeyBinding::new("ctrl-a", SelectAll, Some("FilterInput")), + KeyBinding::new("cmd-a", SelectAll, Some("FilterInput")), + ]); + let bounds = Bounds::centered(None, size(px(width), px(height)), cx); + let window = cx + .open_window( + // The bar across the top is Arca's, not the system's: the + // kit's options make the native caption transparent and + // hand the dragging, the double click and the three window + // buttons to `TitleBar`, which is drawn from the same + // tokens as everything below it. + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(MINIMUM_SIZE.0), px(MINIMUM_SIZE.1))), + ..TitleBar::window_options() + }, + |window, cx| cx.new(|cx| GpuiShell::new(window, cx, startup)), + ) + .expect("open GPUI shell window"); + window + .update(cx, |shell, window, cx| { + let shell_focus = shell.focus_handle.clone(); + window.focus(&shell_focus, cx); + cx.activate(true); + window.set_window_title("Arca"); + }) + .expect("activate GPUI shell window"); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_keeps_compact_and_normal_startup_sizes() { + assert_eq!(shell_size(true), COMPACT_SIZE); + assert_eq!(shell_size(false), NORMAL_SIZE); + assert!(MINIMUM_SIZE.0 <= NORMAL_SIZE.0); + assert!(MINIMUM_SIZE.1 <= NORMAL_SIZE.1); + } + + #[test] + fn breadcrumbs_keep_root_and_nearest_folders() { + assert_eq!( + GpuiShell::visible_crumb_indices(4), + ((0..4).collect(), Vec::new()) + ); + assert_eq!( + GpuiShell::visible_crumb_indices(6), + (vec![0, 3, 4, 5], vec![1, 2]) + ); + } + + #[test] + fn breadcrumb_navigation_drops_stale_handles_when_route_shrinks() { + let route_with_several_hidden = GpuiShell::visible_crumb_indices(10).1.len(); + let route_with_fewer_hidden = GpuiShell::visible_crumb_indices(6).1.len(); + let handles: Vec<_> = (0..route_with_several_hidden).collect(); + let visible = visible_menu_items(&handles, route_with_fewer_hidden); + + assert_eq!(visible, &[0, 1]); + assert_eq!(menu_target(0, "down", visible.len()), Some(1)); + assert_eq!(menu_target(1, "up", visible.len()), Some(0)); + assert_eq!(menu_target(0, "up", visible.len()), None); + assert_eq!(menu_target(1, "down", visible.len()), None); + } + + #[test] + fn menu_navigation_stays_within_the_menu() { + assert_eq!(menu_target(0, "down", 4), Some(1)); + assert_eq!(menu_target(1, "up", 4), Some(0)); + assert_eq!(menu_target(0, "up", 4), None); + assert_eq!(menu_target(3, "down", 4), None); + } + + #[test] + fn focus_trap_wraps_in_both_directions_and_handles_no_focus() { + assert_eq!(focus_cycle_index(Some(0), false, 3), Some(1)); + assert_eq!(focus_cycle_index(Some(2), false, 3), Some(0)); + assert_eq!(focus_cycle_index(Some(0), true, 3), Some(2)); + assert_eq!(focus_cycle_index(Some(2), true, 3), Some(1)); + assert_eq!(focus_cycle_index(None, false, 3), Some(0)); + assert_eq!(focus_cycle_index(None, true, 3), Some(2)); + assert_eq!(focus_cycle_index(None, false, 0), None); + } + + #[test] + fn conflict_actions_answer_every_supported_choice() { + let answers = [ + Answer::Replace, + Answer::ReplaceAll, + Answer::Skip, + Answer::SkipAll, + Answer::Rename, + Answer::RenameAll, + Answer::Cancel, + ]; + for answer in answers { + let mut controller = AppController::new(Settings::default()); + let (tx, rx) = channel(); + controller.state.replies = Some(tx); + controller.state.conflict = Some("already-there.txt".into()); + controller.dispatch(AppAction::AnswerConflict(answer)); + assert_eq!(rx.recv().unwrap(), answer); + assert!(controller.state.conflict.is_none()); + } + } + + #[test] + fn background_events_are_blocked_by_gpui_modals_and_native_pickers() { + assert!(background_event_allowed(false, false)); + assert!(!background_event_allowed(true, false)); + assert!(!background_event_allowed(false, true)); + assert!(!background_event_allowed(true, true)); + } + + #[test] + fn clipboard_actions_require_capability_idle_archive_and_selection() { + assert!(clipboard_action_allowed(true, true, true, 1, true)); + assert!(!clipboard_action_allowed(false, true, true, 1, true)); + assert!(!clipboard_action_allowed(true, false, true, 1, true)); + assert!(!clipboard_action_allowed(true, true, false, 1, true)); + assert!(!clipboard_action_allowed(true, true, true, 0, true)); + assert!(clipboard_action_allowed(true, true, true, 0, false)); + assert!(!clipboard_action_allowed(true, true, false, 0, false)); + } + + #[test] + fn blocked_drop_enter_clears_pending_paths() { + let paths = vec![PathBuf::from("queued.zip")]; + assert_eq!(drop_paths_for_enter(&paths, true), paths); + assert!(drop_paths_for_enter(&paths, false).is_empty()); + } + + #[test] + fn the_row_height_comes_from_the_content_and_not_from_the_viewport() { + // A list showing fifty rows of twenty-eight pixels has fourteen hundred + // pixels of content and a viewport of whatever the window left it. The + // viewport is what `last_item_size.item` holds, so reading that as a + // row height divided by four hundred instead of by twenty-eight: every + // pointer landed on the first row and the band picked nothing. + assert_eq!(row_height(1400.0, 50), Some(28.0)); + assert_eq!(row_height(1400.0, 0), None, "an empty list has no rows"); + assert_eq!(row_height(0.0, 50), None, "nor has one not laid out yet"); + } + + #[test] + fn a_point_lands_on_the_row_that_is_drawn_under_it() { + // The list is virtualized, so which row a pointer is over is arithmetic + // on the scroll offset and not a rectangle anybody kept. Off by one row + // here and a band would pick everything one place along. + let view = ListView { + top: 100.0, + bottom: 360.0, + left: 0.0, + right: 800.0, + row: 26.0, + offset: 0.0, + reach: 1000.0, + }; + assert_eq!(GpuiShell::row_under(&view, 100.0, 50), Some(0)); + assert_eq!(GpuiShell::row_under(&view, 125.9, 50), Some(0)); + assert_eq!(GpuiShell::row_under(&view, 126.0, 50), Some(1)); + // Above the first row is no row at all, not the first one. + assert_eq!(GpuiShell::row_under(&view, 99.0, 50), None); + // And past the last there is nothing either, however far down it is. + assert_eq!(GpuiShell::row_under(&view, 100.0, 0), None); + assert_eq!(GpuiShell::row_under(&view, 100.0 + 26.0 * 3.0, 3), None); + + // Scrolled down by ten rows, the top of the list is row ten. + let scrolled = ListView { + offset: 260.0, + ..view + }; + assert_eq!(GpuiShell::row_under(&scrolled, 100.0, 50), Some(10)); + assert_eq!(GpuiShell::row_under(&scrolled, 126.0, 50), Some(11)); + } + + #[test] + fn every_column_reads_its_width_from_its_own_slot() { + // The widths vector is the one written to gui.conf: the name first, + // then `Columns::ALL` in order. A slot off by one would hand a column + // the width of its neighbour and the file would still load. + assert_eq!(GpuiShell::column_slot(SortColumn::Name), 0); + for (index, (column, _)) in Columns::ALL.iter().enumerate() { + assert_eq!(GpuiShell::column_slot(*column), index + 1); + } + let widths = Settings::default_widths(); + assert_eq!(widths.len(), Columns::ALL.len() + 1); + // A column cannot be pulled below what it needs to stay readable. + assert!(Settings::least(0) > Settings::least(1)); + } + + #[test] + fn the_row_menu_only_offers_the_clipboard_where_there_is_one() { + // A menu entry that can never do anything is worse than no entry, so + // copy, cut and paste are left out rather than greyed out. + let offered: Vec = RowAction::ALL.iter().map(|a| a.offered()).collect(); + for action in [RowAction::Copy, RowAction::Cut, RowAction::Paste] { + assert_eq!(offered[action as usize], clipboard::AVAILABLE); + } + assert!(offered[RowAction::Open as usize]); + assert!(offered[RowAction::Delete as usize]); + // Same index contract as the settings dialog: the focus vector is + // indexed by `action as usize`. + for (index, action) in RowAction::ALL.iter().enumerate() { + assert_eq!(*action as usize, index); + } + } + + #[test] + fn a_modifier_shortcut_still_works_while_a_text_field_has_the_keyboard() { + // Ctrl+O is never text, so it must not wait behind the filter box; F5 + // is a key a text field could want, so it must. + assert_eq!( + shortcut_for(true, false, false, "o", true), + Some(Shortcut::Open) + ); + assert_eq!( + shortcut_for(true, true, false, "c", true), + Some(Shortcut::CopyNames) + ); + assert_eq!( + shortcut_for(false, false, true, "w", true), + Some(Shortcut::ExtractHere) + ); + assert_eq!(shortcut_for(false, false, false, "f5", true), None); + assert_eq!(shortcut_for(false, false, false, "escape", true), None); + assert_eq!( + shortcut_for(false, false, false, "f5", false), + Some(Shortcut::Refresh) + ); + // Ctrl+Shift+C is the names as text, not the files. + assert_eq!(shortcut_for(true, true, false, "o", false), None); + assert_eq!(shortcut_for(false, false, false, "q", false), None); + // The keypad's plus picks a group and its minus drops one; both are + // bare keys, so both stand aside for a text field. + assert_eq!( + shortcut_for(false, false, false, "+", false), + Some(Shortcut::PickGroup(true)) + ); + assert_eq!( + shortcut_for(false, false, false, "minus", false), + Some(Shortcut::PickGroup(false)) + ); + assert_eq!(shortcut_for(false, false, false, "+", true), None); + assert_eq!( + shortcut_for(true, false, false, "z", false), + Some(Shortcut::Undo) + ); + assert_eq!( + shortcut_for(false, false, false, "f3", false), + Some(Shortcut::View) + ); + } + + #[test] + fn every_settings_control_has_its_own_slot_in_draw_order() { + // The dialog indexes `settings_focus` by `control as usize`, so a + // control added out of order would silently take another one's focus + // handle and Enter would activate the wrong preference. + for (index, control) in SettingsControl::ALL.iter().enumerate() { + assert_eq!(*control as usize, index); + } + } + + #[test] + fn empty_folder_label_is_not_reported_as_archive_contents() { + // Both languages, because the point of the label is that it says the + // folder is empty rather than that the archive is unreadable, and a + // translation that loses the difference is the same bug in Spanish. + for lang in [super::super::Lang::En, super::super::Lang::Es] { + let s = super::super::strings(lang); + assert_eq!(empty_state_aria_label(false, "", s), s.empty_folder); + assert_eq!(empty_state_aria_label(false, " ", s), s.empty_folder); + assert_eq!(empty_state_aria_label(false, "zip", s), s.no_matches); + assert_eq!(empty_state_aria_label(true, "", s), s.cannot_open); + assert_ne!(s.empty_folder, s.cannot_open); + } + } +} diff --git a/arca-gui/src/gpui_theme.rs b/arca-gui/src/gpui_theme.rs new file mode 100644 index 0000000..7ce0cf5 --- /dev/null +++ b/arca-gui/src/gpui_theme.rs @@ -0,0 +1,405 @@ +//! Arca's monochrome design tokens, written onto the GPUI Kit theme. +//! +//! Every colour in the GPUI window comes from here. `gpui-component` already +//! owns a full token vocabulary — `background`, `border`, `table_hover`, +//! `ring`, and a hundred more — and every component it ships reads them, so +//! Arca does not need a token layer of its own. What it needs is to *say what +//! those tokens are*, once, for light and for dark, and that is the whole file. +//! +//! The palette is monochrome by design, not by omission. There are six greys +//! and they are the same six greys inverted between the two modes. They are not +//! neutral: they carry a few percent of the night blue from `brand/BRAND.md` +//! (hue 225), which is the difference between a quiet window and a screenshot +//! of a terminal. +//! +//! There is no accent colour. Selection, the keyboard cursor and the focus ring +//! are all the *text* colour at different strengths, so contrast is guaranteed +//! by construction: anything that is legible as text is legible as a selection. +//! +//! The one exception is danger and warning. Those are not decoration; they are +//! the difference between "extracted" and "did not extract", and a user who +//! scans before reading has to be able to see it. They stay at the lowest +//! chroma that still reads as red or amber against both grounds, and they are +//! the only saturated pixels Arca paints. + +use crate::ThemePreference; +use gpui::{px, App, Hsla, Window}; +use gpui_component::theme::{Theme, ThemeMode}; + +/// Corners. Small enough to read as a finish rather than as a shape. +const RADIUS: f32 = 4.0; +const RADIUS_LG: f32 = 6.0; + +/// The body size of the window, and the slightly smaller size the columns of +/// numbers and dates are set in: a monospace face at the same size always looks +/// a size bigger. +const FONT_SIZE: f32 = 13.0; +const MONO_FONT_SIZE: f32 = 12.0; + +/// The six greys, plus the two states that are allowed to have a hue. +/// +/// `surface` sits on `background`, `raised` sits on `surface`. Three grounds is +/// as many as a window this size can tell apart; a fourth reads as noise. +struct Palette { + background: u32, + surface: u32, + raised: u32, + border: u32, + text: u32, + muted: u32, + danger: u32, + warning: u32, +} + +/// Night, with the brand hue held at about 10% saturation. +const DARK: Palette = Palette { + background: 0x0E1014, + surface: 0x14161B, + raised: 0x1B1E25, + border: 0x262A33, + text: 0xE4E6EB, + muted: 0x8B909C, + danger: 0xE5787C, + warning: 0xD9A441, +}; + +/// The same palette turned over. Not `Visuals::light()` inverted by formula: +/// a light window needs its steps closer together or the chrome starts to +/// stripe. +const LIGHT: Palette = Palette { + background: 0xFAFAFB, + surface: 0xFFFFFF, + raised: 0xF3F4F6, + border: 0xE3E5EA, + text: 0x14161B, + muted: 0x666B76, + danger: 0xB3262B, + warning: 0x8A5A00, +}; + +/// A hex literal as GPUI sees colours. +fn hex(value: u32) -> Hsla { + gpui::rgb(value).into() +} + +/// The same colour at a fraction of its opacity. +/// +/// This is what carries the whole monochrome scheme: a selected row is the text +/// colour at 12%, a hovered row is the text colour at 5%, and neither can drift +/// out of contrast with the text sitting on it because it *is* the text. +fn alpha(value: u32, a: f32) -> Hsla { + let mut color = hex(value); + color.a = a; + color +} + +/// Register GPUI Kit's theme machinery. Call once, before the first window. +pub fn init(cx: &mut App) { + gpui_component::init(cx); +} + +/// Resolve a stored preference against the desktop and paint the tokens. +/// +/// `System` asks the window first and the app second, because on Linux the +/// app-level answer is unreliable while a window exists. +pub fn apply(preference: ThemePreference, window: Option<&mut Window>, cx: &mut App) { + let mode = match preference { + ThemePreference::Light => ThemeMode::Light, + ThemePreference::Dark => ThemeMode::Dark, + ThemePreference::System => window + .as_ref() + .map(|window| window.appearance()) + .unwrap_or_else(|| cx.window_appearance()) + .into(), + }; + + // `change` resets every colour from the bundled theme config, so the + // palette has to go on afterwards, and `sync_base` has to go on after that + // or the scrollbar keeps painting with the colours it was last given. + Theme::change(mode, window, cx); + paint(mode, cx); + Theme::sync_base(cx); +} + +fn paint(mode: ThemeMode, cx: &mut App) { + let p = if mode.is_dark() { DARK } else { LIGHT }; + let theme = Theme::global_mut(cx); + + theme.radius = px(RADIUS); + theme.radius_lg = px(RADIUS_LG); + theme.font_size = px(FONT_SIZE); + theme.mono_font_size = px(MONO_FONT_SIZE); + theme.font_family = system_ui().into(); + theme.mono_font_family = system_mono().into(); + + // Grounds and ink. + theme.background = hex(p.background); + theme.foreground = hex(p.text); + theme.border = hex(p.border); + theme.muted = hex(p.raised); + theme.muted_foreground = hex(p.muted); + theme.transparent = alpha(p.background, 0.0); + + // Focus and selection: the text colour, weakened. No accent exists. + theme.ring = alpha(p.text, 0.70); + theme.selection = alpha(p.text, 0.18); + theme.caret = hex(p.text); + + // Primary is the inversion — ink where the window is ground. In a + // monochrome scheme that is the only way a button can be louder than the + // one beside it. + theme.primary = hex(p.text); + theme.primary_foreground = hex(p.background); + theme.primary_hover = alpha(p.text, 0.88); + theme.primary_active = alpha(p.text, 0.76); + + theme.secondary = hex(p.surface); + theme.secondary_foreground = hex(p.text); + theme.secondary_hover = hex(p.raised); + theme.secondary_active = hex(p.border); + + theme.button = hex(p.surface); + theme.button_foreground = hex(p.text); + theme.button_hover = hex(p.raised); + theme.button_active = hex(p.border); + theme.button_primary = theme.primary; + theme.button_primary_foreground = theme.primary_foreground; + theme.button_primary_hover = theme.primary_hover; + theme.button_primary_active = theme.primary_active; + theme.button_secondary = theme.secondary; + theme.button_secondary_foreground = theme.secondary_foreground; + theme.button_secondary_hover = theme.secondary_hover; + theme.button_secondary_active = theme.secondary_active; + + // `accent` is what a menu item or a list item turns when the pointer is on + // it. Weak on purpose: it has to be visible without competing with the + // selection, which is the same colour twice as strong. + theme.accent = alpha(p.text, 0.06); + theme.accent_foreground = hex(p.text); + + theme.popover = hex(p.surface); + theme.popover_foreground = hex(p.text); + theme.overlay = alpha(0x000000, 0.45); + + theme.input = hex(p.border); + + // The list of files. `table_row_border` is transparent because the rows are + // already separated by the alternating tint, and drawing both turns the + // list back into the spreadsheet it stopped being. + theme.table = hex(p.background); + theme.table_head = hex(p.surface); + theme.table_head_foreground = hex(p.muted); + theme.table_foot = hex(p.surface); + theme.table_foot_foreground = hex(p.muted); + theme.table_even = alpha(p.text, 0.02); + theme.table_hover = alpha(p.text, 0.05); + theme.table_active = alpha(p.text, 0.12); + theme.table_active_border = hex(p.text); + theme.table_row_border = alpha(p.text, 0.0); + + // `Theme::list` is the list *settings* struct, not a colour; the colour of + // the same name lives one level down and is only reachable spelled out. + theme.colors.list = hex(p.background); + theme.list_head = hex(p.surface); + theme.list_even = alpha(p.text, 0.02); + theme.list_hover = alpha(p.text, 0.05); + theme.list_active = alpha(p.text, 0.12); + theme.list_active_border = hex(p.text); + + theme.scrollbar = alpha(p.background, 0.0); + theme.scrollbar_thumb = alpha(p.text, 0.16); + theme.scrollbar_thumb_hover = alpha(p.text, 0.28); + + theme.title_bar = hex(p.surface); + theme.title_bar_border = hex(p.border); + theme.status_bar = hex(p.surface); + theme.status_bar_border = hex(p.border); + theme.window_border = hex(p.border); + + theme.sidebar = hex(p.surface); + theme.sidebar_foreground = hex(p.text); + theme.sidebar_border = hex(p.border); + theme.sidebar_accent = alpha(p.text, 0.06); + theme.sidebar_accent_foreground = hex(p.text); + theme.sidebar_primary = hex(p.text); + theme.sidebar_primary_foreground = hex(p.background); + + theme.tab = hex(p.surface); + theme.tab_bar = hex(p.surface); + theme.tab_bar_segmented = hex(p.raised); + theme.tab_foreground = hex(p.muted); + theme.tab_active = hex(p.background); + theme.tab_active_foreground = hex(p.text); + + theme.accordion = hex(p.surface); + theme.group_box = hex(p.surface); + theme.group_box_foreground = hex(p.text); + theme.description_list_label = hex(p.surface); + theme.description_list_label_foreground = hex(p.muted); + theme.skeleton = hex(p.raised); + theme.tiles = hex(p.surface); + + theme.switch = hex(p.border); + theme.switch_thumb = hex(p.surface); + theme.slider_bar = hex(p.border); + theme.slider_thumb = hex(p.text); + theme.progress_bar = hex(p.text); + + // A file dragged over the window. The border is the text colour so it reads + // at a glance; the fill is weak so the list underneath stays readable, + // because what is underneath is what you are about to drop onto. + theme.drag_border = hex(p.text); + theme.drop_target = alpha(p.text, 0.08); + + theme.link = hex(p.text); + theme.link_hover = hex(p.muted); + theme.link_active = hex(p.muted); + + // Neutral where the vocabulary demands a colour but Arca has nothing to + // say with one. + theme.info = hex(p.text); + theme.info_foreground = hex(p.background); + theme.info_hover = alpha(p.text, 0.88); + theme.info_active = alpha(p.text, 0.76); + theme.success = hex(p.text); + theme.success_foreground = hex(p.background); + theme.success_hover = alpha(p.text, 0.88); + theme.success_active = alpha(p.text, 0.76); + + // The two states that keep a hue. + theme.danger = hex(p.danger); + theme.danger_foreground = hex(0xFFFFFF); + theme.danger_hover = alpha(p.danger, 0.88); + theme.danger_active = alpha(p.danger, 0.76); + theme.button_danger = theme.danger; + theme.button_danger_foreground = theme.danger_foreground; + theme.button_danger_hover = theme.danger_hover; + theme.button_danger_active = theme.danger_active; + + theme.warning = hex(p.warning); + theme.warning_foreground = hex(p.background); + theme.warning_hover = alpha(p.warning, 0.88); + theme.warning_active = alpha(p.warning, 0.76); +} + +/// The letters the rest of the desktop is written in. +/// +/// A window sitting next to the Explorer that does not use the Explorer's +/// letters reads as foreign before you have looked at anything in it. Every +/// other platform gets the name GPUI resolves to the system UI face. +fn system_ui() -> &'static str { + if cfg!(windows) { + "Segoe UI" + } else { + ".SystemUIFont" + } +} + +fn system_mono() -> &'static str { + if cfg!(windows) { + "Consolas" + } else { + "monospace" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Relative luminance, WCAG 2.x. Only the sRGB channels are needed here, + /// so this stays a dozen lines rather than a colour-science dependency. + fn luminance(value: u32) -> f32 { + let channel = |shift: u32| { + let c = ((value >> shift) & 0xFF) as f32 / 255.0; + if c <= 0.03928 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0) + } + + fn contrast(a: u32, b: u32) -> f32 { + let (x, y) = (luminance(a), luminance(b)); + let (hi, lo) = if x > y { (x, y) } else { (y, x) }; + (hi + 0.05) / (lo + 0.05) + } + + /// The palette is the accessibility story: there is no accent to fall back + /// on, so if these ratios slip there is nothing else holding the window up. + #[test] + fn every_ground_carries_its_text() { + for (name, p) in [("dark", DARK), ("light", LIGHT)] { + for (ground_name, ground) in [ + ("background", p.background), + ("surface", p.surface), + ("raised", p.raised), + ] { + // WCAG AA for body text. + let body = contrast(p.text, ground); + assert!( + body >= 4.5, + "{name}: text on {ground_name} is {body:.2}:1, below AA" + ); + // AA for large/secondary text, which is all `muted` is used for. + let secondary = contrast(p.muted, ground); + assert!( + secondary >= 3.0, + "{name}: muted on {ground_name} is {secondary:.2}:1, below AA large" + ); + } + // A border nobody can see is not a border. + let edge = contrast(p.border, p.background); + assert!( + edge >= 1.2, + "{name}: border on background is {edge:.2}:1, invisible" + ); + // Danger has to be readable as text, not just present as a hue. + let danger = contrast(p.danger, p.background); + assert!( + danger >= 4.0, + "{name}: danger on background is {danger:.2}:1" + ); + } + } + + /// Two greys that are the same grey, and a mode that is not the other + /// mode's inverse. Both are copy-paste slips, both survive a compile, and + /// both are invisible in a diff of thirty hex literals. + /// + /// The step sizes are deliberately not asserted: elevation does not run the + /// same way in both modes — a light window raises a panel *towards* white + /// and tints a hover *away* from it — so any threshold here would be a + /// number tuned until it passed rather than a rule. + #[test] + fn the_palettes_are_distinct_and_opposed() { + for (name, p) in [("dark", DARK), ("light", LIGHT)] { + let greys = [ + ("background", p.background), + ("surface", p.surface), + ("raised", p.raised), + ("border", p.border), + ("text", p.text), + ("muted", p.muted), + ]; + for (i, (a_name, a)) in greys.iter().enumerate() { + for (b_name, b) in greys.iter().skip(i + 1) { + assert_ne!(a, b, "{name}: {a_name} and {b_name} are the same colour"); + } + } + } + + // Ink and ground swap places between the modes. If they ever stop + // doing that, one of the two windows has gone grey-on-grey. + assert!( + luminance(DARK.text) > luminance(DARK.background), + "dark: text is not lighter than its ground" + ); + assert!( + luminance(LIGHT.text) < luminance(LIGHT.background), + "light: text is not darker than its ground" + ); + } +} diff --git a/arca-gui/src/i18n.rs b/arca-gui/src/i18n.rs index a0be8f0..e96a4f6 100644 --- a/arca-gui/src/i18n.rs +++ b/arca-gui/src/i18n.rs @@ -5,8 +5,6 @@ pub enum Lang { } impl Lang { - pub const ALL: [Lang; 2] = [Lang::En, Lang::Es]; - pub fn code(self) -> &'static str { match self { Lang::En => "en", @@ -41,7 +39,6 @@ pub struct Strings { pub compress: &'static str, pub extract_all: &'static str, pub extract_selected: &'static str, - pub filter_hint: &'static str, pub format: &'static str, pub compressor: &'static str, pub level: &'static str, @@ -63,7 +60,6 @@ pub struct Strings { pub update_failed: &'static str, pub update_tampered: &'static str, pub update_installing: &'static str, - pub check_updates: &'static str, pub moving_word: &'static str, pub folder_name: &'static str, pub col_created: &'static str, @@ -71,7 +67,6 @@ pub struct Strings { pub col_attributes: &'static str, pub flat_view: &'static str, pub recent_word: &'static str, - pub folder_tree: &'static str, pub select_group: &'static str, pub deselect_group: &'static str, pub mask_hint: &'static str, @@ -97,11 +92,9 @@ pub struct Strings { pub paused_word: &'static str, pub stopped: &'static str, pub clear_history: &'static str, - pub columns_word: &'static str, pub open_word: &'static str, pub copy_names: &'static str, pub select_all: &'static str, - pub sort_hint: &'static str, pub drop_here: &'static str, pub language: &'static str, pub theme: &'static str, @@ -121,8 +114,6 @@ pub struct Strings { pub uncompressed_word: &'static str, pub in_archive: &'static str, pub saved_word: &'static str, - pub one_entry: &'static str, - pub entries_word: &'static str, pub start: &'static str, pub cancel: &'static str, pub close: &'static str, @@ -131,8 +122,6 @@ pub struct Strings { pub extracting: &'static str, pub compressing: &'static str, pub testing: &'static str, - pub done: &'static str, - pub failed: &'static str, pub extracted_to: &'static str, pub created: &'static str, pub verified_ok: &'static str, @@ -144,7 +133,6 @@ pub struct Strings { pub defaults_title: &'static str, pub items_word: &'static str, pub conflict_title: &'static str, - pub conflict_text: &'static str, pub already_there: &'static str, pub yes: &'static str, pub yes_all: &'static str, @@ -184,11 +172,15 @@ pub struct Strings { pub added: &'static str, pub clipboard_empty: &'static str, pub drop_title: &'static str, - pub drop_question: &'static str, pub dropped_word: &'static str, - pub drop_to_add: &'static str, pub password_word: &'static str, pub more_word: &'static str, + pub archive_group: &'static str, + pub selection_group: &'static str, + pub clipboard_group: &'static str, + pub recent_group: &'static str, + pub view_group: &'static str, + pub application_group: &'static str, pub test_word: &'static str, pub shortcuts_title: &'static str, pub invert_selection: &'static str, @@ -198,6 +190,25 @@ pub struct Strings { pub toggle_word: &'static str, pub jump_word: &'static str, pub move_word: &'static str, + // Names the GPUI surface reads out to a screen reader. Every landmark + // carries the active language, so English labels never leak into a Spanish + // window. + pub toolbar_region: &'static str, + pub hidden_folders: &'static str, + pub open_folder: &'static str, + pub status_region: &'static str, + pub progress_region: &'static str, + pub cannot_open: &'static str, + pub empty_archive: &'static str, + pub empty_folder: &'static str, + pub no_matches: &'static str, + pub archive_contents: &'static str, + pub ascending: &'static str, + pub descending: &'static str, + pub not_checked: &'static str, + pub show_word: &'static str, + pub hide_word: &'static str, + pub working_word: &'static str, } const EN: Strings = Strings { @@ -205,7 +216,6 @@ const EN: Strings = Strings { compress: "Compress…", extract_all: "Extract all", extract_selected: "Extract selection", - filter_hint: "Search...", format: "Format:", compressor: "Compressor:", level: "Level:", @@ -227,7 +237,6 @@ const EN: Strings = Strings { update_failed: "the new version could not be downloaded", update_tampered: "what arrived is not what the release says it is, so it has not been run", update_installing: "installing Arca {version}...", - check_updates: "Look for new versions on start", moving_word: "Moving inside the archive", folder_name: "Name for the new folder", col_created: "Created", @@ -235,7 +244,6 @@ const EN: Strings = Strings { col_attributes: "Attributes", flat_view: "Flat view", recent_word: "Recent", - folder_tree: "Folder tree", select_group: "Select group", deselect_group: "Deselect group", mask_hint: "Names to pick, with * and ? -- for example *.txt", @@ -261,11 +269,9 @@ const EN: Strings = Strings { paused_word: "paused", stopped: "stopped -- nothing was changed", clear_history: "Clear history", - columns_word: "Columns", open_word: "Open", copy_names: "Copy names", select_all: "Select all", - sort_hint: "Sort by this column", drop_here: "Drop a .zip, .tar or .tar.gz here", language: "Language:", theme: "Theme:", @@ -285,8 +291,6 @@ const EN: Strings = Strings { uncompressed_word: "uncompressed", in_archive: "in the archive", saved_word: "saved", - one_entry: "entry", - entries_word: "entries", start: "Start", cancel: "Cancel", close: "Close", @@ -295,8 +299,6 @@ const EN: Strings = Strings { extracting: "Extracting", compressing: "Compressing", testing: "Testing", - done: "Done", - failed: "Failed", extracted_to: "Extracted {size} into {dest}", created: "Created {name}: {from} to {to} ({pct} saved)", verified_ok: "{n} entries verified, no errors", @@ -308,7 +310,6 @@ const EN: Strings = Strings { defaults_title: "Defaults for new archives", items_word: "items", conflict_title: "File already exists", - conflict_text: "What do you want to do with it?", already_there: "Already in the destination:", yes: "Replace", yes_all: "Replace all", @@ -348,11 +349,15 @@ const EN: Strings = Strings { added: "{n} added", clipboard_empty: "there are no files on the clipboard", drop_title: "That is an archive too", - drop_question: "Open it instead, or put it inside {name}?", dropped_word: "Dropped:", - drop_to_add: "Let go to add it to {name}", password_word: "Password", more_word: "More", + archive_group: "Archive", + selection_group: "Selection", + clipboard_group: "Clipboard", + recent_group: "Recent", + view_group: "View", + application_group: "Application", test_word: "Test the archive", shortcuts_title: "Keyboard shortcuts", invert_selection: "Invert the selection", @@ -362,6 +367,22 @@ const EN: Strings = Strings { toggle_word: "Tick or untick", jump_word: "Jump to a name", move_word: "Move around the list", + toolbar_region: "Archive actions", + hidden_folders: "Hidden folders", + open_folder: "Open folder {name}", + status_region: "Archive status", + progress_region: "Operation progress", + cannot_open: "This archive could not be opened", + empty_archive: "This archive has no entries", + empty_folder: "This folder is empty", + no_matches: "Nothing matches the filter", + archive_contents: "Archive contents", + ascending: "ascending", + descending: "descending", + not_checked: "not checked", + show_word: "Show", + hide_word: "Hide", + working_word: "Working", }; const ES: Strings = Strings { @@ -369,7 +390,6 @@ const ES: Strings = Strings { compress: "Comprimir…", extract_all: "Extraer todo", extract_selected: "Extraer selección", - filter_hint: "Buscar...", format: "Formato:", compressor: "Compresor:", level: "Nivel:", @@ -391,7 +411,6 @@ const ES: Strings = Strings { update_failed: "no se ha podido bajar la version nueva", update_tampered: "lo que ha llegado no es lo que dice la release, asi que no se ha ejecutado", update_installing: "instalando Arca {version}...", - check_updates: "Buscar versiones nuevas al arrancar", moving_word: "Moviendo dentro del archivo", folder_name: "Nombre de la carpeta nueva", col_created: "Creado", @@ -399,7 +418,6 @@ const ES: Strings = Strings { col_attributes: "Atributos", flat_view: "Vista plana", recent_word: "Recientes", - folder_tree: "Arbol de carpetas", select_group: "Seleccionar grupo", deselect_group: "Quitar grupo", mask_hint: "Nombres a coger, con * y ? -- por ejemplo *.txt", @@ -425,11 +443,9 @@ const ES: Strings = Strings { paused_word: "en pausa", stopped: "parado -- no se ha cambiado nada", clear_history: "Borrar historial", - columns_word: "Columnas", open_word: "Abrir", copy_names: "Copiar nombres", select_all: "Seleccionar todo", - sort_hint: "Ordenar por esta columna", drop_here: "Arrastra aquí un .zip, .tar o .tar.gz", language: "Idioma:", theme: "Tema:", @@ -449,8 +465,6 @@ const ES: Strings = Strings { uncompressed_word: "sin comprimir", in_archive: "en el archivo", saved_word: "ahorrado", - one_entry: "entrada", - entries_word: "entradas", start: "Empezar", cancel: "Cancelar", close: "Cerrar", @@ -459,8 +473,6 @@ const ES: Strings = Strings { extracting: "Extrayendo", compressing: "Comprimiendo", testing: "Comprobando", - done: "Hecho", - failed: "Ha fallado", extracted_to: "Extraído {size} en {dest}", created: "Creado {name}: {from} a {to} ({pct} ahorrado)", verified_ok: "{n} entradas verificadas, sin errores", @@ -472,7 +484,6 @@ const ES: Strings = Strings { defaults_title: "Valores por defecto para archivos nuevos", items_word: "elementos", conflict_title: "El fichero ya existe", - conflict_text: "¿Qué quieres hacer con él?", already_there: "Ya está en el destino:", yes: "Reemplazar", yes_all: "Reemplazar todos", @@ -512,11 +523,15 @@ const ES: Strings = Strings { added: "{n} dentro", clipboard_empty: "no hay archivos en el portapapeles", drop_title: "Eso también es un comprimido", - drop_question: "¿Lo abro, o lo meto dentro de {name}?", dropped_word: "Soltado:", - drop_to_add: "Suelta para añadirlo a {name}", password_word: "Contraseña", more_word: "Más", + archive_group: "Archivo", + selection_group: "Selección", + clipboard_group: "Portapapeles", + recent_group: "Recientes", + view_group: "Vista", + application_group: "Aplicación", test_word: "Verificar el archivo", shortcuts_title: "Atajos de teclado", invert_selection: "Invertir la selección", @@ -526,6 +541,22 @@ const ES: Strings = Strings { toggle_word: "Marcar o desmarcar", jump_word: "Saltar a un nombre", move_word: "Moverse por la lista", + toolbar_region: "Acciones del archivo", + hidden_folders: "Carpetas ocultas", + open_folder: "Abrir la carpeta {name}", + status_region: "Estado del archivo", + progress_region: "Progreso de la operación", + cannot_open: "Este archivo no se ha podido abrir", + empty_archive: "Este archivo no tiene entradas", + empty_folder: "Esta carpeta está vacía", + no_matches: "Nada coincide con el filtro", + archive_contents: "Contenido del archivo", + ascending: "ascendente", + descending: "descendente", + not_checked: "sin marcar", + show_word: "Mostrar", + hide_word: "Ocultar", + working_word: "Trabajando", }; pub fn strings(l: Lang) -> &'static Strings { diff --git a/arca-gui/src/main.rs b/arca-gui/src/main.rs index 106db6d..700d93a 100644 --- a/arca-gui/src/main.rs +++ b/arca-gui/src/main.rs @@ -1,8191 +1,117 @@ #![forbid(unsafe_code)] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod archive_ops; +mod clipboard; +mod controller; +mod gpui_shell; +mod gpui_theme; +mod i18n; +mod model; +mod settings; +mod tree; -mod clipboard; -mod glyphs; -mod i18n; -mod theme; -mod tree; - -use arca_core::{Codec, Entry, Level}; -use arca_tar::{TarReader, TarWriter}; -use arca_zip::ZipArchive; -use eframe::egui; -use egui::ThemePreference; -use egui_extras::{Column, TableBuilder}; -use i18n::{strings, Lang, Strings}; -use rayon::prelude::*; -use std::collections::{HashMap, HashSet}; -use std::fs::{self, File}; -use std::io::{BufReader, BufWriter, Read, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::mpsc::{channel, Receiver, Sender}; -use std::time::Instant; -use tree::{children_of, draw_icon, draw_icon_at, entries_under, kind_of, parent_of, Kind, Row}; - -const BUF: usize = 256 * 1024; -const ROW_HEIGHT: f32 = 29.0; - -// How wide a column starts out and the least it can be pulled down to. The -// name gets the room because it is the thing being read; the rest hold a -// number or a word and are sized for it. -const NAME_WIDE: f32 = 320.0; -const NAME_LEAST: f32 = 140.0; -const CELL_WIDE: f32 = 95.0; -/// Lo que se aparta del canto lo que va dentro de la primera columna. -/// -/// La lista llega al borde de la ventana, que es lo que hace que no parezca -/// metida en una caja. Su contenido no: un icono pegado al canto no lo tiene -/// ninguna lista de ficheros, ni la del Explorador ni la de WinRAR. -const NAME_INSET: f32 = 8.0; - -const CELL_LEAST: f32 = 60.0; -// A second click on the same row within this opens it. Half a second, which is -// what Windows uses for the same gesture by default. -const DOUBLE_CLICK: f64 = 0.5; -const ICON_PNG: &[u8] = include_bytes!("../../brand/arca-256.png"); - -#[derive(PartialEq, Eq, Clone, Copy)] -enum Format { - Zip, - Tar, - TarGz, -} - -impl Format { - fn extension(self) -> &'static str { - match self { - Format::Zip => "zip", - Format::Tar => "tar", - Format::TarGz => "tar.gz", - } - } - - fn label(self) -> &'static str { - match self { - Format::Zip => "ZIP", - Format::Tar => "TAR", - Format::TarGz => "TAR.GZ", - } - } -} - -fn detect(p: &Path) -> Option { - let n = p.to_string_lossy().to_ascii_lowercase(); - if n.ends_with(".zip") { - Some(Format::Zip) - } else if n.ends_with(".tar.gz") || n.ends_with(".tgz") { - Some(Format::TarGz) - } else if n.ends_with(".tar") { - Some(Format::Tar) - } else { - None - } -} - -// The little triangle beside a column name that says which way it is sorted. -// Ascending points up, which is what a file list means by it everywhere: the -// smallest, the earliest, the first alphabetically, at the top. -// -// The one thing to get wrong here is the sign. Screen coordinates grow -// downwards, so the apex of an upward triangle sits at a SMALLER y than its -// base, and writing it the other way round gives a mark that says the opposite -// of what the list is doing without anything else looking amiss. -fn sort_mark(c: egui::Pos2, ascending: bool) -> [egui::Pos2; 3] { - let (w, h) = (3.8_f32, 2.4_f32); - if ascending { - [ - egui::pos2(c.x - w, c.y + h), - egui::pos2(c.x + w, c.y + h), - egui::pos2(c.x, c.y - h), - ] - } else { - [ - egui::pos2(c.x - w, c.y - h), - egui::pos2(c.x + w, c.y - h), - egui::pos2(c.x, c.y + h), - ] - } -} - -// How many folders at the front of the path have to go behind the "…" for the -// rest to fit in `room`. Drops from the front, because the folders you are -// nearest are the ones worth seeing, and never drops the last one: the folder -// you are standing in stays whatever its name costs, cut short if it must be. -fn crumbs_hidden(sizes: &[f32], sep: f32, dots: f32, room: f32) -> usize { - let mut first = 0usize; - while first + 1 < sizes.len() { - let shown = sizes.len() - first; - let mut total: f32 = sizes[first..].iter().sum::() + sep * (shown - 1) as f32; - if first > 0 { - total += dots + sep; - } - if total <= room { - break; - } - first += 1; - } - first -} - -// The row a height falls on, out of the ones the table drew this frame. -// -// The rows do not touch: there is a gap of the item spacing between one and the -// next, and the table paints over it so that the stripes look continuous, but -// the rectangles it hands back stop short. Asking which rectangle *contains* a -// height therefore has no answer whenever the pointer is resting in one of -// those gaps, which is most of the way from one row to the next. So the -// question asked here is which row has started by this height, and the answer -// in a gap is the row above it. -// -// Above the first row it clamps to the first: a drag that has run off that end -// is still asking for everything up to it. Under the last row there are two -// different situations and they cannot share an answer. If there is more list -// below, still to be scrolled into view, the answer is that last row and the -// drag carries on from there. If the list has ended, the height is in the empty -// space under it and the answer is `len`, one past the end, which is not a row: -// pressing down there and moving a little must pick nothing at all rather than -// reach up and grab whatever happens to be last. -fn row_at(rects: &[(usize, egui::Rect)], y: f32, len: usize) -> Option { - let (first, top) = *rects.first()?; - if y <= top.top() { - return Some(first); - } - let (last, bottom) = *rects.last()?; - if y > bottom.bottom() && last + 1 >= len { - return Some(len); - } - rects - .iter() - .rev() - .find(|(_, r)| y >= r.top()) - .map(|(i, _)| *i) -} - -fn saved_of(r: &Row) -> f64 { - if r.size == 0 { - 0.0 - } else { - 1.0 - r.packed as f64 / r.size as f64 - } -} - -// A Unix timestamp as a date somebody can read. Done by hand rather than with -// a date crate: the archive formats store civil time with no zone, so there is -// nothing here worth a dependency that knows about leap seconds and Tokyo. -fn when(mtime: Option) -> String { - let Some(t) = mtime.filter(|t| *t > 0) else { - return String::new(); - }; - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - // Days since 1970 to a civil date, by Howard Hinnant's method: shift the - // epoch to March so the leap day lands at the end of the year and the - // month lengths follow one formula. - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = era * 400 + yoe + i64::from(month <= 2); - format!( - "{year:04}-{month:02}-{day:02} {:02}:{:02}", - secs / 3600, - (secs % 3600) / 60 - ) -} - -fn human(n: u64) -> String { - const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; - let mut v = n as f64; - let mut i = 0; - while v >= 1024.0 && i < U.len() - 1 { - v /= 1024.0; - i += 1; - } - if i == 0 { - format!("{n} B") - } else { - format!("{v:.1} {}", U[i]) - } -} - -fn archive_stem(p: &Path) -> String { - let name = p - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_default(); - let lower = name.to_ascii_lowercase(); - for ext in [".tar.gz", ".tgz", ".zip", ".tar"] { - if lower.ends_with(ext) { - return name[..name.len() - ext.len()].to_string(); - } - } - name -} - -/// Lo mas pequena que se le deja ser a la ventana de navegar. -/// -/// Un solo numero para dos sitios que tienen que estar de acuerdo: el suelo con -/// el que se abre la ventana, y la comprobacion que decide si vale la pena -/// recordar un tamano guardado. Mientras no coincidieron, una ventana hecha mas -/// pequena que la comprobacion se tiraba al cerrar y volvia como estaba. -/// -/// El ancho es el que necesita la fila de comandos entera: los seis botones con -/// su palabra, los separadores, y una caja de buscar que se pueda leer. Estaba -/// en 720, que es lo que ocupan los botones y nada mas, asi que a la caja no le -/// quedaba ancho ninguno y salia aplastada contra el borde. -const WINDOW_MIN: [f32; 2] = [840.0, 340.0]; - -fn config_file() -> Option { - let base = if cfg!(windows) { - std::env::var_os("APPDATA").map(PathBuf::from) - } else if cfg!(target_os = "macos") { - std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support")) - } else { - std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) - }?; - Some(base.join("Arca").join("gui.conf")) -} - -struct Settings { - lang: Option, - theme: ThemePreference, - columns: Columns, - // Every file in the archive at once, instead of one folder at a time. - flat: bool, - // The folders of the archive down the left hand side. - tree: bool, - // Whether to ask, once when the window opens, if there is a newer Arca. - // It is the only thing this program does on the network, and it is asked - // here rather than assumed. - updates: bool, - // Which code page an unflagged zip has its names written in. Only the - // person looking at the archive can know, so it is remembered: somebody - // whose archives all come from one machine says it once. - page: arca_zip::pages::Page, - // Where the window was left and how big: x, y, width, height. None until it - // has been opened once. - window: Option<[f32; 4]>, - // The archives opened lately, newest first. Paths, so that one that has - // since been moved can be noticed and dropped rather than opened blind. - recent: Vec, - // How wide each column is: the name first, then the ones that can be - // turned off, in the order of `Columns::ALL`. A column keeps its width - // while it is off, so turning one back on does not lose how it was set. - widths: Vec, -} - -impl Settings { - // What a column starts out at, before anybody has pulled on it. - fn default_widths() -> Vec { - std::iter::once(NAME_WIDE) - .chain(std::iter::repeat(CELL_WIDE).take(Columns::ALL.len())) - .collect() - } - - // The least a column can be pulled down to, by its place in `widths`. - fn least(slot: usize) -> f32 { - if slot == 0 { - NAME_LEAST - } else { - CELL_LEAST - } - } -} - -impl Default for Settings { - fn default() -> Self { - Settings { - lang: None, - theme: ThemePreference::System, - columns: Columns::default(), - flat: false, - tree: false, - updates: true, - page: arca_zip::pages::Page::default(), - window: None, - recent: Vec::new(), - widths: Settings::default_widths(), - } - } -} - -impl Settings { - fn load() -> Self { - let Some(p) = config_file() else { - return Settings::default(); - }; - match fs::read_to_string(p) { - Ok(text) => Settings::parse(&text), - Err(_) => Settings::default(), - } - } - - /// Reads a settings file. The other half of [`text`](Settings::text), and - /// apart from the disk for the same reason. - /// - /// A line it does not know is skipped rather than refused: a file written - /// by a newer Arca has to keep working in an older one. - fn parse(text: &str) -> Settings { - let mut s = Settings::default(); - for line in text.lines() { - let Some((k, v)) = line.split_once('=') else { - continue; - }; - match (k.trim(), v.trim()) { - ("lang", "system") => s.lang = None, - ("lang", other) => s.lang = Lang::from_code(other), - ("theme", "light") => s.theme = ThemePreference::Light, - ("theme", "dark") => s.theme = ThemePreference::Dark, - ("theme", _) => s.theme = ThemePreference::System, - ("flat", v) => s.flat = v == "yes", - ("tree", v) => s.tree = v == "yes", - ("updates", v) => s.updates = v != "no", - ("page", v) => { - if let Some(p) = arca_zip::pages::Page::from_code(v) { - s.page = p; - } - } - // One line each, because a path can hold anything a filename - // can and there is no separator left that it could not. - ("recent", p) if !p.is_empty() => s.recent.push(p.to_string()), - ("window", v) => { - let n: Vec = v.split(',').filter_map(|x| x.trim().parse().ok()).collect(); - if let [x, y, w, h] = n[..] { - // A window smaller than the minimum, or one left on a - // screen that is no longer plugged in, is not a window - // anybody can use. - if w >= WINDOW_MIN[0] && h >= WINDOW_MIN[1] { - s.window = Some([x, y, w, h]); - } - } - } - // Written since the columns could first be turned off and read - // by nobody, so every window opened with the six of them - // showing however they had been left. - ("columns", list) => { - let mut c = Columns::default(); - for (which, _) in Columns::ALL { - c.set(which, false); - } - for name in list.split(',').map(str::trim) { - if let Some((which, _)) = Columns::ALL.iter().find(|(_, n)| *n == name) { - c.set(*which, true); - } - } - s.columns = c; - } - // All of them or none: a line with a column missing from it - // belongs to a different set of columns than this one has, and - // guessing which is which would put the widths on the wrong - // ones. Each is held above its floor in case the file was - // written by hand. - ("widths", list) => { - let read: Vec = list - .split(',') - .filter_map(|n| n.trim().parse::().ok()) - .enumerate() - .map(|(i, w)| w.max(Settings::least(i))) - .collect(); - if read.len() == s.widths.len() { - s.widths = read; - } - } - _ => {} - } - } - s - } - - fn save(&self) { - let Some(p) = config_file() else { return }; - if let Some(dir) = p.parent() { - let _ = fs::create_dir_all(dir); - } - let _ = fs::write(p, self.text()); - } - - /// The settings file as text. - /// - /// Apart from `save` so that what is written can be read back and compared - /// without going near a disk. That is not tidiness: five settings were - /// being read at startup and never written, because the line that builds - /// this had quietly stopped mentioning them, and nothing said so. A - /// round trip that never touches a file is the only way that stays fixed. - fn text(&self) -> String { - let lang = self.lang.map(|l| l.code()).unwrap_or("system"); - let theme = match self.theme { - ThemePreference::Light => "light", - ThemePreference::Dark => "dark", - ThemePreference::System => "system", - }; - let yes = |b: bool| if b { "yes" } else { "no" }; - let columns: Vec<&str> = Columns::ALL - .iter() - .filter(|(which, _)| self.columns.on(*which)) - .map(|(_, name)| *name) - .collect(); - let widths: Vec = self.widths.iter().map(|w| format!("{w:.1}")).collect(); - - let mut out = String::new(); - out.push_str(&format!("lang = {lang}\n")); - out.push_str(&format!("theme = {theme}\n")); - out.push_str(&format!("flat = {}\n", yes(self.flat))); - out.push_str(&format!("tree = {}\n", yes(self.tree))); - out.push_str(&format!("updates = {}\n", yes(self.updates))); - out.push_str(&format!("page = {}\n", self.page.code())); - out.push_str(&format!("columns = {}\n", columns.join(","))); - out.push_str(&format!("widths = {}\n", widths.join(","))); - if let Some([x, y, w, h]) = self.window { - out.push_str(&format!("window = {x:.0},{y:.0},{w:.0},{h:.0}\n")); - } - // One line each: a path can hold anything a filename can and there is - // no separator left that it could not. - for path in &self.recent { - out.push_str(&format!("recent = {path}\n")); - } - out - } - - fn effective_lang(&self) -> Lang { - self.lang.unwrap_or_else(Lang::from_system) - } -} - -fn open_source(archive: &Path, format: Format) -> std::io::Result> { - let f = BufReader::with_capacity(BUF, File::open(archive)?); - Ok(match format { - Format::TarGz => Box::new(flate2::read::GzDecoder::new(f)), - _ => Box::new(f), - }) -} - -fn list_entries(archive: &Path) -> arca_core::Result> { - let Some(format) = detect(archive) else { - return Err(arca_core::Error::Unsupported(format!( - "unrecognized extension in '{}'", - archive.display() - ))); - }; - match format { - Format::Zip => Ok(ZipArchive::open(File::open(archive)?)?.entries().to_vec()), - _ => { - let mut r = TarReader::new(open_source(archive, format)?); - let mut v = Vec::new(); - while let Some(e) = r.next_entry()? { - v.push(e.entry.clone()); - r.skip_data(&e)?; - } - Ok(v) - } - } -} - -// Only the central directory is read, which is a few kilobytes at the tail of -// the file. A .tar has no encryption to look for. -fn is_encrypted(archive: &Path) -> bool { - if detect(archive) != Some(Format::Zip) { - return false; - } - File::open(archive) - .ok() - .and_then(|f| ZipArchive::open(f).ok()) - .map(|a| a.has_encrypted()) - .unwrap_or(false) -} - -// The icon the desktop shows for this kind of file, kept as a texture per -// extension. Without the cache a listing of 1513 entries would ask the shell -// 1513 times a frame; with it, once per kind for the life of the window. -// -// A `None` in the map is a remembered failure, so a kind the system has no -// answer for is not asked about again every frame. -fn system_icon( - ctx: &egui::Context, - cache: &mut HashMap>, - name: &str, - is_dir: bool, -) -> Option { - let key = arca_icons::cache_key(name, is_dir); - if let Some(found) = cache.get(&key) { - return found.clone(); - } - let made = arca_icons::lookup(name, is_dir).map(|icon| { - let image = egui::ColorImage::from_rgba_unmultiplied( - [icon.width as usize, icon.height as usize], - &icon.rgba, - ); - ctx.load_texture(format!("icon:{key}"), image, egui::TextureOptions::LINEAR) - }); - cache.insert(key, made.clone()); - made -} - -// What the desktop calls this kind of file, cached by extension the way the -// icons are: the answer is the same for every .txt in the archive, and asking -// the shell fifteen hundred times for it would be fifteen hundred round trips -// to another thread while the list is being drawn. -fn system_type(cache: &mut HashMap>, name: &str, is_dir: bool) -> String { - let key = arca_icons::cache_key(name, is_dir); - if let Some(found) = cache.get(&key) { - return found.clone().unwrap_or_default(); - } - let made = arca_icons::type_name(name, is_dir); - cache.insert(key, made.clone()); - made.unwrap_or_default() -} - -// A folder of its own per archive, so two archives holding a file with the same -// name do not overwrite each other's copy. safe_name is what keeps an entry -// called "../../evil" from landing outside it. -/// Where the version before the last change is kept, so it can be put back. -fn undo_path(archive: &Path) -> PathBuf { - let mut name = archive.as_os_str().to_os_string(); - name.push(".arca-undo"); - PathBuf::from(name) -} - -/// Moves the archive out of the way instead of letting the new one overwrite -/// it, so that the change can be taken back. -/// -/// A move, not a copy: the file stays on the volume it was already on and -/// nothing is read or written, so keeping the old version costs the time of a -/// directory entry however big the archive is. What it does cost is the space, -/// until the next change replaces it or the window closes. -fn step_aside(archive: &Path) -> std::io::Result<()> { - let keep = undo_path(archive); - if keep.exists() { - fs::remove_file(&keep)?; - } - fs::rename(archive, &keep) -} - -// One entry straight into memory, for looking at rather than for keeping. -// -// The same walk as `extract_one` without the file at the end of it: a viewer -// that wrote to the temporary folder on the way would have extracted the thing -// it was only supposed to show. -fn read_entry( - archive: &Path, - index: usize, - out: &mut Vec, - password: Option<&str>, -) -> arca_core::Result<()> { - let Some(format) = detect(archive) else { - return Err(arca_core::Error::Unsupported("unknown format".into())); - }; - match format { - Format::Zip => { - let mut a = ZipArchive::open(File::open(archive)?)?; - a.extract_to_with(index, out, password)?; - } - _ => { - // A tar has no index, so the only way to one entry is through all - // the ones before it. - let mut r = TarReader::new(open_source(archive, format)?); - let mut at = 0usize; - while let Some(e) = r.next_entry()? { - if at == index { - r.copy_data(&e, out)?; - return Ok(()); - } - r.skip_data(&e)?; - at += 1; - } - return Err(arca_core::Error::Format( - "that entry is not in the archive any more".into(), - )); - } - } - Ok(()) -} - -fn extract_one( - archive: &Path, - entry: &Entry, - password: Option<&str>, -) -> arca_core::Result { - let room = std::env::temp_dir() - .join("Arca") - .join(archive_stem(archive)); - let path = room.join(arca_core::safe_name(&entry.name)?); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let Some(format) = detect(archive) else { - return Err(arca_core::Error::Unsupported("unknown format".into())); - }; - let mut out = BufWriter::with_capacity(BUF, File::create(&path)?); - match format { - Format::Zip => { - let mut source = BufReader::with_capacity(BUF, File::open(archive)?); - arca_zip::extract_entry_with(&mut source, entry, &mut out, password)?; - } - _ => { - // A tar has no index, so the only way to one entry is through all - // the ones before it. - let mut r = TarReader::new(open_source(archive, format)?); - let mut found = false; - while let Some(e) = r.next_entry()? { - if e.entry.name == entry.name && !e.entry.is_dir { - r.copy_data(&e, &mut out)?; - found = true; - break; - } - r.skip_data(&e)?; - } - if !found { - return Err(arca_core::Error::Format(format!( - "'{}' is not in the archive any more", - entry.name - ))); - } - } - } - out.flush()?; - Ok(path) -} - -// Whatever the desktop opens this kind of file with. The child is left to run -// on its own; the window does not wait for it and does not care what it was. -#[cfg(windows)] -fn launch_with_system(path: &Path) -> arca_core::Result<()> { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - - // The empty pair of quotes is the window title `start` insists on eating. - // Without it a quoted path becomes the title and nothing opens. No /WAIT - // either: that would leave a cmd sitting around until the viewer is closed. - std::process::Command::new("cmd") - .creation_flags(CREATE_NO_WINDOW) - .args(["/C", "start", ""]) - .arg(path) - .spawn() - .map_err(arca_core::Error::Io)?; - Ok(()) -} - -#[cfg(not(windows))] -fn launch_with_system(path: &Path) -> arca_core::Result<()> { - let opener = if cfg!(target_os = "macos") { - "open" - } else { - "xdg-open" - }; - std::process::Command::new(opener) - .arg(path) - .spawn() - .map_err(arca_core::Error::Io)?; - Ok(()) -} - -/// Lanza el instalador ya comprobado y deja que reemplace a este programa. -/// -/// En silencio del todo: sin asistente, sin barra y sin preguntas, que es lo -/// que se le pide a algo que ya se ha decidido. Tampoco hay ventana de -/// permisos, porque el instalador es por usuario y se queda en la carpeta del -/// usuario -- si pidiera permisos de administrador esto no seria un boton, -/// seria un susto. -/// -/// `/update=1` es cosa nuestra, no de Inno, y le dice dos cosas al instalador: -/// que no reinicie el Explorador para reemplazar la DLL del menu contextual -/// -- la deja puesta para el proximo arranque y la vieja sigue valiendo -- y -/// que al terminar vuelva a abrir Arca. -/// -/// No se cierra Arca aqui a proposito: Inno ve que el programa que va a -/// reemplazar esta abierto y lo cierra el mismo. Reabrirlo tambien sabe -/// hacerlo, con el Restart Manager, pero medido no lo hizo -- la ventana se -/// fue y no volvio -- asi que eso lo hace ahora el instalador por su cuenta y -/// aqui se le dice que el Restart Manager no lo intente, o saldrian dos. -#[cfg(windows)] -fn install_update(path: &Path) -> std::result::Result<(), String> { - std::process::Command::new(path) - .args([ - "/VERYSILENT", - "/NOCANCEL", - "/NORESTART", - // Que no la reabra el Restart Manager: de eso se encarga el propio - // instalador, que con /update=1 tiene una linea para ello. Si - // hiciesen las dos cosas saldrian dos ventanas. - "/NORESTARTAPPLICATIONS", - "/update=1", - ]) - .spawn() - .map(|_| ()) - .map_err(|e| e.to_string()) -} - -#[cfg(not(windows))] -fn install_update(_path: &Path) -> std::result::Result<(), String> { - // Fuera de Windows no hay instalador que bajar, asi que aqui no se llega: - // `arca_net` no contesta y nunca hay una version nueva que ofrecer. - Err("no installer on this system".into()) -} - -// A button with a picture on it, and a word next to the picture when the button -// is one of the ones worth naming. Written out rather than built from -// `egui::Button` because that one only takes an image for its icon, and these -// come either from the system icon font or from a painter. -fn tool_button( - ui: &mut egui::Ui, - glyph: glyphs::Glyph, - label: &str, - enabled: bool, - tip: &str, -) -> egui::Response { - let gap = 6.0; - let pad = ui.spacing().button_padding; - let galley = (!label.is_empty()).then(|| { - ui.painter().layout_no_wrap( - label.to_owned(), - egui::TextStyle::Button.resolve(ui.style()), - egui::Color32::PLACEHOLDER, - ) - }); - let text_w = galley.as_ref().map_or(0.0, |g| g.size().x + gap); - let size = egui::vec2( - glyphs::SIZE + text_w + pad.x * 2.0, - (glyphs::SIZE + pad.y * 2.0).max(ui.spacing().interact_size.y), - ); - let (rect, response) = ui.allocate_exact_size( - size, - if enabled { - egui::Sense::click() - } else { - egui::Sense::hover() - }, - ); - - if ui.is_rect_visible(rect) { - let visuals = ui.style().interact(&response); - let (fill, stroke, fg) = if enabled { - ( - visuals.weak_bg_fill, - visuals.bg_stroke, - visuals.fg_stroke.color, - ) - } else { - let off = ui.visuals().widgets.noninteractive; - ( - off.weak_bg_fill, - off.bg_stroke, - ui.visuals().weak_text_color(), - ) - }; - ui.painter().rect(rect, visuals.rounding, fill, stroke); - let icon = egui::Rect::from_min_size( - egui::pos2(rect.left() + pad.x, rect.center().y - glyphs::SIZE / 2.0), - egui::Vec2::splat(glyphs::SIZE), - ); - match glyphs::codepoint(glyph).filter(|_| theme::icons_available()) { - Some(ch) => { - ui.painter().text( - icon.center(), - egui::Align2::CENTER_CENTER, - ch, - egui::FontId::new(glyphs::SIZE, egui::FontFamily::Name(theme::ICONS.into())), - fg, - ); - } - None => glyphs::draw(ui.painter(), icon, glyph, fg), - } - if let Some(g) = galley { - let at = egui::pos2(icon.right() + gap, rect.center().y - g.size().y / 2.0); - ui.painter().galley(at, g, fg); - } - } - - if enabled && !tip.is_empty() { - response.on_hover_text(tip) - } else { - response - } -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Answer { - Replace, - ReplaceAll, - Skip, - SkipAll, - Rename, - RenameAll, - Cancel, -} - -// The worker asks the window and blocks until it answers. The "all" answers -// stick, so the question is asked once and not per file. -/// The name of the thing a job is being done to, without the path. -/// -/// One archive is named; several are counted, because a list of ten paths in a -/// title bar is no more use than none. Compressing has no archive yet, so it is -/// the file about to be made. -fn subject_of(job: &Job) -> String { - let named = |p: &Path| { - p.file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default() - }; - match job { - Job::Extract { archives, .. } => match archives.split_first() { - Some((only, [])) => named(only), - Some((_, rest)) => format!("{} +{}", named(&archives[0]), rest.len()), - None => String::new(), - }, - Job::Compress { out, .. } => named(out), - Job::Test { archive, .. } - | Job::Password { archive, .. } - | Job::Delete { archive, .. } - | Job::CopyTo { archive, .. } - | Job::Move { archive, .. } - | Job::NewFolder { archive, .. } - | Job::Rename { archive, .. } - | Job::Add { archive, .. } => named(archive), - // Aqui el archivo es el instalador, y su nombre ya lleva la version. - Job::Update { installer, .. } => { - installer.rsplit('/').next().unwrap_or_default().to_string() - } - } -} - -fn conflict_asker<'a>( - tx: &'a Sender, - ctx: &'a egui::Context, - replies: &'a std::sync::mpsc::Receiver, -) -> impl Fn(&Path) -> Answer + 'a { - let sticky = std::cell::Cell::new(None::); - move |path: &Path| { - if let Some(a) = sticky.get() { - return a; - } - if tx - .send(Message::Conflict(path.display().to_string())) - .is_err() - { - return Answer::Cancel; - } - ctx.request_repaint(); - let answer = replies.recv().unwrap_or(Answer::Cancel); - if matches!( - answer, - Answer::ReplaceAll | Answer::SkipAll | Answer::RenameAll | Answer::Cancel - ) { - sticky.set(Some(answer)); - } - answer - } -} - -// `claimed` holds the names this run has already handed out. A .zip decides -// every destination before writing anything, so `exists()` alone would give two -// entries with the same name the same free name. -fn free_name(path: &Path, claimed: &HashSet) -> PathBuf { - let dir = path.parent().map(PathBuf::from).unwrap_or_default(); - let stem = path - .file_stem() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_default(); - let ext = path - .extension() - .map(|s| format!(".{}", s.to_string_lossy())) - .unwrap_or_default(); - for n in 1..10_000u32 { - let candidate = dir.join(format!("{stem} ({n}){ext}")); - if !candidate.exists() && !claimed.contains(&candidate) { - return candidate; - } - } - path.to_path_buf() -} - -// Returns None when the entry must be skipped, and Err on cancel. -fn dest_path( - dest: &Path, - name: &str, - is_dir: bool, - ask: &dyn Fn(&Path) -> Answer, - claimed: &mut HashSet, -) -> arca_core::Result> { - let path = dest.join(arca_core::safe_name(name)?); - if is_dir { - fs::create_dir_all(&path)?; - return Ok(None); - } - if let Some(p) = path.parent() { - fs::create_dir_all(p)?; - } - if !path.exists() && !claimed.contains(&path) { - claimed.insert(path.clone()); - return Ok(Some(path)); - } - let chosen = match ask(&path) { - Answer::Replace | Answer::ReplaceAll => path, - Answer::Skip | Answer::SkipAll => return Ok(None), - Answer::Rename | Answer::RenameAll => free_name(&path, claimed), - Answer::Cancel => return Err(arca_core::Error::Format("cancelled".into())), - }; - claimed.insert(chosen.clone()); - Ok(Some(chosen)) -} - -// A .zip is random access: the central directory says where every entry starts, -// so one thread per core can each open the file and decompress a different one. -// A .tar is a single stream, and a .tar.gz a single gzip stream on top of it, so -// there is nothing to split there and that branch stays sequential. -// -// The directories and the overwrite questions are settled first, in one thread. -// Asking the window from several threads at once would put the same dialog on -// screen twice, and racing on which name is free gives a different result every -// run. -fn extract( - archive: &Path, - dest: &Path, - wanted: &[bool], - // Told how far along this is, and answers whether to carry on. False is - // somebody pressing stop. - notify: &(dyn Fn(usize, usize, &str) -> bool + Sync), - ask: &dyn Fn(&Path) -> Answer, - password: Option<&str>, -) -> arca_core::Result { - let Some(format) = detect(archive) else { - return Err(arca_core::Error::Unsupported("unknown format".into())); - }; - fs::create_dir_all(dest)?; - let mut bytes = 0u64; - let mut claimed: HashSet = HashSet::new(); - - match format { - Format::Zip => { - let a = ZipArchive::open(File::open(archive)?)?; - let mut jobs: Vec<(Entry, PathBuf)> = Vec::new(); - for (i, e) in a.entries().iter().enumerate() { - if !wanted.is_empty() && !wanted.get(i).copied().unwrap_or(true) { - continue; - } - if let Some(path) = dest_path(dest, &e.name, e.is_dir, ask, &mut claimed)? { - jobs.push((e.clone(), path)); - } - } - drop(a); - - let total = jobs.len(); - let done = AtomicUsize::new(0); - let written: Vec = jobs - .par_iter() - .map(|(e, path)| { - let mut source = BufReader::with_capacity(BUF, File::open(archive)?); - let mut f = BufWriter::with_capacity(BUF, File::create(path)?); - let w = arca_zip::extract_entry_with(&mut source, e, &mut f, password)?; - f.flush()?; - if !notify(done.fetch_add(1, Ordering::Relaxed) + 1, total, &e.name) { - return Err(arca_core::Error::Cancelled); - } - Ok(w) - }) - .collect::>>()?; - bytes = written.iter().sum(); - let _ = notify(total, total, ""); - } - _ => { - let mut r = TarReader::new(open_source(archive, format)?); - let total = wanted.len(); - let mut i = 0usize; - while let Some(e) = r.next_entry()? { - if !notify(i, total, &e.entry.name) { - return Err(arca_core::Error::Cancelled); - } - if !wanted.is_empty() && !wanted.get(i).copied().unwrap_or(true) { - r.skip_data(&e)?; - i += 1; - continue; - } - match dest_path(dest, &e.entry.name, e.entry.is_dir, ask, &mut claimed)? { - Some(path) => { - let mut w = BufWriter::with_capacity(BUF, File::create(&path)?); - bytes += r.copy_data(&e, &mut w)?; - w.flush()?; - } - None => r.skip_data(&e)?, - } - i += 1; - } - let _ = notify(i, i, ""); - } - } - Ok(bytes) -} - -fn test_archive( - archive: &Path, - only: Option<&HashSet>, - // Told how far along this is, and answers whether to carry on. False is - // somebody pressing stop. - notify: &(dyn Fn(usize, usize, &str) -> bool + Sync), -) -> arca_core::Result<(usize, Vec)> { - let Some(format) = detect(archive) else { - return Err(arca_core::Error::Unsupported("unknown format".into())); - }; - let mut good = 0usize; - let mut bad = Vec::new(); - - match format { - Format::Zip => { - let mut a = ZipArchive::open(File::open(archive)?)?; - let total = a.len(); - for i in 0..total { - let name = a.entries()[i].name.clone(); - if !notify(i, total, &name) { - return Err(arca_core::Error::Cancelled); - } - if a.entries()[i].is_dir || only.is_some_and(|set| !set.contains(&name)) { - continue; - } - match a.extract_to(i, std::io::sink()) { - Ok(_) => good += 1, - Err(e) => bad.push(format!("{name}: {e}")), - } - } - let _ = notify(total, total, ""); - } - _ => { - let mut r = TarReader::new(open_source(archive, format)?); - let mut i = 0usize; - while let Some(e) = r.next_entry()? { - if !notify(i, i + 1, &e.entry.name) { - return Err(arca_core::Error::Cancelled); - } - if e.entry.is_dir || only.is_some_and(|set| !set.contains(&e.entry.name)) { - r.skip_data(&e)?; - } else { - match r.copy_data(&e, &mut std::io::sink()) { - Ok(_) => good += 1, - Err(err) => bad.push(format!("{}: {err}", e.entry.name)), - } - } - i += 1; - } - let _ = notify(i, i, ""); - } - } - Ok((good, bad)) -} - -fn collect_files(inputs: &[PathBuf]) -> std::io::Result> { - fn walk(p: &Path, base: &Path, out: &mut Vec<(PathBuf, String)>) -> std::io::Result<()> { - let meta = fs::symlink_metadata(p)?; - let rel = p.strip_prefix(base).unwrap_or(p); - let name = rel.to_string_lossy().replace('\\', "/"); - if meta.is_dir() { - let mut children: Vec<_> = fs::read_dir(p)?.collect::>>()?; - children.sort_by_key(|d| d.file_name()); - for c in children { - walk(&c.path(), base, out)?; - } - } else if meta.is_file() { - out.push((p.to_path_buf(), name)); - } - Ok(()) - } - - let mut v = Vec::new(); - for e in inputs { - let base = e.parent().unwrap_or(Path::new("")); - walk(e, base, &mut v)?; - } - Ok(v) -} - -/// When a file was last written, as seconds since 1970. -/// -/// Zero when the system will not say, which is what a zip means by leaving the -/// field empty anyway. -fn mtime_of(m: &fs::Metadata) -> i64 { - m.modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -fn compress( - out: &Path, - inputs: &[PathBuf], - format: Format, - codec: Codec, - level: Level, - // Told how far along this is, and answers whether to carry on. False is - // somebody pressing stop. - notify: &(dyn Fn(usize, usize, &str) -> bool + Sync), - password: Option<&str>, -) -> arca_core::Result<(u64, u64)> { - let outcome = compress_inner(out, inputs, format, codec, level, notify, password); - if outcome.is_err() { - // Half an archive is not a small archive: it is a file that opens to an - // error, and leaving one where a good one was asked for looks like it - // worked. `create_zip` clears up after itself; this is for the tar. - let _ = fs::remove_file(out); - } - outcome -} - -#[allow(clippy::too_many_arguments)] -fn compress_inner( - out: &Path, - inputs: &[PathBuf], - format: Format, - codec: Codec, - level: Level, - notify: &(dyn Fn(usize, usize, &str) -> bool + Sync), - password: Option<&str>, -) -> arca_core::Result<(u64, u64)> { - if password.is_some() && format != Format::Zip { - return Err(arca_core::Error::Unsupported( - "encryption only exists in .zip".into(), - )); - } - let files = collect_files(inputs)?; - let total = files.len(); - let mut source_bytes = 0u64; - - match format { - // Every entry in a zip is compressed on its own, so this hands the - // whole list over and lets it run on every core. It is the same call - // the command line makes; there is one of these, not two. - Format::Zip => { - let mut sources = Vec::with_capacity(files.len()); - for (path, name) in &files { - let meta = fs::metadata(path)?; - source_bytes += meta.len(); - sources.push(arca_zip::Source { - path: path.clone(), - name: name.clone(), - size: meta.len(), - mtime: mtime_of(&meta), - codec, - level, - }); - } - arca_zip::create_zip(out, &sources, 0, password, notify)?; - } - _ => { - let raw = BufWriter::with_capacity(BUF, File::create(out)?); - let sink: Box = if format == Format::TarGz { - Box::new(flate2::write::GzEncoder::new( - raw, - flate2::Compression::new(level.to_flate2()), - )) - } else { - Box::new(raw) - }; - let mut w = TarWriter::new(sink); - for (i, (path, name)) in files.iter().enumerate() { - if !notify(i, total, name) { - return Err(arca_core::Error::Cancelled); - } - let meta = fs::metadata(path)?; - let f = BufReader::with_capacity(BUF, File::open(path)?); - w.add(name, meta.len(), 0, 0o644, f)?; - source_bytes += meta.len(); - } - w.finish()?; - } - } - let _ = notify(total, total, ""); - let final_size = fs::metadata(out).map(|m| m.len()).unwrap_or(0); - Ok((source_bytes, final_size)) -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Destination { - Beside, - Subfolder, -} - -enum Job { - Extract { - archives: Vec, - dest: Destination, - password: Option, - }, - Test { - archive: PathBuf, - // The names to check, or all of them. A selection is checked by walking - // the whole archive and skipping what is not in the set: the entries - // have to be read in the order they are filed anyway. - only: Option>, - }, - // Rewriting an archive with a different password, or with none. - Password { - archive: PathBuf, - current: Option, - new: Option, - }, - // Taking entries out. A zip has no hole to leave behind, so this rebuilds - // the archive without them. - Delete { - archive: PathBuf, - names: Vec, - password: Option, - }, - CopyTo { - archive: PathBuf, - dest: PathBuf, - }, - Move { - archive: PathBuf, - // Pairs of what a name is now and what it becomes, without the slash a - // folder carries: both spellings are handled where the move is made. - moves: Vec<(String, String)>, - password: Option, - }, - NewFolder { - archive: PathBuf, - // The whole path with the slash already on it, worked out where the - // folder you are looking at is known. - name: String, - password: Option, - }, - Rename { - archive: PathBuf, - // Both are full paths inside the archive, not the names on their own: - // renaming happens in the folder you are looking at and the entries are - // stored by their whole path. - from: String, - to: String, - // A folder is not one entry but everything filed under it, so the whole - // branch moves. There is no entry for it to be renamed on its own. - folder: bool, - password: Option, - }, - Compress { - out: PathBuf, - inputs: Vec, - format: Format, - codec: Codec, - level: Level, - password: Option, - }, - // Putting files in. Same rebuild as Delete, and for the same reason: the - // central directory is at the end of the file. - Add { - archive: PathBuf, - inputs: Vec, - // Where inside the archive they land, which is the folder the window is - // showing. Empty means the root. - dir: String, - codec: Codec, - level: Level, - password: Option, - }, - // Bajar la version nueva y comprobarla. No la instala: eso lo hace la - // ventana cuando este trabajo le dice donde ha quedado el fichero, porque - // instalar significa cerrar Arca y eso no se hace desde un hilo de fondo. - Update { - tag: String, - installer: String, - sums: String, - }, -} - -enum Startup { - Browse(Option), - Run(Job), - Add(Vec), -} - -fn quick_output(inputs: &[PathBuf], format: Format) -> PathBuf { - let first = &inputs[0]; - let dir = first.parent().map(PathBuf::from).unwrap_or_default(); - let stem = if inputs.len() == 1 { - let name = first - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "archive".into()); - if first.is_dir() { - name - } else { - Path::new(&name) - .file_stem() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or(name) - } - } else { - dir.file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "archive".into()) - }; - dir.join(format!("{stem}.{}", format.extension())) -} - -fn parse_args() -> Startup { - let args: Vec = std::env::args().skip(1).collect(); - if args.is_empty() { - return Startup::Browse(None); - } - let rest: Vec = args[1..].iter().map(PathBuf::from).collect(); - match args[0].as_str() { - "--extract-here" if !rest.is_empty() => Startup::Run(Job::Extract { - archives: rest, - dest: Destination::Beside, - password: None, - }), - "--extract-to-folder" if !rest.is_empty() => Startup::Run(Job::Extract { - archives: rest, - dest: Destination::Subfolder, - password: None, - }), - "--test" if !rest.is_empty() => Startup::Run(Job::Test { - archive: rest[0].clone(), - only: None, - }), - "--add" if !rest.is_empty() => Startup::Add(rest), - "--add-quick" if !rest.is_empty() => Startup::Run(Job::Compress { - out: quick_output(&rest, Format::Zip), - inputs: rest, - format: Format::Zip, - codec: Codec::Deflate, - level: Level::Normal, - password: None, - }), - other if !other.starts_with("--") => Startup::Browse(Some(PathBuf::from(other))), - _ => Startup::Browse(None), - } -} - -fn fill(template: &str, pairs: &[(&str, &str)]) -> String { - let mut s = template.to_string(); - for (key, value) in pairs { - s = s.replace(&format!("{{{key}}}"), value); - } - s -} - -/// Baja el instalador de la version nueva y lo deja comprobado en un temporal. -/// -/// Lo que se ejecuta despues de esto es un programa entero con permisos de -/// quien lo lance, asi que no basta con que la descarga termine: se pide -/// tambien el fichero de sumas de la misma release y se compara el SHA-256 de -/// lo que ha llegado con lo que ahi pone. Si no cuadra, no se escribe nada y no -/// se ejecuta nada. -/// -/// Eso protege de una descarga a medias o corrompida por el camino. No protege -/// de una release envenenada, porque la suma sale del mismo sitio que el -/// fichero: para eso hace falta firmar, y estos binarios todavia no van -/// firmados. -fn download_update( - installer: &str, - sums: &str, - s: &'static Strings, - notify: &(dyn Fn(usize, usize, &str) -> bool + Sync), -) -> std::result::Result { - use sha2::{Digest, Sha256}; - - let agent = format!("Arca/{}", env!("CARGO_PKG_VERSION")); - let name = installer - .rsplit('/') - .next() - .filter(|n| !n.is_empty()) - .ok_or_else(|| s.update_failed.to_string())?; - - // Primero las sumas, que son cuatro lineas: si eso ya no se puede traer, no - // tiene sentido bajarse cinco megas para no poder comprobarlos. - let listing = arca_net::get(sums, &agent).ok_or_else(|| s.update_failed.to_string())?; - let want = sum_for(&listing, name).ok_or_else(|| s.update_failed.to_string())?; - - let body = arca_net::fetch(installer, &agent, INSTALLER_LIMIT, &|so_far, total| { - notify(so_far, total.unwrap_or(0), name) - }) - .ok_or_else(|| s.update_failed.to_string())?; - - let got: [u8; 32] = Sha256::digest(&body).into(); - if got != want { - return Err(s.update_tampered.to_string()); - } - - let path = std::env::temp_dir().join(name); - fs::write(&path, &body).map_err(|e| e.to_string())?; - Ok(path) -} - -fn run_job_blocking( - job: Job, - s: &'static Strings, - // Told how far along this is, and answers whether to carry on. False is - // somebody pressing stop. - notify: &(dyn Fn(usize, usize, &str) -> bool + Sync), - ask: &dyn Fn(&Path) -> Answer, -) -> std::result::Result { - match job { - Job::Extract { - archives, - dest, - password, - } => { - if archives.is_empty() { - return Err(s.nothing_to_do.to_string()); - } - if archives.iter().any(|a| detect(a).is_none()) { - return Err(s.unknown_format.to_string()); - } - let mut total = 0u64; - let mut last = PathBuf::new(); - for a in &archives { - let base = a.parent().map(PathBuf::from).unwrap_or_default(); - let target = match dest { - Destination::Beside => base, - Destination::Subfolder => base.join(archive_stem(a)), - }; - total += extract(a, &target, &[], notify, ask, password.as_deref()) - .map_err(|e| e.to_string())?; - last = target; - } - Ok(fill( - s.extracted_to, - &[ - ("size", &human(total)), - ("dest", &last.display().to_string()), - ], - )) - } - Job::Test { archive, only } => { - if detect(&archive).is_none() { - return Err(s.unknown_format.to_string()); - } - let (good, bad) = - test_archive(&archive, only.as_ref(), notify).map_err(|e| e.to_string())?; - if bad.is_empty() { - Ok(fill(s.verified_ok, &[("n", &good.to_string())])) - } else { - Err(format!( - "{}: {}", - fill( - s.errors_found, - &[("good", &good.to_string()), ("bad", &bad.len().to_string())] - ), - bad.join("; ") - )) - } - } - // Built next to the original and read back in full before it replaces - // it. The archive is the only copy of what is inside it. - Job::Password { - archive, - current, - new, - } => { - let temp = archive.with_file_name(format!( - "{}.arca-new", - archive - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default() - )); - let done = arca_zip::rewrite_password( - &archive, - &temp, - current.as_deref(), - new.as_deref(), - notify, - ); - if let Err(e) = done { - let _ = fs::remove_file(&temp); - return Err(e.to_string()); - } - step_aside(&archive).map_err(|e| e.to_string())?; - fs::rename(&temp, &archive).map_err(|e| e.to_string())?; - let name = archive - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default(); - Ok(fill( - if new.is_some() { - s.password_set - } else { - s.password_removed - }, - &[("name", &name)], - )) - } - // Same shape as the password rewrite, and the same care: the archive - // is the only copy of what is inside it, so the new one is built - // alongside, read back in full, and only then moved over. - Job::Delete { - archive, - names, - password, - } => { - if detect(&archive) != Some(Format::Zip) { - return Err(s.only_zip_can_change.to_string()); - } - let doomed: HashSet = names.into_iter().collect(); - let temp = archive.with_file_name(format!( - "{}.arca-new", - archive - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default() - )); - let done = arca_zip::remove_entries( - &archive, - &temp, - password.as_deref(), - &|e| !doomed.contains(&e.name), - notify, - ); - let gone = doomed.len(); - if let Err(e) = done { - let _ = fs::remove_file(&temp); - return Err(e.to_string()); - } - step_aside(&archive).map_err(|e| e.to_string())?; - fs::rename(&temp, &archive).map_err(|e| e.to_string())?; - Ok(fill(s.deleted, &[("n", &gone.to_string())])) - } - Job::CopyTo { archive, dest } => { - // Copied by hand rather than with `fs::copy`, which says nothing - // until it is finished: a three gigabyte archive would be a window - // that had stopped answering for a minute. This one has a bar and a - // way out, like everything else that takes a while. - let total = fs::metadata(&archive).map(|m| m.len()).unwrap_or(0); - let name = dest - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default(); - let copied = (|| -> std::io::Result { - let mut from = BufReader::with_capacity(BUF, File::open(&archive)?); - let mut to = BufWriter::with_capacity(BUF, File::create(&dest)?); - let mut buf = vec![0u8; BUF]; - let mut done = 0u64; - loop { - let n = from.read(&mut buf)?; - if n == 0 { - break; - } - to.write_all(&buf[..n])?; - done += n as u64; - // The counters are whole megabytes: a bar that redraws once - // per sixty-four kilobytes is a bar drawing itself instead - // of the copy getting on with it. - if !notify( - (done / (1 << 20)) as usize, - (total / (1 << 20)).max(1) as usize, - &name, - ) { - return Err(std::io::Error::other("cancelled")); - } - } - to.flush()?; - Ok(done) - })(); - match copied { - Ok(bytes) => Ok(fill( - s.copied_to, - &[ - ("size", &human(bytes)), - ("dest", &dest.display().to_string()), - ], - )), - Err(e) => { - // Half a copy is not a copy. Whatever was written goes, - // whether the reason was a full disk or somebody pressing - // stop. - let _ = fs::remove_file(&dest); - if e.to_string() == "cancelled" { - return Err(arca_core::Error::Cancelled.to_string()); - } - Err(e.to_string()) - } - } - } - Job::Rename { - archive, - from, - to, - folder, - password, - } => { - if detect(&archive) != Some(Format::Zip) { - return Err(s.only_zip_can_change.to_string()); - } - let temp = archive.with_file_name(format!( - "{}.arca-new", - archive - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default() - )); - // A folder answers to two spellings: some tools file an entry for - // the folder itself with a slash on the end, others only file what - // is inside it. Both have to move, and neither can be assumed. - let under = format!("{from}/"); - let moved = format!("{to}/"); - let rename = |name: &str| -> String { - // Compared in the spelling the window works in. An archive - // written with backslashes matched nothing otherwise, and the - // rename quietly did nothing at all. - let name = slashed(name); - if !folder { - return if name == from { to.clone() } else { name }; - } - if name == from { - to.clone() - } else if name == under { - moved.clone() - } else if let Some(rest) = name.strip_prefix(&under) { - format!("{moved}{rest}") - } else { - name - } - }; - let done = arca_zip::rename_entries( - &archive, - &temp, - password.as_deref(), - &|e| rename(&e.name), - notify, - ); - if let Err(e) = done { - let _ = fs::remove_file(&temp); - return Err(e.to_string()); - } - step_aside(&archive).map_err(|e| e.to_string())?; - fs::rename(&temp, &archive).map_err(|e| e.to_string())?; - // Nothing to say: the new name is in the list, which is where the - // eye already is. An empty word here leaves the summary of the - // archive standing, which is what the bar is for. - Ok(String::new()) - } - Job::Compress { - out, - inputs, - format, - codec, - level, - password, - } => { - if inputs.is_empty() { - return Err(s.nothing_to_do.to_string()); - } - let (from, to) = compress( - &out, - &inputs, - format, - codec, - level, - notify, - password.as_deref(), - ) - .map_err(|e| e.to_string())?; - let pct = if from == 0 { - 0.0 - } else { - (1.0 - to as f64 / from as f64) * 100.0 - }; - Ok(fill( - s.created, - &[ - ("name", &out.display().to_string()), - ("from", &human(from)), - ("to", &human(to)), - ("pct", &format!("{pct:.1}%")), - ], - )) - } - // Same care as Delete and the password rewrite: built alongside, read - // back in full, and only then moved over the original. - Job::Add { - archive, - inputs, - dir, - codec, - level, - password, - } => { - if detect(&archive) != Some(Format::Zip) { - return Err(s.only_zip_can_change.to_string()); - } - if inputs.is_empty() { - return Err(s.nothing_to_do.to_string()); - } - let extra: Vec = collect_files(&inputs) - .map_err(|e| e.to_string())? - .into_iter() - .map(|(source, name)| arca_zip::Addition { - source: Some(source), - name: format!("{dir}{name}"), - codec, - level, - }) - .collect(); - if extra.is_empty() { - return Err(s.nothing_to_do.to_string()); - } - let temp = archive.with_file_name(format!( - "{}.arca-new", - archive - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default() - )); - let n = extra.len(); - let done = arca_zip::add_entries(&archive, &temp, password.as_deref(), &extra, notify); - if let Err(e) = done { - let _ = fs::remove_file(&temp); - return Err(e.to_string()); - } - step_aside(&archive).map_err(|e| e.to_string())?; - fs::rename(&temp, &archive).map_err(|e| e.to_string())?; - Ok(fill(s.added, &[("n", &n.to_string())])) - } - Job::Move { - archive, - moves, - password, - } => { - if detect(&archive) != Some(Format::Zip) { - return Err(s.only_zip_can_change.to_string()); - } - let temp = archive.with_file_name(format!( - "{}.arca-new", - archive - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default() - )); - // All of them in one pass. Moving is renaming with a different - // folder in front, and renaming is a rewrite of the whole archive: - // five files moved one at a time would be five rewrites. - let done = arca_zip::rename_entries( - &archive, - &temp, - password.as_deref(), - &|e| moved_name(&e.name, &moves), - notify, - ); - if let Err(e) = done { - let _ = fs::remove_file(&temp); - return Err(e.to_string()); - } - step_aside(&archive).map_err(|e| e.to_string())?; - fs::rename(&temp, &archive).map_err(|e| e.to_string())?; - // The list says where everything is now, which is the whole answer. - Ok(String::new()) - } - Job::NewFolder { - archive, - name, - password, - } => { - if detect(&archive) != Some(Format::Zip) { - return Err(s.only_zip_can_change.to_string()); - } - let temp = archive.with_file_name(format!( - "{}.arca-new", - archive - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default() - )); - let extra = [arca_zip::Addition { - // No file behind it: a folder in a zip is a name and nothing - // else. - source: None, - name, - codec: Codec::Store, - level: Level::Store, - }]; - let done = arca_zip::add_entries(&archive, &temp, password.as_deref(), &extra, notify); - if let Err(e) = done { - let _ = fs::remove_file(&temp); - return Err(e.to_string()); - } - step_aside(&archive).map_err(|e| e.to_string())?; - fs::rename(&temp, &archive).map_err(|e| e.to_string())?; - // Nothing to say: the folder is in the list, which is where the eye - // already is. - Ok(String::new()) - } - // No llega aqui: bajar la version nueva se atiende antes, en el hilo - // que lanza el trabajo, porque acaba en un fichero que ejecutar y no en - // un texto que ensenar. - Job::Update { .. } => Err(s.update_failed.to_string()), - } -} - -#[derive(PartialEq, Eq, Clone, Copy)] -enum SortColumn { - Name, - Size, - Packed, - Method, - Saved, - Modified, - Crc, - Type, - Path, - Created, - Accessed, - Attributes, -} - -// Which columns the list shows. Name is not here: a list of nothing but sizes -// would be a strange thing to allow. -#[derive(Clone, Copy, PartialEq, Eq)] -struct Columns { - size: bool, - packed: bool, - method: bool, - saved: bool, - modified: bool, - crc: bool, - type_: bool, - path: bool, - created: bool, - accessed: bool, - attributes: bool, -} - -impl Default for Columns { - fn default() -> Self { - // What was on screen before any of this was a choice, plus the date, - // which both WinRAR and NanaZip show and which people look for. - Columns { - size: true, - packed: true, - method: true, - saved: true, - modified: true, - crc: false, - type_: false, - path: false, - created: false, - accessed: false, - attributes: false, - } - } -} - -impl Columns { - const ALL: [(SortColumn, &'static str); 11] = [ - (SortColumn::Size, "size"), - (SortColumn::Packed, "packed"), - (SortColumn::Method, "method"), - (SortColumn::Saved, "saved"), - (SortColumn::Modified, "modified"), - (SortColumn::Crc, "crc"), - (SortColumn::Type, "type"), - (SortColumn::Path, "path"), - (SortColumn::Created, "created"), - (SortColumn::Accessed, "accessed"), - (SortColumn::Attributes, "attributes"), - ]; - - fn on(&self, which: SortColumn) -> bool { - match which { - SortColumn::Size => self.size, - SortColumn::Packed => self.packed, - SortColumn::Method => self.method, - SortColumn::Saved => self.saved, - SortColumn::Modified => self.modified, - SortColumn::Crc => self.crc, - SortColumn::Type => self.type_, - SortColumn::Path => self.path, - SortColumn::Created => self.created, - SortColumn::Accessed => self.accessed, - SortColumn::Attributes => self.attributes, - SortColumn::Name => true, - } - } - - fn set(&mut self, which: SortColumn, value: bool) { - match which { - SortColumn::Size => self.size = value, - SortColumn::Packed => self.packed = value, - SortColumn::Method => self.method = value, - SortColumn::Saved => self.saved = value, - SortColumn::Modified => self.modified = value, - SortColumn::Crc => self.crc = value, - SortColumn::Type => self.type_ = value, - SortColumn::Path => self.path = value, - SortColumn::Created => self.created = value, - SortColumn::Accessed => self.accessed = value, - SortColumn::Attributes => self.attributes = value, - SortColumn::Name => {} - } - } - - fn label(which: SortColumn, s: &Strings) -> &'static str { - match which { - SortColumn::Size => s.col_size, - SortColumn::Packed => s.col_packed, - SortColumn::Method => s.col_method, - SortColumn::Saved => s.col_saved, - SortColumn::Modified => s.col_modified, - SortColumn::Crc => s.col_crc, - SortColumn::Type => s.col_type, - SortColumn::Path => s.col_path, - SortColumn::Created => s.col_created, - SortColumn::Accessed => s.col_accessed, - SortColumn::Attributes => s.col_attributes, - SortColumn::Name => s.col_name, - } - } -} - -enum Message { - Listing(PathBuf, Vec), - Conflict(String), - Progress(usize, usize, String), - Done(String), - Failed(String), - // A cut reached the clipboard in one piece. Sent before Done, and only - // then, so a cut that failed halfway never leaves the window waiting to - // take entries out of an archive on the strength of it. - CutReady, - // El instalador de la version nueva, bajado y con su hash comprobado, ahi - // donde quedo. Sale de aqui y no de Done porque lo que hace falta no es un - // texto que leer, es un fichero que ejecutar. - Downloaded(PathBuf), -} - -// What a cut is waiting on. The entries stay in the archive until the paste -// actually happens, and this is what says which ones and how to tell. -struct Cut { - archive: PathBuf, - // The extracted copies handed to the shell. A paste with the move effect - // takes them out of the temporary folder, and their absence is the only - // sign Windows gives that it happened. - paths: Vec, - names: Vec, -} - -// What the password window is standing in front of: a job the context menu -// handed us, or an encrypted archive just opened in the window. -// Which blank the password window is filling in. They are not the same -// question: one needs the password the archive already has, the other the one it -// is about to get. -enum Pending { - Extract(Box), - OpenArchive, - CurrentPassword(Box), - NewPassword(Box), -} - -enum View { - Browse, - Add, - Running, -} - -// What the overflow button on the toolbar was asked for. A value rather than a -// closure because the menu draws while the toolbar still holds `self`. -#[derive(Clone)] -enum More { - Test, - Release, - Undo, - NewFolder, - Page(arca_zip::pages::Page), - SaveCopy, - DefaultPassword, - Flat, - Tree, - Open(PathBuf), - Forget, - All, - Invert, - None_, - Settings, - Shortcuts, -} - -/// Pressing the wheel drops an anchor and the list then runs towards the -/// pointer, faster the further away it is: the gesture Windows has had since -/// the wheel arrived, and the one every browser copies. -#[derive(Clone, Copy)] -struct Wheel { - /// Where the wheel went down. The list stands still while the pointer is - /// near it and runs when it is away. - anchor: egui::Pos2, - /// How far down the list is, kept here rather than read back from the - /// table because it moves by fractions of a pixel per frame and the table - /// only remembers whole scroll positions. - at: f32, - /// Whether the pointer has pulled away from the anchor yet. Letting the - /// wheel go after it has ends the gesture, letting it go before leaves it - /// running until the next click; that is what makes press-and-drag and - /// click-and-go both work off the one button. - moved: bool, -} - -/// The DOS attribute byte as the letters every file manager has shown it with -/// since there were file managers: read only, hidden, system, archive. -/// -/// A dash where a bit is off rather than a shorter string, so that the column -/// lines up down the page and the eye can read one position instead of one -/// word. The directory bit is not shown: the list already says which rows are -/// folders, in a way that does not need decoding. -fn attribute_letters(bits: u8) -> String { - [(0x01, 'R'), (0x02, 'H'), (0x04, 'S'), (0x20, 'A')] - .iter() - .map(|(mask, letter)| if bits & mask != 0 { *letter } else { '-' }) - .collect() -} - -/// Whether a name claims to be a picture of a kind the window can draw. -fn looks_like_picture(name: &str) -> bool { - let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()); - matches!( - ext.as_deref(), - Some("png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp") - ) -} - -/// One line of a hex dump: where it starts, the bytes, and what they would be -/// if they were letters. -/// -/// The three columns are what makes a dump readable: the offset to point at, -/// the bytes to read, and the letters to recognise a string in the middle of -/// something that is not one. A dot stands for everything unprintable, which is -/// the convention every other dump follows. -fn hex_line(at: usize, bytes: &[u8]) -> String { - let mut out = format!("{at:08X} "); - for i in 0..16 { - match bytes.get(i) { - Some(b) => out.push_str(&format!("{b:02X} ")), - None => out.push_str(" "), - } - if i == 7 { - out.push(' '); - } - } - out.push(' '); - for b in bytes { - out.push(if (0x20..0x7F).contains(b) { - *b as char - } else { - '.' - }); - } - out -} - -/// How a file is being looked at in the viewer. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Look { - Text, - Hex, - Picture, -} - -/// A file out of the archive, held in memory for looking at. -/// -/// The bytes are never written to disk. Viewing something is not the same as -/// extracting it, and a viewer that leaves a copy in the temporary folder has -/// quietly extracted it. -struct Viewed { - name: String, - // Shared rather than owned outright: the picture view hands these to egui - // on every frame, and a file of thirty megabytes copied sixty times a - // second is two gigabytes a second of nothing. - bytes: std::sync::Arc<[u8]>, - look: Look, - // Split once when the file arrives rather than on every frame: the view is - // drawn a line at a time and the lines have to exist to be counted. - lines: Vec, - // Whether the picture loader made anything of it. Asked once, because a - // failed decode is as expensive as a successful one. - picture: bool, -} - -/// The most a file can be and still be opened for looking at. -/// -/// A viewer holds the whole thing in memory, and the point of it is a glance at -/// a text file or a picture, not reading a database. Past this the answer is to -/// take it out properly, which is what the rest of the window is for. -const VIEW_LIMIT: u64 = 32 * 1024 * 1024; - -/// Whether these bytes are meant to be read as words. -/// -/// Two questions, in the order that settles it fastest. A zero byte is the one -/// thing text almost never has and binary almost always does, so it is asked -/// first and on its own. Failing that, the balance of what is printable: a -/// stray high byte is a name with an accent in it, a run of them is a program. -/// -/// Only the head is read. A file that begins as text and turns into something -/// else halfway down is a file the reader will notice by looking at it. -fn looks_like_text(bytes: &[u8]) -> bool { - let head = &bytes[..bytes.len().min(8192)]; - if head.is_empty() { - return true; - } - if head.contains(&0) { - return false; - } - let odd = head - .iter() - .filter(|b| **b < 0x20 && !matches!(b, b'\t' | b'\n' | b'\r')) - .count(); - odd * 20 < head.len() -} - -/// Whether `name` answers to `mask`, where `*` stands for any run of -/// characters and `?` for exactly one. -/// -/// The same two wildcards WinRAR and the command line have always used, and -/// nothing else: a mask is something people type in a hurry and a language with -/// character classes in it would turn a typo into a silent mismatch. Case is -/// ignored, because Windows ignores it and the names came off a Windows disk. -/// -/// Written as a walk with one point of backtracking rather than as a recursion: -/// `*` is the only thing that can be taken back, so remembering where the last -/// one was and how far it had eaten is the whole of it. That is what keeps a -/// mask of nothing but stars from taking exponential time on a long name. -fn matches_mask(mask: &str, name: &str) -> bool { - let m: Vec = mask.to_lowercase().chars().collect(); - let n: Vec = name.to_lowercase().chars().collect(); - let (mut i, mut j) = (0usize, 0usize); - // Where to come back to: the star, and the character after which it had - // eaten everything up to. - let mut star: Option<(usize, usize)> = None; - - while j < n.len() { - match m.get(i) { - Some('*') => { - star = Some((i, j)); - i += 1; - } - Some('?') => { - i += 1; - j += 1; - } - Some(c) if *c == n[j] => { - i += 1; - j += 1; - } - // No match here. If a star is behind us it can swallow one more - // character and we try again from there; if not, there is nothing - // left to try. - _ => match star { - Some((si, sj)) => { - i = si + 1; - j = sj + 1; - star = Some((si, sj + 1)); - } - None => return false, - }, - } - } - // Trailing stars match the empty rest of the name; anything else does not. - m[i..].iter().all(|c| *c == '*') -} - -/// One row of the folder tree, ready to be drawn. -struct Twig<'a> { - name: &'a str, - path: String, - depth: usize, - kids: bool, - open: bool, - // The archive itself rather than a folder inside it, which gets the icon - // the desktop puts on a .zip. - archive: bool, -} - -/// How tall a row of the tree is. Taller than a line of text, because this is a -/// list of places to press rather than a paragraph to read. -const TWIG_HEIGHT: f32 = 26.0; - -/// Draws one row of the folder tree and says what was pressed. -/// -/// The whole width answers, not the word: a navigation pane where only the -/// letters are a target is a pane you have to aim at, and the highlight that -/// says where you are should reach both edges or it reads as a button that -/// happens to be lit. That is how the Explorer's own pane behaves. -/// -/// The chevron on the right belongs to whether the folder is unfolded and -/// nothing else. Pressing the name takes you there whether it is unfolded or -/// not, which is the difference between a tree you can walk and one you have to -/// open first. -fn twig( - ui: &mut egui::Ui, - icons: &mut HashMap>, - twig: &Twig<'_>, - here: &str, -) -> egui::Response { - let full = ui.available_width(); - let (rect, resp) = ui.allocate_exact_size(egui::vec2(full, TWIG_HEIGHT), egui::Sense::click()); - let on = here == twig.path; - let fill = if on { - ui.visuals().selection.bg_fill - } else if resp.hovered() { - ui.visuals().widgets.hovered.bg_fill - } else { - egui::Color32::TRANSPARENT - }; - let ink = if on { - ui.visuals().selection.stroke.color - } else { - ui.visuals().widgets.noninteractive.fg_stroke.color - }; - if fill != egui::Color32::TRANSPARENT { - ui.painter() - .rect_filled(rect, egui::Rounding::same(4.0), fill); - } - - // Each level a thumb further in, and the icon always at the same distance - // from the name, so a column of names reads as a column. - let inset = 8.0 + twig.depth as f32 * 14.0; - let mid = rect.center().y; - let icon = egui::Rect::from_center_size( - egui::pos2(rect.left() + inset + 8.0, mid), - egui::Vec2::splat(16.0), - ); - let name = if twig.archive { - "archive.zip" - } else { - "folder" - }; - match system_icon(ui.ctx(), icons, name, !twig.archive) { - Some(tex) => { - egui::Image::new(&tex).paint_at(ui, icon); - } - None => draw_icon_at( - ui, - icon, - if twig.archive { - Kind::Archive - } else { - Kind::Dir - }, - ), - } - - ui.painter().text( - egui::pos2(icon.right() + 8.0, mid), - egui::Align2::LEFT_CENTER, - twig.name, - egui::TextStyle::Body.resolve(ui.style()), - ink, - ); - - // A chevron only where there is something folded up behind it, turned down - // once it is open, at the far edge where every pane on this machine puts - // the thing that says "there is more". - if twig.kids { - let c = egui::pos2(rect.right() - 14.0, mid); - let (w, h) = (3.5, 5.0); - let points = if twig.open { - vec![ - egui::pos2(c.x - h, c.y - w * 0.6), - egui::pos2(c.x + h, c.y - w * 0.6), - egui::pos2(c.x, c.y + w), - ] - } else { - vec![ - egui::pos2(c.x - w * 0.6, c.y - h), - egui::pos2(c.x - w * 0.6, c.y + h), - egui::pos2(c.x + w, c.y), - ] - }; - ui.painter().add(egui::Shape::convex_polygon( - points, - ink.gamma_multiply(0.7), - egui::Stroke::NONE, - )); - } - resp -} - -/// One level of the folder tree, and everything under it. -fn branch( - ui: &mut egui::Ui, - icons: &mut HashMap>, - folder: &tree::Folder, - prefix: &str, - depth: usize, - here: &str, - go: &mut Option, -) { - for (name, kid) in &folder.kids { - let path = format!("{prefix}{name}/"); - let id = ui.make_persistent_id(&path); - let mut state = - egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false); - let open = state.is_open(); - let row = Twig { - name, - path: path.clone(), - depth, - kids: !kid.is_empty(), - open, - archive: false, - }; - let resp = twig(ui, icons, &row, here); - if resp.clicked() { - // The chevron is its own target: the last stretch of the row folds - // and unfolds, and the rest of it goes there. - let at_end = ui - .input(|i| i.pointer.interact_pos()) - .is_some_and(|p| p.x > resp.rect.right() - 28.0); - if row.kids && at_end { - state.toggle(ui); - } else { - *go = Some(path.clone()); - } - } - if open && row.kids { - branch(ui, icons, kid, &path, depth + 1, here, go); - } - } -} - -/// What an entry is called after a move. -/// -/// `moves` are pairs of paths without their trailing slash. A file matches one -/// of them outright; a folder matches in two more ways, because the archive may -/// carry an entry for the folder itself with a slash on the end and it -/// certainly carries everything filed under it. All three have to move together -/// or the branch comes apart. -/// -/// Anything that matches nothing keeps its name, which is most of the archive: -/// this is asked of every entry in it. -/// The same name with the separators a zip is supposed to use. -/// -/// The format says forward slashes and most tools write them, but Windows's own -/// Compress-Archive writes backslashes, and the window works in the spelling it -/// shows. Comparing one against the other silently matched nothing: renaming -/// and moving inside a folder did nothing at all in those archives. -fn slashed(name: &str) -> String { - name.replace('\\', "/") -} - -fn moved_name(name: &str, moves: &[(String, String)]) -> String { - // Compared, and written out again, in the spelling the window works in. - let name = slashed(name); - for (from, to) in moves { - if name == *from { - return to.clone(); - } - let under = format!("{from}/"); - if name == under { - return format!("{to}/"); - } - if let Some(rest) = name.strip_prefix(&under) { - return format!("{to}/{rest}"); - } - } - name -} - -/// Seconds as a clock: `0:07`, `1:38`, `2:05:11`. -/// -/// Minutes and seconds until there are hours, and no leading zero on the -/// largest part: a job that says `0:00:07` is a job whose progress window was -/// designed for a job that takes hours. -fn clock(seconds: f64) -> String { - // A guess of a hundred hours is not a guess; anything past this is capped - // rather than shown, and NaN falls to nothing rather than to a panic. - let whole = if seconds.is_finite() { - seconds.clamp(0.0, 359_999.0) as u64 - } else { - 0 - }; - let (h, m, s) = (whole / 3600, (whole / 60) % 60, whole % 60); - if h > 0 { - format!("{h}:{m:02}:{s:02}") - } else { - format!("{m}:{s:02}") - } -} - -/// Where the newest release is announced, and where somebody is sent to get it. -const RELEASES_API: &str = "https://api.github.com/repos/THIONG/arca/releases/latest"; -const RELEASES_PAGE: &str = "https://github.com/THIONG/arca/releases/latest"; - -/// The most an installer is allowed to weigh. Four and a half megabytes today, -/// so ten times that is room to grow into and still a number that says no to -/// anything that is not an installer. -const INSTALLER_LIMIT: usize = 64 * 1024 * 1024; - -/// What the announcement says, of the little this needs from it. -/// -/// The name of the version, and where to get the installer and the file of -/// checksums that vouches for it. Either of those can be missing -- a release -/// put together by hand, an older one from before there was an installer -- and -/// then there is nothing to fetch and the only thing left to offer is the page. -#[derive(Clone)] -struct Release { - tag: String, - installer: Option, - sums: Option, -} - -/// Reads the announcement. -/// -/// Hand written rather than a JSON library, because this asks three questions -/// of one reply and the answers are short strings. A parser for the whole -/// language would be a dependency, and a large one, for that. -/// -/// The addresses are picked by what they end in rather than by walking the list -/// of assets: the shape of that list is GitHub's to change, but a file called -/// `SHA256SUMS.txt` is called that because we named it. -fn release_of(reply: &str) -> Option { - let tag = tag_of(reply)?; - let mut installer = None; - let mut sums = None; - for piece in reply.split("\"browser_download_url\"").skip(1) { - let Some(open) = piece.find('"').and_then(|c| piece.get(c + 1..)) else { - continue; - }; - let Some(close) = open.find('"') else { - continue; - }; - let url = &open[..close]; - // Only ours, and only over the wire we trust. A reply that names some - // other place is not one to go and fetch an executable from. - if !url.starts_with("https://github.com/THIONG/arca/releases/download/") { - continue; - } - if url.ends_with("/SHA256SUMS.txt") { - sums = Some(url.to_string()); - } else if url.ends_with("-x86_64.exe") && url.contains("/arca-setup-") { - installer = Some(url.to_string()); - } - } - Some(Release { - tag, - installer, - sums, - }) -} - -/// The line for `name` in a `sha256sum` listing, as raw bytes. -/// -/// Two spellings, because that is what the tool writes: two spaces for a file -/// it read as text and a space and a star for one it read as binary. The -/// Windows halves of our own releases come out with the star. -fn sum_for(listing: &str, name: &str) -> Option<[u8; 32]> { - for line in listing.lines() { - let (hash, rest) = line.split_once(' ')?; - let named = rest.trim_start_matches([' ', '*']); - if named != name || hash.len() != 64 { - continue; - } - let mut out = [0u8; 32]; - for (i, byte) in out.iter_mut().enumerate() { - *byte = u8::from_str_radix(hash.get(i * 2..i * 2 + 2)?, 16).ok()?; - } - return Some(out); - } - None -} - -/// Whether this copy of Arca was put here by the installer. -/// -/// Inno Setup leaves its uninstaller in the folder it installed to, so that -/// file being next to the program is the program saying how it got there. A -/// copy unpacked from the .zip has no uninstaller and nothing to update: for -/// that one the only honest offer is the page. -fn installed_by_setup() -> bool { - std::env::current_exe() - .ok() - .and_then(|exe| exe.parent().map(|d| d.join("unins000.exe"))) - .is_some_and(|u| u.exists()) -} - -/// Pulls the release's name out of what the announcement page answered. -/// -/// Hand written rather than a JSON library, because this asks one question of -/// one field and the answer is a short string. A parser for the whole language -/// would be a dependency, a build, and a surface, all so that a version number -/// could be read once when the window opens. -/// -/// What it must not do is find the wrong `tag_name`. There is only one at the -/// top level of that reply, so the first is the right one; anything unexpected -/// gives nothing, and nothing means the window says nothing. -fn tag_of(reply: &str) -> Option { - let at = reply.find("\"tag_name\"")? + "\"tag_name\"".len(); - let rest = reply.get(at..)?; - let colon = rest.find(':')?; - let after = rest.get(colon + 1..)?; - let open = after.find('"')?; - let value = after.get(open + 1..)?; - let close = value.find('"')?; - let tag = value.get(..close)?.trim(); - // A name of nothing, or one long enough to be somebody being funny, is not - // a version. - (!tag.is_empty() && tag.len() <= 32).then(|| tag.to_string()) -} - -/// Whether `latest` is a later version than `running`. -/// -/// Numbers separated by dots, a leading `v` forgiven, and compared a part at a -/// time rather than as text: as text, `0.10.0` comes before `0.9.0` and the -/// window would either nag for ever or never say anything at all. -/// -/// A version with something after the numbers -- `0.6.0-rc1` -- counts as -/// earlier than the plain one, which is what those names mean everywhere. And -/// anything that is not a version at all answers no: silence is the right -/// behaviour for an announcement nobody can read. -fn newer(running: &str, latest: &str) -> bool { - fn parts(v: &str) -> Option<(Vec, bool)> { - let v = v.trim().trim_start_matches(['v', 'V']); - if v.is_empty() { - return None; - } - let (numbers, tail) = match v.find(['-', '+']) { - Some(cut) => (&v[..cut], true), - None => (v, false), - }; - let mut out = Vec::new(); - for piece in numbers.split('.') { - out.push(piece.parse::().ok()?); - } - (!out.is_empty()).then_some((out, tail)) - } - - let (Some((mine, mine_tail)), Some((theirs, theirs_tail))) = (parts(running), parts(latest)) - else { - return false; - }; - // Missing parts count as zero, so 0.6 and 0.6.0 are the same version. - let deep = mine.len().max(theirs.len()); - for i in 0..deep { - let a = mine.get(i).copied().unwrap_or(0); - let b = theirs.get(i).copied().unwrap_or(0); - if a != b { - return b > a; - } - } - // The same numbers: the one without a suffix is the finished one. - mine_tail && !theirs_tail -} - -/// Whether a name is a folder's, which in a zip is the slash on the end of it -/// and nothing else. Both slashes, because archives from Windows use theirs. -fn is_folder_name(name: &str) -> bool { - name.ends_with('/') || name.ends_with('\\') -} - -/// The folder an entry is filed in, without the name on the end. Empty at the -/// root, which is where the archive itself is. -fn folder_of(path: &str) -> &str { - match path.trim_end_matches('/').rsplit_once('/') { - Some((parent, _)) => parent, - None => "", - } -} - -/// The way out of a folder: the row every file list keeps at the top, spelt the -/// way every file list spells it. -/// -/// It stands for the folder above and nothing else. There is no entry behind -/// it, so it cannot be picked, weighed, renamed or taken out, and the list -/// leaves it at the top however it is sorted. -fn up_row(dir: &str) -> Row { - Row { - label: "..".to_string(), - path: parent_of(dir), - kind: Kind::Dir, - is_dir: true, - entry: None, - size: 0, - packed: 0, - method: "", - encrypted: false, - count: 0, - mtime: None, - created: None, - accessed: None, - attributes: 0, - crc32: 0, - up: true, - } -} - -/// How wide `text` comes out in `style`, laid out on one line. -fn wide_of(ui: &egui::Ui, text: &str, style: egui::TextStyle) -> f32 { - ui.fonts(|f| { - f.layout_no_wrap( - text.to_owned(), - style.resolve(ui.style()), - egui::Color32::PLACEHOLDER, - ) - .size() - .x - }) -} - -/// What a column would have to be to hold everything in it without cutting -/// anything off. -/// -/// Measured over the rows on screen -- which is the folder you are looking at, -/// filter and all -- rather than over the whole archive, because that is the -/// list the column is being fitted to. Every cell is measured in the style it -/// is drawn in: the numbers are monospaced and a monospaced digit is wider than -/// a proportional one, so measuring them all as body text would fit a column -/// that then cuts off its own contents. -fn natural_width(ui: &egui::Ui, rows: &[Row], which: SortColumn, s: &Strings) -> f32 { - use egui::TextStyle::{Body, Monospace}; - let pad = ui.spacing().item_spacing.x * 2.0; - let widest = |style: egui::TextStyle, of: &dyn Fn(&Row) -> String| -> f32 { - rows.iter() - .map(|r| wide_of(ui, &of(r), style.clone())) - .fold(0.0_f32, f32::max) - }; - match which { - // The icon and the gap after it are part of what the name column has to - // hold, so they are part of what it is fitted to. - SortColumn::Name => { - let head = wide_of(ui, s.col_name, Body) + 20.0; - (widest(Body, &|r| r.label.clone()) + 19.0 + pad).max(head) - } - SortColumn::Size => widest(Monospace, &|r| human(r.size)) + pad, - SortColumn::Packed => widest(Monospace, &|r| human(r.packed)) + pad, - SortColumn::Method => { - widest(Body, &|r| { - if r.is_dir { - format!("{} {}", r.count, s.items_word) - } else if r.encrypted { - format!("AES-256 {}", r.method) - } else { - r.method.to_string() - } - }) + pad - } - SortColumn::Saved => widest(Monospace, &|_| "100%".to_string()) + pad, - SortColumn::Modified => widest(Monospace, &|r| when(r.mtime)) + pad, - SortColumn::Crc => widest(Monospace, &|_| "FFFFFFFF".to_string()) + pad, - // Fitted to the heading alone. The words come from the shell one - // extension at a time and measuring them here would ask it about every - // row in the folder before the column could be sized. - SortColumn::Type => wide_of(ui, s.col_type, Body) + pad, - SortColumn::Path => widest(Body, &|r| folder_of(&r.path).to_string()) + pad, - SortColumn::Created => widest(Monospace, &|r| when(r.created)) + pad, - SortColumn::Accessed => widest(Monospace, &|r| when(r.accessed)) + pad, - SortColumn::Attributes => wide_of(ui, "RHSA", Monospace) + pad, - } -} - -/// The name of a row, opened for editing where it stands. -/// -/// In the row rather than in a dialog, because that is where the name is and -/// where the eye already is: WinRAR and the Explorer both do it here. Enter -/// keeps what was typed, Escape throws it away, and so does clicking somewhere -/// else -- a rename abandoned by looking away has to be abandoned, not left -/// half open on a row nobody is looking at any more. -/// -/// `fresh` is true on the first frame only. That is when the box takes the -/// keyboard and picks out the part of the name before the extension, which is -/// the part anybody renaming a file means to change; the extension stays behind -/// the cursor, ready to be kept. -fn name_box( - ui: &mut egui::Ui, - typing: &std::cell::RefCell, - fresh: &std::cell::Cell, - finish: &std::cell::Cell>, -) { - let id = egui::Id::new("arca-rename"); - let mut text = typing.borrow_mut(); - let field = ui.add( - egui::TextEdit::singleline(&mut *text) - .id(id) - .desired_width(ui.available_width()) - .vertical_align(egui::Align::Center), - ); - if fresh.get() { - field.request_focus(); - if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { - // The stem, or the whole thing when there is no extension to keep - // out of the way. A leading dot is not an extension, it is how a - // file asks to be left alone. - let stem = text.rfind('.').filter(|at| *at > 0).unwrap_or(text.len()); - let upto = text[..stem].chars().count(); - state - .cursor - .set_char_range(Some(egui::text::CCursorRange::two( - egui::text::CCursor::new(0), - egui::text::CCursor::new(upto), - ))); - state.store(ui.ctx(), id); - } - fresh.set(false); - } - if field.lost_focus() { - // Losing the keyboard to Enter is finishing; losing it any other way -- - // Tab, a click elsewhere -- is walking away. - let kept = ui.input(|i| i.key_pressed(egui::Key::Enter)); - finish.set(Some(kept)); - } else if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - finish.set(Some(false)); - } -} - -/// How fast the list should run, in pixels a second, for a pointer `away` -/// pixels from the anchor. Negative runs it up. -/// -/// Nothing at all inside a dead zone, because the wheel is a button too and a -/// hand that presses one moves a pixel or two doing it. Past that it grows -/// with the square of the distance: gently near the anchor, where the point is -/// to read what goes by, and hard further out, where the point is to get to -/// the end. Capped, because past a certain speed the only difference is how -/// blurred it is. -fn wheel_speed(away: f32) -> f32 { - const DEAD: f32 = 12.0; - let past = away.abs() - DEAD; - if past <= 0.0 { - return 0.0; - } - (past * past / 12.0).min(4000.0) * away.signum() -} - -struct Arca { - view: View, - settings: Settings, - archive: Option, - entries: Vec, - checked: Vec, - filter: String, - order: (SortColumn, bool), - channel: Option>, - notice: String, - error: bool, - busy: bool, - done_count: usize, - total_count: usize, - current_file: String, - started: Option, - format: Format, - codec: Codec, - level: Level, - into_subfolder: bool, - pending_inputs: Vec, - output_name: String, - close_when_done: bool, - title: String, - // Si lo que cuenta la barra son bytes en vez de ficheros. Una descarga es - // lo unico que se mide asi, y "2481152 de 4627170" no se lo lee nadie. - in_bytes: bool, - // What the job is being done to: the archive, or the file about to be - // made. Shown under the verb, and put in the title bar of the little - // window a job opens on its own. - subject: String, - current_dir: String, - show_settings: bool, - conflict: Option, - replies: Option>, - // A job held back until the password window has an answer. Extraction asks - // once, before it starts, rather than per entry: every entry in a .zip is - // encrypted with the same password, and asking again per file is noise. - waiting_on_password: Option, - password_input: String, - show_password: bool, - add_password: String, - // Held for the archive currently open in the window, so extracting from it - // does not ask again for every button press. - archive_password: Option, - // Where to look again once a password job has rewritten the archive, and the - // password it now carries. - after_password: Option<(PathBuf, Option)>, - // Where the window has been, so the mouse back and forward buttons have - // somewhere to go. `here` indexes into it; going somewhere new throws away - // whatever was ahead, the way a browser does. - history: Vec, - here: usize, - // The row the keyboard is on. Everything the arrows, Enter and Space do - // hangs off this, and there was no such thing before: the table had - // checkboxes but no cursor. None means nothing is focused yet. - cursor: Option, - // One texture per extension, filled the first time a kind is seen. - icons: HashMap>, - // The desktop's word for each kind of file, by extension. See `system_type`. - types: HashMap>, - // Where a rubber band started, and what was ticked before it did. The - // second is what lets the band be recomputed from scratch every frame, so - // dragging back over a row lets go of it again. - band: Option, - band_base: Vec, - // The row the drag started on, and the scroll position it is dragging the - // list to when it runs off an edge. Row numbers rather than places on - // screen, because the list moves while the drag is happening. - band_anchor: Option, - // Set when a press lands on a row that is already picked: the gesture is - // still ambiguous until it has moved, and this is what it turns into. - drag_ready: Option, - // Set the moment a drag out of the window finishes. `DoDragDrop` runs its - // own loop and swallows the release that ends it, so the toolkit comes back - // still believing the button is held: without this the next frame starts a - // selection band that has already missed its own release and can never be - // ended, and the list stops answering to anything. It clears when the - // button really does come up. - drag_settling: bool, - band_scroll: Option, - // The entry being renamed and what has been typed into it so far. Held by - // path rather than by row number so that sorting or filtering underneath a - // half typed name cannot move the box onto somebody else's row. - renaming: Option<(String, String)>, - // True for the first frame of a rename, when the box has to be given the - // keyboard and the part of the name before the extension picked out. - rename_fresh: bool, - // One password to try before asking, for a folder of archives all locked - // with the same word. Never written anywhere: see `default_password_window`. - default_password: Option, - asking_default_password: bool, - // The version somebody else is running, once it is known to be newer than - // this one, and the way it arrives. Both empty unless there is something - // to say. - update: Option, - update_rx: Option>, - asked_about_updates: bool, - // Set while a job is being shown as a window over the list rather than as - // the whole window. Cleared when the job finishes without a complaint. - overlay: bool, - // The selection while it is in the air: the top of what was picked when - // the drag began. Where it lands is not decided until the button comes up. - carrying: Option>, - // Set while the box that asks for a new folder's name is up, and what has - // been typed into it. - asking_folder: bool, - folder_input: String, - // The archive that has a previous version kept beside it, and the word for - // what was done to it. One step back, which is the one anybody wants: - // deeper than that and the sidecars would pile up. - undo: Option<(PathBuf, &'static str)>, - // Raised to ask whatever is running to stop where it is. Shared with the - // thread doing the work, which reads it every time it reports progress. - stop: std::sync::Arc, - // Raised to hold the work where it is without giving it up. Read in the - // same place as `stop`, which is the end of an entry: a file that has - // started is finished, and nothing new is begun until this comes down. - hold: std::sync::Arc, - // The folders of the archive, rebuilt when a listing arrives rather than - // every frame: it is fifteen hundred paths split on every slash and the - // answer only changes when the archive does. - folders: tree::Folder, - // The file being looked at without taking it out of the archive. - viewing: Option, - // Set while the box that picks a group by name is up: true to add what - // matches to the selection, false to take it away. - picking_group: Option, - // The last mask typed, kept so that picking one group and then another - // does not mean typing it again. - mask: String, - // Where the window is and how big, as of this frame. Kept so that `on_exit` - // has something to write: it is handed no context to ask with. - geometry: Option<[f32; 4]>, - // Set while the wheel is being used to walk the list up and down. - wheel: Option, - // The last row a left click landed on, and when. What tells a second click - // on the same row from the first one of a new pair. - last_click: Option<(usize, f64)>, - // Names waiting on a yes before they are taken out of the archive. There - // is no undo, so this one asks. - confirm_delete: Option>, - // An archive dropped onto an open archive, which is two reasonable things - // at once and so gets asked about rather than guessed at. - confirm_drop: Option>, - // Set when the cursor moves by keyboard, so the table can scroll it into - // view on the next frame and then forget about it. - scroll_to_cursor: bool, - // The folder the last Ctrl+C or Ctrl+X extracted into. The clipboard is - // holding paths inside it, so it stays until the next copy replaces it and - // makes those paths meaningless anyway. - clip_dir: Option, - // What the last Ctrl+X put on the clipboard, so those rows can show it. - cut_names: HashSet, - // Made ready before the extraction runs and armed only when it says the - // clipboard took it, which is what `Message::CutReady` reports. - cut_armed: Option, - cut_pending: Option, - // Whether the window had the keyboard last frame. Getting it back is when a - // paste elsewhere has had its chance to happen. - was_focused: bool, - show_shortcuts: bool, - // Set for work that says nothing while it runs. Copying to the clipboard is - // the only such job: it is over before a bar has finished appearing, and a - // bar that flashes past says less than nothing. - quiet: bool, -} - -impl Arca { - fn new(settings: Settings) -> Self { - Arca { - view: View::Browse, - settings, - archive: None, - entries: Vec::new(), - checked: Vec::new(), - filter: String::new(), - order: (SortColumn::Name, true), - channel: None, - notice: String::new(), - error: false, - busy: false, - done_count: 0, - total_count: 0, - current_file: String::new(), - started: None, - format: Format::Zip, - codec: Codec::Deflate, - level: Level::Normal, - into_subfolder: false, - pending_inputs: Vec::new(), - output_name: String::new(), - close_when_done: false, - title: String::new(), - subject: String::new(), - in_bytes: false, - current_dir: String::new(), - show_settings: false, - conflict: None, - replies: None, - waiting_on_password: None, - password_input: String::new(), - show_password: false, - add_password: String::new(), - archive_password: None, - after_password: None, - history: vec![String::new()], - here: 0, - cursor: None, - icons: HashMap::new(), - types: HashMap::new(), - band: None, - band_base: Vec::new(), - confirm_delete: None, - scroll_to_cursor: false, - clip_dir: None, - cut_names: HashSet::new(), - confirm_drop: None, - band_anchor: None, - drag_ready: None, - drag_settling: false, - band_scroll: None, - renaming: None, - rename_fresh: false, - default_password: None, - asking_default_password: false, - update: None, - update_rx: None, - asked_about_updates: false, - overlay: false, - carrying: None, - asking_folder: false, - folder_input: String::new(), - undo: None, - stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - hold: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - viewing: None, - picking_group: None, - mask: String::new(), - folders: tree::Folder::default(), - geometry: None, - wheel: None, - last_click: None, - cut_armed: None, - cut_pending: None, - was_focused: true, - show_shortcuts: false, - quiet: false, - } - } - - fn s(&self) -> &'static Strings { - strings(self.settings.effective_lang()) - } - - fn level_name(&self, l: Level) -> &'static str { - let s = self.s(); - match l { - Level::Store => s.level_none, - Level::Fast => s.level_fast, - Level::Normal => s.level_normal, - Level::Best => s.level_best, - } - } - - fn codec_name(&self, c: Codec) -> &'static str { - let s = self.s(); - match c { - Codec::Store => s.codec_store, - Codec::Deflate => s.codec_deflate, - Codec::Zstd => s.codec_zstd, - } - } - - fn summary(&self) -> String { - let s = self.s(); - let n = self.entries.iter().filter(|e| !e.is_dir).count(); - let raw: u64 = self.entries.iter().map(|e| e.size).sum(); - let packed: u64 = self.entries.iter().map(|e| e.compressed_size).sum(); - let ratio = if raw == 0 { - 0.0 - } else { - (1.0 - packed as f64 / raw as f64) * 100.0 - }; - format!( - "{n} {} · {} {} · {} {} · {ratio:.1}% {}", - s.files_word, - human(raw), - s.uncompressed_word, - human(packed), - s.in_archive, - s.saved_word - ) - } - - fn visible_rows(&self) -> Vec { - let filter = self.filter.trim().to_lowercase(); - // Flat view: every file in the archive at once, wherever it is filed. - // It is how you find something when you know its name and not its - // folder, and it is the same list a filter builds, only without one. - let flat = self.settings.flat && filter.is_empty(); - let mut rows = if flat { - self.entries - .iter() - .enumerate() - .filter(|(_, e)| !e.is_dir) - .map(|(i, e)| { - let full = e.name.replace('\\', "/"); - Row { - // The leaf here and the folder in its own column, the - // way WinRAR splits them: a column of paths that all - // begin the same way is a column you read the end of. - label: full.rsplit('/').next().unwrap_or(&full).to_string(), - kind: kind_of(&full, false), - path: full, - is_dir: false, - entry: Some(i), - size: e.size, - packed: e.compressed_size, - method: e.method.name(), - encrypted: e.encrypted, - count: 0, - mtime: e.mtime, - created: e.created, - accessed: e.accessed, - attributes: e.attributes, - crc32: e.crc32, - up: false, - } - }) - .collect() - } else if filter.is_empty() { - children_of(&self.entries, &self.current_dir) - } else { - self.entries - .iter() - .enumerate() - .filter(|(_, e)| !e.is_dir && e.name.to_lowercase().contains(&filter)) - .map(|(i, e)| Row { - label: e.name.replace('\\', "/"), - path: e.name.replace('\\', "/"), - kind: kind_of(&e.name, false), - is_dir: false, - entry: Some(i), - size: e.size, - packed: e.compressed_size, - method: e.method.name(), - encrypted: e.encrypted, - count: 0, - mtime: e.mtime, - created: e.created, - accessed: e.accessed, - attributes: e.attributes, - crc32: e.crc32, - up: false, - }) - .collect() - }; - - let (col, asc) = self.order; - rows.sort_by(|x, y| { - if x.is_dir != y.is_dir { - return if x.is_dir { - std::cmp::Ordering::Less - } else { - std::cmp::Ordering::Greater - }; - } - let o = match col { - SortColumn::Name => x.label.to_lowercase().cmp(&y.label.to_lowercase()), - SortColumn::Size => x.size.cmp(&y.size), - SortColumn::Packed => x.packed.cmp(&y.packed), - SortColumn::Method => x.method.cmp(y.method), - SortColumn::Saved => saved_of(x) - .partial_cmp(&saved_of(y)) - .unwrap_or(std::cmp::Ordering::Equal), - SortColumn::Modified => x.mtime.cmp(&y.mtime), - SortColumn::Crc => x.crc32.cmp(&y.crc32), - // By extension, which is what the type is worked out from: - // sorting by the words themselves would need the shell asked - // about every entry in the archive to answer one click. - SortColumn::Type => arca_icons::cache_key(&x.label, x.is_dir) - .cmp(&arca_icons::cache_key(&y.label, y.is_dir)), - SortColumn::Path => folder_of(&x.path).cmp(folder_of(&y.path)), - SortColumn::Created => x.created.cmp(&y.created), - SortColumn::Accessed => x.accessed.cmp(&y.accessed), - SortColumn::Attributes => x.attributes.cmp(&y.attributes), - }; - if asc { - o - } else { - o.reverse() - } - }); - // Put on after the sort, because it belongs at the top whichever column - // the list is held by and whichever way round. - if !flat && filter.is_empty() && !self.current_dir.is_empty() { - rows.insert(0, up_row(&self.current_dir)); - } - rows - } - - /// Where a job about to start is going to be shown, and under what name. - /// - /// Work started from the list stays over the list: what is being worked on - /// is right there behind it, and a window that goes away and comes back - /// loses your place in it. Work started from the Explorer has no list - /// behind it and takes the whole window, which is all there is -- and then - /// the title bar is the only place left to say what is going on, the way - /// every other progress window on the machine does. A taskbar full of - /// windows called "Arca" tells nobody which is which. - /// - /// Every job goes through here, opening a file to look at it included: it - /// is the same thing being reported, and a panel that puts itself away when - /// it is done beats a screen that has to be dismissed. - /// - /// `from_here` es que el trabajo se ha pedido desde esta ventana, con lo - /// que hay detras. Lo que decide el sitio no es que haya un archivo - /// abierto: es si hay algo detras a lo que volver. Bajar una version nueva - /// se pide desde el menu, siempre, y sin esto se llevaba la ventana entera - /// por delante cuando no habia ningun archivo abierto -- una barra de - /// progreso de novecientos setenta de ancho para bajar cuatro megas. - fn show_job(&mut self, ctx: &egui::Context, verb: &str, subject: String, from_here: bool) { - self.title = verb.to_string(); - self.subject = subject; - self.overlay = matches!(self.view, View::Browse) && from_here; - if !self.overlay { - self.view = View::Running; - ctx.send_viewport_cmd(egui::ViewportCommand::Title(if self.subject.is_empty() { - "Arca".to_string() - } else { - format!("{} — {}", self.title, self.subject) - })); - } - } - - /// A fresh pair of flags for a job about to start, handed back so the - /// worker and the window end up holding the same two. - /// - /// Fresh rather than lowered: a thread that was told to stop may still be - /// on its way out, and it must not read the flag the next job is watching. - /// And handed back rather than cloned by the caller, because these used to - /// be replaced inside `spawn`, after the worker had already taken a copy of - /// the old one -- which left Cancel writing to a flag nobody was reading. - fn fresh_flags( - &mut self, - ) -> ( - std::sync::Arc, - std::sync::Arc, - ) { - self.stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - self.hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - (self.stop.clone(), self.hold.clone()) - } - - fn spawn(&mut self, ctx: &egui::Context, total: usize, work: F) - where - F: FnOnce(&Sender) + Send + 'static, - { - let (tx, rx) = channel(); - self.channel = Some(rx); - self.busy = true; - self.quiet = false; - self.error = false; - self.done_count = 0; - self.total_count = total; - self.current_file.clear(); - self.started = Some(Instant::now()); - let ctx = ctx.clone(); - std::thread::spawn(move || { - work(&tx); - ctx.request_repaint(); - }); - } - - // The folders of the archive down the side, the way WinRAR and the Explorer - // both offer one. - // - // It earns its place in a deep archive, where walking to a folder six - // levels down and back is a dozen double clicks. Off by default: in a flat - // archive it would be an empty column taking a fifth of the window. - fn tree_panel(&mut self, ctx: &egui::Context) { - if !self.settings.tree || self.entries.is_empty() { - return; - } - let mut go: Option = None; - let folders = self.folders.clone(); - let here = self.current_dir.clone(); - let s = self.s(); - // Borrowed for the panel and put back, the way the table borrows it: - // the rows want the desktop's own folder icon and the cache is what - // stops that being one question to the shell per row per frame. - let mut icons = std::mem::take(&mut self.icons); - egui::SidePanel::left("tree") - .resizable(true) - .default_width(220.0) - .width_range(140.0..=420.0) - .show(ctx, |ui| { - egui::ScrollArea::both() - .auto_shrink([false, false]) - .show(ui, |ui| { - // The archive itself, named for what it is rather than - // for the file: the path along the top already says - // which archive this is, and here it is a place to go - // back to. - let root = Twig { - name: s.archive_root, - path: String::new(), - depth: 0, - kids: false, - open: false, - archive: true, - }; - if twig(ui, &mut icons, &root, &here).clicked() { - go = Some(String::new()); - } - branch(ui, &mut icons, &folders, "", 1, &here, &mut go); - }); - }); - self.icons = icons; - if let Some(path) = go { - // Going to a folder while the list is showing every file at once is - // asking for that folder, so the flat view gets out of the way - // rather than swallowing the click. - if self.settings.flat { - self.settings.flat = false; - self.settings.save(); - } - self.go_to(path); - } - } - - // Reads the names again in a different code page. - // - // Nothing is written and the archive is not touched: the bytes of every - // name were kept as the archive spells them, and this decides again what - // they mean. Entries the archive marked as UTF-8 are left alone -- there is - // no question about those and reading them any other way would break the - // ones that were right. - // - // The listing goes back to the root afterwards. The folder you were in was - // a path made out of those names, and under a different page it is a path - // that does not exist. - fn reread_names(&mut self, ctx: &egui::Context, page: arca_zip::pages::Page) { - self.settings.page = page; - self.settings.save(); - for e in &mut self.entries { - if e.utf8 { - continue; - } - e.name = arca_zip::pages::decode(&e.raw_name, page); - e.is_dir = e.name.ends_with('/') || e.name.ends_with('\\'); - } - self.folders = tree::folders_of(&self.entries); - self.clear_picked(); - self.cursor = None; - self.current_dir.clear(); - self.history = vec![String::new()]; - self.here = 0; - self.notice = self.summary(); - self.error = false; - ctx.request_repaint(); - } - - // Asks once, when the window opens, whether there is a newer Arca. - // - // On a thread of its own and through its own channel, not the one the jobs - // use: those put the window into its working state, and a question nobody - // asked must not make the window look busy. If the answer never comes -- - // no network, no reply, a machine that says no -- nothing happens and - // nothing is said. There is nothing here worth a complaint. - fn ask_about_updates(&mut self, ctx: &egui::Context) { - if self.asked_about_updates || !self.settings.updates { - return; - } - self.asked_about_updates = true; - let (tx, rx) = channel::(); - self.update_rx = Some(rx); - let ctx = ctx.clone(); - let running = env!("CARGO_PKG_VERSION").to_string(); - std::thread::spawn(move || { - // GitHub turns away anything that does not name itself, and naming - // the program and its version is what a user agent is for. Nothing - // else is sent: no machine, no user, no archive. - let agent = format!("Arca/{running}"); - let Some(reply) = arca_net::get(RELEASES_API, &agent) else { - return; - }; - if let Some(release) = release_of(&reply) { - if newer(&running, &release.tag) { - let _ = tx.send(release); - ctx.request_repaint(); - } - } - }); - } - - // A folder made inside the archive, in the one you are looking at. - // - // Asked for in a box rather than made as "New folder" and renamed after, - // because making it is a rewrite of the whole archive and doing that twice - // for one folder would be silly. - fn new_folder_window(&mut self, ctx: &egui::Context) { - if !self.asking_folder { - return; - } - let s = self.s(); - let mut go = false; - let mut cancel = false; - egui::Window::new(s.new_folder) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) - .show(ctx, |ui| { - ui.add_space(6.0); - ui.label(s.folder_name); - ui.add_space(6.0); - let field = ui.add( - egui::TextEdit::singleline(&mut self.folder_input) - .id(egui::Id::new("arca-new-folder")) - .desired_width(260.0), - ); - if !field.has_focus() && !field.lost_focus() { - field.request_focus(); - } - if field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - go = true; - } - ui.add_space(10.0); - ui.horizontal(|ui| { - if ui.button(s.new_folder).clicked() { - go = true; - } - if ui.button(s.cancel).clicked() { - cancel = true; - } - }); - ui.add_space(4.0); - }); - if cancel || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - self.asking_folder = false; - self.folder_input.clear(); - } - if go { - self.asking_folder = false; - let name = std::mem::take(&mut self.folder_input).trim().to_string(); - let Some(archive) = self.archive.clone() else { - return; - }; - // The same rules a rename lives by: a name is a name and not a - // path, and nothing here is called that already. - if name.is_empty() || name.contains('/') || name.contains('\\') { - self.notice = s.bad_name.to_string(); - self.error = true; - return; - } - if self - .visible_rows() - .iter() - .any(|r| r.label.eq_ignore_ascii_case(&name)) - { - self.notice = fill(s.name_taken, &[("name", &name)]); - self.error = true; - return; - } - self.run_job( - ctx, - Job::NewFolder { - archive, - name: format!("{}{name}/", self.current_dir), - password: self.archive_password.clone(), - }, - ); - } - } - - // A copy of the archive under whatever name is chosen for it. - // - // The one thing to do before a change nobody is sure about, and the reason - // it is here rather than in the file manager is that the archive being - // looked at is the one that gets copied: no going and finding it again. - fn save_copy(&mut self, ctx: &egui::Context) { - let Some(archive) = self.archive.clone() else { - return; - }; - let name = archive - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default(); - let Some(dest) = rfd::FileDialog::new() - .set_file_name(&name) - .set_directory(archive.parent().unwrap_or(Path::new("."))) - .save_file() - else { - return; - }; - if dest == archive { - return; - } - self.run_job(ctx, Job::CopyTo { archive, dest }); - } - - // The password to try on anything that asks for one, so that a folder full - // of archives locked with the same word is opened once and not fifteen - // times. - // - // In memory and nowhere else. It is never written to the settings file: a - // password in plain text beside the theme and the column widths is how an - // encrypted archive stops being encrypted, and a program that offers to - // remember one for you had better be clear about how long "remember" is. - fn default_password_window(&mut self, ctx: &egui::Context) { - if !self.asking_default_password { - return; - } - let s = self.s(); - let mut close = false; - let mut forget = false; - egui::Window::new(s.default_password) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) - .show(ctx, |ui| { - ui.add_space(6.0); - let field = ui.add( - egui::TextEdit::singleline(&mut self.password_input) - .id(egui::Id::new("arca-default-password")) - .password(!self.show_password) - .desired_width(280.0) - .hint_text(s.password_hint), - ); - if !field.has_focus() && !field.lost_focus() { - field.request_focus(); - } - if field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - close = true; - } - ui.checkbox(&mut self.show_password, s.show_password); - ui.add_space(4.0); - ui.label(egui::RichText::new(s.password_kept).weak().small()); - ui.add_space(10.0); - ui.horizontal(|ui| { - if ui.button(s.start).clicked() { - close = true; - } - if ui - .add_enabled( - self.default_password.is_some(), - egui::Button::new(s.remove_password), - ) - .clicked() - { - forget = true; - } - if ui.button(s.cancel).clicked() { - self.asking_default_password = false; - self.password_input.clear(); - } - }); - ui.add_space(4.0); - }); - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - self.asking_default_password = false; - self.password_input.clear(); - } - if forget { - self.default_password = None; - self.asking_default_password = false; - self.password_input.clear(); - self.notice = s.password_forgotten.to_string(); - self.error = false; - } - if close { - let given = std::mem::take(&mut self.password_input); - self.default_password = (!given.is_empty()).then_some(given); - self.asking_default_password = false; - } - } - - // Puts the archive back the way it was before the last change. - // - // A swap of two names, because the version before the change was moved - // aside rather than thrown away. There is one step and no more: taking it - // back leaves nothing to take back, and the sidecar goes with it. - fn undo_last(&mut self, ctx: &egui::Context) { - let Some((archive, _)) = self.undo.take() else { - return; - }; - let keep = undo_path(&archive); - if !keep.exists() { - return; - } - let pw = self.archive_password.clone(); - if let Err(e) = fs::remove_file(&archive).and_then(|_| fs::rename(&keep, &archive)) { - self.notice = e.to_string(); - self.error = true; - return; - } - self.open(ctx, archive); - self.archive_password = pw; - } - - // Whatever is being kept for an undo is thrown away. - // - // Called when the window closes and when the archive is left behind: a file - // called `something.zip.arca-undo` sitting next to somebody's archive after - // the program has gone is litter, whatever it was for. - fn drop_undo(&mut self) { - if let Some((archive, _)) = self.undo.take() { - let _ = fs::remove_file(undo_path(&archive)); - } - } - - // Puts an archive at the top of the recent list. - // - // Ten of them, which is about as many as anybody scans before giving up and - // going to the folder instead, and by path rather than by name so that two - // archives called backup.zip in different places stay two. - fn remember(&mut self, path: &Path) { - let text = path.to_string_lossy().to_string(); - self.settings.recent.retain(|p| *p != text); - self.settings.recent.insert(0, text); - self.settings.recent.truncate(10); - self.settings.save(); - } - - fn open(&mut self, ctx: &egui::Context, path: PathBuf) { - self.archive_password = None; - // Whatever was cut belonged to the listing being replaced, and so did - // whatever the status bar was saying: the summary of the archive being - // closed sat there over the one that had just opened. - self.cut_names.clear(); - self.cut_armed = None; - self.cut_pending = None; - self.notice.clear(); - self.error = false; - // A different archive means the last change is not on the table any - // more, and the copy kept for it is just a file in somebody's folder. - if self.undo.as_ref().is_some_and(|(a, _)| *a != path) { - self.drop_undo(); - } - self.remember(&path); - let ctx2 = ctx.clone(); - self.spawn(ctx, 0, move |tx| { - let m = match list_entries(&path) { - Ok(v) => Message::Listing(path, v), - Err(e) => Message::Failed(e.to_string()), - }; - let _ = tx.send(m); - ctx2.request_repaint(); - }); - } - - // Reading the central directory is enough to know whether the archive is - // encrypted, and costs nothing next to extracting it. Asking here, before - // any work starts, keeps the question on the window's own thread. - fn run_job(&mut self, ctx: &egui::Context, job: Job) { - // One at a time. Two jobs on one archive would be two rewrites of the - // same file racing to be the one that lands. - if self.busy { - return; - } - if let Job::Extract { - archives, - password: None, - .. - } = &job - { - if archives.iter().any(|a| is_encrypted(a)) { - self.password_input.clear(); - self.waiting_on_password = Some(Pending::Extract(Box::new(job))); - self.view = View::Running; - self.title = self.s().extracting.to_string(); - ctx.request_repaint(); - return; - } - } - let s: &'static Strings = self.s(); - let verb = match &job { - Job::Extract { .. } => s.extracting, - Job::Test { .. } => s.testing, - Job::Password { .. } => s.changing_password, - Job::Delete { .. } => s.deleting, - Job::Rename { .. } => s.renaming, - Job::CopyTo { .. } => s.copying_word, - Job::NewFolder { .. } => s.adding, - Job::Move { .. } => s.moving_word, - Job::Compress { .. } => s.compressing, - Job::Add { .. } => s.adding, - Job::Update { .. } => s.update_downloading, - }; - // El unico verbo que lleva un hueco dentro: cual es la version que se - // esta bajando lo sabe el trabajo, no la lista de palabras. - let heading = match &job { - Job::Update { tag, .. } => fill(verb, &[("version", tag)]), - _ => verb.to_string(), - }; - self.in_bytes = matches!(job, Job::Update { .. }); - // Bajar la version nueva se pide desde el menu de esta ventana; lo - // demas puede venir del Explorador, y entonces no hay lista detras. - let from_here = self.archive.is_some() || matches!(job, Job::Update { .. }); - self.show_job(ctx, &heading, subject_of(&job), from_here); - self.close_when_done = !matches!( - job, - Job::Test { .. } - | Job::Password { .. } - | Job::Delete { .. } - | Job::Rename { .. } - | Job::CopyTo { .. } - | Job::NewFolder { .. } - | Job::Move { .. } - | Job::Add { .. } - ); - // The file on disk is about to change, so the listing has to be redone. - if let Job::Password { archive, new, .. } = &job { - self.after_password = Some((archive.clone(), new.clone())); - } - if let Job::Delete { - archive, password, .. - } = &job - { - self.after_password = Some((archive.clone(), password.clone())); - } - if let Job::Add { - archive, password, .. - } = &job - { - self.after_password = Some((archive.clone(), password.clone())); - } - if let Job::Rename { - archive, password, .. - } = &job - { - self.after_password = Some((archive.clone(), password.clone())); - } - // The four that build the archive again leave the old one beside it. - // What is kept here is the word for the change, so that offering to - // take it back can say what it would be taking back. - let words = self.s(); - self.undo = match &job { - Job::Delete { archive, .. } => Some((archive.clone(), words.delete_word)), - Job::Rename { archive, .. } => Some((archive.clone(), words.rename_word)), - Job::Add { archive, .. } => Some((archive.clone(), words.add_to_archive)), - Job::Password { archive, .. } => Some((archive.clone(), words.password_word)), - Job::NewFolder { archive, .. } => Some((archive.clone(), words.new_folder)), - Job::Move { archive, .. } => Some((archive.clone(), words.moving_word)), - _ => None, - }; - - let (reply_tx, reply_rx) = channel::(); - self.replies = Some(reply_tx); - let ctx2 = ctx.clone(); - let (stop, hold) = self.fresh_flags(); - self.spawn(ctx, 0, move |tx| { - let notify = |i: usize, n: usize, name: &str| { - let _ = tx.send(Message::Progress(i, n, name.to_string())); - ctx2.request_repaint(); - // Held right here while it is paused. This is the end of an - // entry, which is the one moment the work is not in the middle - // of something; stopping still gets through, so a paused job - // can be given up on without being let go first. - while hold.load(Ordering::Relaxed) && !stop.load(Ordering::Relaxed) { - std::thread::sleep(std::time::Duration::from_millis(60)); - } - // The answer to "carry on?". Read on every step because that is - // the only place a long job looks up from what it is doing. - !stop.load(Ordering::Relaxed) - }; - // Bajar la version nueva no acaba en un texto que leer sino en un - // fichero que ejecutar, y ejecutarlo cierra Arca. Por eso sale por - // su propio mensaje y no por Done: quien decide instalar es la - // ventana, no este hilo. - if let Job::Update { - installer, sums, .. - } = &job - { - let _ = tx.send(match download_update(installer, sums, s, ¬ify) { - Ok(path) => Message::Downloaded(path), - Err(text) => Message::Failed(text), - }); - ctx2.request_repaint(); - return; - } - let ask = conflict_asker(tx, &ctx2, &reply_rx); - let outcome = run_job_blocking(job, s, ¬ify, &ask); - let _ = tx.send(match outcome { - Ok(text) => Message::Done(text), - Err(text) => Message::Failed(text), - }); - ctx2.request_repaint(); - }); - } - - fn receive(&mut self, ctx: &egui::Context) { - // The answer about a newer version, if it ever came. Its own channel, - // because it is not a job and must not make the window look busy. - if let Some(rx) = &self.update_rx { - if let Ok(release) = rx.try_recv() { - self.update = Some(release); - self.update_rx = None; - } - } - let mut close = false; - let mut finished_ok = false; - if let Some(rx) = &self.channel { - while let Ok(m) = rx.try_recv() { - match m { - Message::Listing(path, mut v) => { - if v.iter().any(|e| e.encrypted) && self.archive_password.is_none() { - // The one already given for everything, if there is - // one. A wrong guess here is no worse than a wrong - // answer to the box: whatever it was tried on says - // so when it fails. - match self.default_password.clone() { - Some(pw) => self.archive_password = Some(pw), - None => { - self.password_input.clear(); - self.archive_password = None; - self.waiting_on_password = Some(Pending::OpenArchive); - } - } - } - // Nothing picked to begin with. It used to be - // everything, which was invisible while the ticks were - // the only sign of it; now that a picked row is painted - // it would open as a wall of blue, and "everything is - // selected" is not what a list means when you open it. - // The buttons that work on the whole archive never - // looked at the ticks anyway. - // The archive was read with the page the format nominally - // means; if this window has been told otherwise, the - // names are read again before anything else looks at - // them. - let page = self.settings.page; - if page != arca_zip::pages::Page::default() { - for e in &mut v { - if !e.utf8 { - e.name = arca_zip::pages::decode(&e.raw_name, page); - e.is_dir = is_folder_name(&e.name); - } - } - } - self.checked = vec![false; v.len()]; - self.folders = tree::folders_of(&v); - self.entries = v; - if let Some(f) = detect(&path) { - self.format = f; - } - // The name of what is open goes where every other - // program puts it, which frees a whole row above the - // list for nothing at all. - ctx.send_viewport_cmd(egui::ViewportCommand::Title(format!( - "{} — Arca", - path.file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default() - ))); - self.archive = Some(path); - self.history = vec![String::new()]; - self.here = 0; - self.current_dir = String::new(); - self.busy = false; - close = true; - } - Message::Conflict(path) => { - self.conflict = Some(path); - } - Message::Progress(done, total, name) => { - self.done_count = done; - self.total_count = total; - self.current_file = name; - } - Message::Done(text) => { - self.notice = text; - self.busy = false; - // Nothing went wrong, so there is nothing to read and - // nothing to dismiss: the window over the list takes - // itself away. - self.overlay = false; - close = true; - finished_ok = true; - } - Message::Failed(text) => { - // Stopping is not failing. Nothing is wrong with the - // archive and there is nothing to report in red: the - // rewrite gave up before it swapped anything. - let quit = text == arca_core::Error::Cancelled.to_string(); - self.notice = if quit { - self.s().stopped.to_string() - } else { - text - }; - self.error = !quit; - self.busy = false; - close = true; - } - // El instalador esta abajo y comprobado. Se lanza en - // silencio y Arca se aparta: Inno Setup cierra el programa - // que va a reemplazar y lo vuelve a abrir al terminar, que - // es como se actualiza algo que se esta ejecutando. - Message::Downloaded(path) => { - self.busy = false; - close = true; - let version = self - .update - .as_ref() - .map(|r| r.tag.clone()) - .unwrap_or_default(); - self.notice = fill(self.s().update_installing, &[("version", &version)]); - match install_update(&path) { - Ok(()) => ctx.send_viewport_cmd(egui::ViewportCommand::Close), - Err(e) => { - self.notice = e; - self.error = true; - } - } - } - Message::CutReady => { - self.cut_pending = self.cut_armed.take(); - } - } - } - } - if close { - self.channel = None; - self.quiet = false; - if !self.entries.is_empty() && self.notice.is_empty() { - self.notice = self.summary(); - } - } - // The archive on disk is not the one that was listed any more. Reopen it - // with the password it now carries, so the browse view shows the new - // state and does not ask for a password it was just handed. - if finished_ok { - if let Some((path, pw)) = self.after_password.take() { - let notice = std::mem::take(&mut self.notice); - self.open(ctx, path); - self.archive_password = pw; - self.notice = notice; - self.view = View::Browse; - } - } - if finished_ok && self.close_when_done && matches!(self.view, View::Running) { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - } - - fn settings_row(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - let s = self.s(); - let mut changed = false; - - ui.label(s.language); - let current = self - .settings - .lang - .map(|l| l.label()) - .unwrap_or(s.theme_system); - egui::ComboBox::from_id_salt("lang") - .selected_text(current) - .width(110.0) - .show_ui(ui, |ui| { - if ui - .selectable_label(self.settings.lang.is_none(), s.theme_system) - .clicked() - { - self.settings.lang = None; - changed = true; - } - for l in Lang::ALL { - if ui - .selectable_label(self.settings.lang == Some(l), l.label()) - .clicked() - { - self.settings.lang = Some(l); - changed = true; - } - } - }); - - ui.add_space(10.0); - ui.label(s.theme); - let theme_label = match self.settings.theme { - ThemePreference::System => s.theme_system, - ThemePreference::Light => s.theme_light, - ThemePreference::Dark => s.theme_dark, - }; - egui::ComboBox::from_id_salt("theme") - .selected_text(theme_label) - .width(100.0) - .show_ui(ui, |ui| { - for (t, label) in [ - (ThemePreference::System, s.theme_system), - (ThemePreference::Light, s.theme_light), - (ThemePreference::Dark, s.theme_dark), - ] { - if ui - .selectable_label(self.settings.theme == t, label) - .clicked() - { - self.settings.theme = t; - ctx.set_theme(t); - changed = true; - } - } - }); - - // The only thing this program does on the network, so it is asked here - // rather than assumed. What goes out is the address of a public page - // and the name and version of this program, which is what any browser - // sends to anyone; what comes back is looked at and thrown away. - ui.separator(); - if ui - .checkbox(&mut self.settings.updates, s.check_updates) - .changed() - { - changed = true; - } - - if changed { - self.settings.save(); - } - } - - fn format_row(&mut self, ui: &mut egui::Ui) { - let s = self.s(); - let codecs: Vec<(Codec, &'static str)> = [Codec::Store, Codec::Deflate, Codec::Zstd] - .into_iter() - .map(|c| (c, self.codec_name(c))) - .collect(); - let levels: Vec<(Level, &'static str)> = - [Level::Store, Level::Fast, Level::Normal, Level::Best] - .into_iter() - .map(|l| (l, self.level_name(l))) - .collect(); - let current_codec = self.codec_name(self.codec); - let current_level = self.level_name(self.level); - let is_zip = self.format == Format::Zip; - - ui.horizontal(|ui| { - ui.label(s.format); - egui::ComboBox::from_id_salt("fmt") - .selected_text(self.format.label()) - .width(90.0) - .show_ui(ui, |ui| { - for f in [Format::Zip, Format::Tar, Format::TarGz] { - ui.selectable_value(&mut self.format, f, f.label()); - } - }); - ui.add_space(8.0); - ui.label(s.compressor); - ui.add_enabled_ui(is_zip, |ui| { - egui::ComboBox::from_id_salt("cdc") - .selected_text(current_codec) - .width(130.0) - .show_ui(ui, |ui| { - for (c, label) in &codecs { - ui.selectable_value(&mut self.codec, *c, *label); - } - }); - }); - ui.add_space(8.0); - ui.label(s.level); - egui::ComboBox::from_id_salt("lvl") - .selected_text(current_level) - .width(100.0) - .show_ui(ui, |ui| { - for (l, label) in &levels { - ui.selectable_value(&mut self.level, *l, *label); - } - }); - }); - } - - // Everything out, beside the archive, without a word. What Alt+W does, and - // the row menu offers the same thing where the hand already is. - fn extract_here(&mut self, ctx: &egui::Context) { - let Some(archive) = self.archive.clone() else { - return; - }; - self.run_job( - ctx, - Job::Extract { - archives: vec![archive], - dest: if self.into_subfolder { - Destination::Subfolder - } else { - Destination::Beside - }, - password: self.archive_password.clone(), - }, - ); - } - - fn ask_extract(&mut self, ctx: &egui::Context, only_checked: bool) { - let s: &'static Strings = self.s(); - let Some(archive) = self.archive.clone() else { - return; - }; - let Some(mut dest) = rfd::FileDialog::new().pick_folder() else { - return; - }; - if self.into_subfolder { - dest = dest.join(archive_stem(&archive)); - } - let wanted: Vec = if only_checked { - self.checked.clone() - } else { - vec![true; self.entries.len()] - }; - let total = wanted.iter().filter(|b| **b).count(); - let pw = self.archive_password.clone(); - self.close_when_done = false; - let (reply_tx, reply_rx) = channel::(); - self.replies = Some(reply_tx); - let ctx2 = ctx.clone(); - let (stop, hold) = self.fresh_flags(); - self.spawn(ctx, total, move |tx| { - let notify = |i: usize, n: usize, name: &str| { - let _ = tx.send(Message::Progress(i, n, name.to_string())); - ctx2.request_repaint(); - // Held right here while it is paused. This is the end of an - // entry, which is the one moment the work is not in the middle - // of something; stopping still gets through, so a paused job - // can be given up on without being let go first. - while hold.load(Ordering::Relaxed) && !stop.load(Ordering::Relaxed) { - std::thread::sleep(std::time::Duration::from_millis(60)); - } - // The answer to "carry on?". Read on every step because that is - // the only place a long job looks up from what it is doing. - !stop.load(Ordering::Relaxed) - }; - let ask = conflict_asker(tx, &ctx2, &reply_rx); - let _ = tx.send( - match extract(&archive, &dest, &wanted, ¬ify, &ask, pw.as_deref()) { - Ok(bytes) => Message::Done(fill( - s.extracted_to, - &[ - ("size", &human(bytes)), - ("dest", &dest.display().to_string()), - ], - )), - Err(e) => Message::Failed(e.to_string()), - }, - ); - }); - } - - // Escape and the Cancel button are the same act, so they go through the - // same code: two copies of this would drift apart the first time one side - // grew a step. - // The names ticked right now, which is what every action that works on a - // selection needs. - fn selected_names(&self) -> Vec { - self.entries - .iter() - .zip(&self.checked) - .filter(|(_, &on)| on) - .map(|(e, _)| e.name.clone()) - .collect() - } - - // The top of what is ticked. A folder with every one of its entries ticked - // stands for all of them, so a copy hands the clipboard one folder instead - // of the fifteen hundred files inside it, and the Explorer pastes a folder - // rather than a heap of loose files. - fn selected_roots(&self) -> Vec { - let names: Vec = self - .entries - .iter() - .map(|e| e.name.replace('\\', "/")) - .collect(); - // Whether everything under a prefix is ticked, worked out once per - // prefix: the same ancestors come round again for every file in a - // folder, and there can be thousands of them. - let mut whole: HashMap = HashMap::new(); - let mut roots: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - - for (i, full) in names.iter().enumerate() { - if !self.checked.get(i).copied().unwrap_or(false) { - continue; - } - let trimmed = full.trim_end_matches('/'); - let mut root = trimmed.to_string(); - // Shortest ancestor first: the outermost folder that is ticked all - // the way down is the one that was meant. - let mut at = 0usize; - while let Some(cut) = trimmed[at..].find('/') { - at += cut + 1; - let prefix = &trimmed[..at]; - let all = *whole.entry(prefix.to_string()).or_insert_with(|| { - names - .iter() - .enumerate() - .filter(|(_, n)| n.starts_with(prefix)) - .all(|(j, _)| self.checked.get(j).copied().unwrap_or(false)) - }); - if all { - root = prefix.trim_end_matches('/').to_string(); - break; - } - } - if seen.insert(root.clone()) { - roots.push(root); - } - } - roots - } - - // Ctrl+C and Ctrl+X. The clipboard carries paths, not archive entries, so - // what is picked is extracted into a folder of its own under the temporary - // directory first and those paths are what the shell is handed. - // - // A cut marks the rows and asks the shell to move rather than copy, which - // is what empties the temporary folder afterwards. It does not take the - // entries out of the archive: nothing tells this window whether the paste - // ever happened, and removing them on the guess that it did would lose them - // for good the moment somebody changed their mind. - fn copy_to_clipboard(&mut self, ctx: &egui::Context, cut: bool) { - let s: &'static Strings = self.s(); - let Some(archive) = self.archive.clone() else { - return; - }; - let roots = self.selected_roots(); - if roots.is_empty() { - return; - } - // A folder of its own per copy, so the paths already on the clipboard - // never end up pointing at something a later copy overwrote. - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - let dir = std::env::temp_dir() - .join("Arca") - .join(format!("clip-{stamp:x}")); - let previous = self.clip_dir.replace(dir.clone()); - let names = self.selected_names(); - // Before the old folder is thrown away further down: an earlier cut - // still waiting was watching for those files to disappear, and this is - // about to delete them itself. - self.cut_armed = None; - self.cut_pending = None; - self.cut_names = if cut { - names.iter().cloned().collect() - } else { - HashSet::new() - }; - // Where each picked thing will land once extracted. Worked out here - // rather than in the thread because it is also what a pending cut has - // to watch, and only a .zip can have entries taken out of it in place. - let landed: Vec = roots - .iter() - .filter_map(|r| arca_core::safe_name(r).ok()) - .map(|r| dir.join(r)) - .collect(); - if cut && detect(&archive) == Some(Format::Zip) { - self.cut_armed = Some(Cut { - archive: archive.clone(), - paths: landed.clone(), - names, - }); - } - - let wanted = self.checked.clone(); - let total = wanted.iter().filter(|b| **b).count(); - let pw = self.archive_password.clone(); - self.close_when_done = false; - let ctx2 = ctx.clone(); - let (stop, hold) = self.fresh_flags(); - self.spawn(ctx, total, move |tx| { - let notify = |i: usize, n: usize, name: &str| { - let _ = tx.send(Message::Progress(i, n, name.to_string())); - ctx2.request_repaint(); - // Held right here while it is paused. This is the end of an - // entry, which is the one moment the work is not in the middle - // of something; stopping still gets through, so a paused job - // can be given up on without being let go first. - while hold.load(Ordering::Relaxed) && !stop.load(Ordering::Relaxed) { - std::thread::sleep(std::time::Duration::from_millis(60)); - } - // The answer to "carry on?". Read on every step because that is - // the only place a long job looks up from what it is doing. - !stop.load(Ordering::Relaxed) - }; - // A folder nobody has seen yet has nothing in it to overwrite, so - // there is no question to put on screen. - let ask = |_: &Path| Answer::Replace; - let outcome = extract(&archive, &dir, &wanted, ¬ify, &ask, pw.as_deref()) - .map_err(|e| e.to_string()) - .and_then(|_| clipboard::set_files(&landed, cut).map(|()| landed.len())); - // Only once the new list is on the clipboard: until that moment the - // old paths are still what a paste would reach for. - if let Some(old) = previous { - let _ = fs::remove_dir_all(old); - } - let _ = tx.send(match outcome { - // Nothing to say. Copying somewhere else does not announce - // itself either, and the rows a cut is holding are already - // faded; what is worth a line is the entries leaving the - // archive, and that has its own. An empty message hands the - // status bar back to the summary of what is open. - Ok(_) => { - if cut { - let _ = tx.send(Message::CutReady); - } - Message::Done(String::new()) - } - Err(why) => Message::Failed(fill(s.clipboard_failed, &[("why", &why)])), - }); - ctx2.request_repaint(); - }); - // After `spawn`, which clears it: this is the one job that runs without - // saying so. - self.quiet = true; - } - - // Whether the cut waiting on a paste has had it. - // - // Windows never says. What it does instead, when the clipboard asked for a - // move rather than a copy, is take the files out of the folder they were - // handed over in, so their absence is the whole of the evidence. It is - // checked when the window gets the keyboard back, because pasting somewhere - // else means having gone somewhere else first. - // - // Only all of them counts. A cut that is half gone is more likely to be a - // paste still running than one that finished, and leaving the entries where - // they are costs nothing: they will still be there next time. Every way - // this can be wrong leaves the archive untouched, which is the side to be - // wrong on when there is no undo. - fn cut_landed(&mut self, ctx: &egui::Context) { - let Some(cut) = &self.cut_pending else { - return; - }; - if self.busy || self.archive.as_ref() != Some(&cut.archive) { - return; - } - if cut.paths.iter().any(|p| p.exists()) { - return; - } - let Some(cut) = self.cut_pending.take() else { - return; - }; - self.cut_names.clear(); - self.run_job( - ctx, - Job::Delete { - archive: cut.archive, - names: cut.names, - password: self.archive_password.clone(), - }, - ); - } - - // What is picked, named the way it should land where it is dropped: the - // folder on screen is the base, so dragging a folder out puts that folder - // down rather than scattering what was inside it. - // - // Windows only, like the drag it answers: dragging out of the window is - // COM, and where there is no COM there is nobody to ask this. - #[cfg(windows)] - fn dragged_files(&self) -> Vec<(Entry, String)> { - let base = &self.current_dir; - self.entries - .iter() - .zip(&self.checked) - .filter(|(e, &on)| on && !e.is_dir) - .map(|(e, _)| { - let full = e.name.replace('\\', "/"); - let rel = full.strip_prefix(base.as_str()).unwrap_or(&full); - (e.clone(), rel.replace('/', "\\")) - }) - .filter(|(_, rel)| !rel.is_empty()) - .collect() - } - - // Dragging the selection out of the window. Blocks until it has been - // dropped or abandoned, because that is what `DoDragDrop` does: the window - // stops repainting for as long as the drag lasts, which nobody sees because - // the pointer is somewhere else by then. - // - // Nothing is extracted here. The shell is handed a list of names and sizes - // and asks for one file at a time while it is dropping, so a drag that is - // thought better of costs nothing, and a drag of six gigabytes starts as - // fast as a drag of one file. - #[cfg(windows)] - fn drag_out(&mut self, ctx: &egui::Context) { - let Some(archive) = self.archive.clone() else { - return; - }; - let picked = self.dragged_files(); - if picked.is_empty() { - return; - } - let items: Vec = picked - .iter() - .map(|(e, name)| arca_drag::Item { - name: name.clone(), - size: e.size, - mtime: e.mtime, - }) - .collect(); - let password = self.archive_password.clone(); - let entries: Vec = picked.into_iter().map(|(e, _)| e).collect(); - let deliver = Box::new(move |i: usize| { - entries - .get(i) - .and_then(|e| extract_one(&archive, e, password.as_deref()).ok()) - }); - // Copy only. Moving would mean taking the entries out of the archive, - // and the one gesture that does that already asks first. - let _ = arca_drag::drag(items, deliver, false); - // However it ended -- dropped, or called off with Escape -- the button - // that started it went up somewhere this window never saw. - self.drag_settling = true; - self.band = None; - self.band_anchor = None; - self.band_scroll = None; - ctx.request_repaint(); - } - - #[cfg(not(windows))] - fn drag_out(&mut self, _ctx: &egui::Context) {} - - // Ctrl+V: whatever files the shell is holding, into the folder on screen. - fn paste_from_clipboard(&mut self, ctx: &egui::Context) { - let Some(archive) = self.archive.clone() else { - return; - }; - let here = fs::canonicalize(&archive).unwrap_or_else(|_| archive.clone()); - // Pasting the archive into itself would have the rewrite reading the - // file it is replacing. - let inputs: Vec = clipboard::files() - .into_iter() - .filter(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()) != here) - .collect(); - if inputs.is_empty() { - self.notice = self.s().clipboard_empty.to_string(); - self.error = true; - return; - } - self.add_files(ctx, inputs); - } - - // Files from anywhere outside into the folder the window is showing. Both - // the paste and the drop end here so they cannot answer the same question - // two different ways. - fn add_files(&mut self, ctx: &egui::Context, inputs: Vec) { - let Some(archive) = self.archive.clone() else { - return; - }; - self.run_job( - ctx, - Job::Add { - archive, - inputs, - dir: self.current_dir.clone(), - codec: self.codec, - level: self.level, - password: self.archive_password.clone(), - }, - ); - } - - // What a drop means depends on what the window is already showing. With - // nothing open there is only one thing it can be, and that is what it has - // always done: open it. With an archive open, dropping a file on it means - // putting the file inside, which is what every other archiver does and what - // opening a second archive over the first never was. - // - // The exception is dropping an archive onto an archive, which is honestly - // both, so it asks instead of picking one and being wrong half the time. - fn dropped(&mut self, ctx: &egui::Context, paths: Vec) { - if paths.is_empty() { - return; - } - self.notice.clear(); - self.error = false; - let open_first = |me: &mut Self, paths: Vec| { - if let Some(p) = paths.into_iter().next() { - me.open(ctx, p); - } - }; - let Some(archive) = self.archive.clone() else { - open_first(self, paths); - return; - }; - let all_archives = paths.iter().all(|p| detect(p).is_some()); - // Only a .zip can be added to in place. With a .tar open there is - // nothing to weigh up: an archive opens, and anything else has to say - // why it cannot go in rather than quietly do nothing. - if detect(&archive) != Some(Format::Zip) { - if all_archives { - open_first(self, paths); - } else { - self.notice = self.s().only_zip_can_change.to_string(); - self.error = true; - } - return; - } - if all_archives { - self.confirm_drop = Some(paths); - return; - } - self.add_files(ctx, paths); - } - - // A drop used to mean one thing and now means another, so while something - // is held over the window it says which. Guessing in silence is what made - // the old behaviour surprising in the first place. - fn drop_hint(&self, ctx: &egui::Context) { - if !matches!(self.view, View::Browse) - || self.busy - || ctx.input(|i| i.raw.hovered_files.is_empty()) - { - return; - } - let s = self.s(); - let text = match &self.archive { - Some(a) if detect(a) == Some(Format::Zip) => fill( - s.drop_to_add, - &[( - "name", - &a.file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default(), - )], - ), - _ => s.drop_here.to_string(), - }; - let screen = ctx.screen_rect(); - let p = ctx.layer_painter(egui::LayerId::new( - egui::Order::Foreground, - egui::Id::new("drop_hint"), - )); - p.rect_filled(screen, 0.0, egui::Color32::from_black_alpha(170)); - p.text( - screen.center(), - egui::Align2::CENTER_CENTER, - text, - egui::FontId::proportional(17.0), - egui::Color32::WHITE, - ); - } - - fn confirm_drop_window(&mut self, ctx: &egui::Context) { - let Some(paths) = self.confirm_drop.clone() else { - return; - }; - let s = self.s(); - let into = self - .archive - .as_ref() - .and_then(|a| a.file_name()) - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default(); - let mut open_it = false; - let mut add_it = false; - let mut cancel = false; - egui::Window::new(s.drop_title) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) - .show(ctx, |ui| { - ui.add_space(6.0); - ui.label(fill(s.drop_question, &[("name", &into)])); - ui.add_space(6.0); - ui.weak(s.dropped_word); - for p in paths.iter().take(8) { - ui.weak(format!( - " {}", - p.file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default() - )); - } - if paths.len() > 8 { - ui.weak(format!(" … {}", paths.len() - 8)); - } - ui.add_space(10.0); - ui.horizontal(|ui| { - // Opening first: it is what the window used to do, so it is - // the answer somebody pressing Enter out of habit expects. - if ui.button(s.open_word).clicked() { - open_it = true; - } - if ui.button(s.add_to_archive).clicked() { - add_it = true; - } - if ui.button(s.cancel).clicked() { - cancel = true; - } - }); - ui.add_space(4.0); - }); - if cancel || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - self.confirm_drop = None; - } - if open_it { - self.confirm_drop = None; - if let Some(p) = paths.into_iter().next() { - self.open(ctx, p); - } - } else if add_it { - self.confirm_drop = None; - self.add_files(ctx, paths); - } - } - - // Windows shortcuts that mean something here. The ones that would need the - // archive to grow a feature it has not got are left out rather than made to - // look present and do nothing. - fn shortcuts(&mut self, ctx: &egui::Context) { - if !matches!(self.view, View::Browse) - || self.busy - || self.confirm_delete.is_some() - || self.confirm_drop.is_some() - { - return; - } - let typing = ctx.memory(|m| m.focused().is_some()); - // Cut, copy and paste never arrive as key presses. egui's winit layer - // recognises those three shortcuts itself and turns them into events of - // their own, returning before the key is passed on, so watching for - // Ctrl+X was watching for something that is never sent: those three did - // nothing from the keyboard and only worked from the row menu. - // - // Cut and copy come back as events. Paste does not: that one is only - // sent when the clipboard has text in it, and a clipboard holding files - // has none, which is exactly the case here. What does still arrive is - // the key going back up, because the early return only covers the press - // -- so that is what a paste is recognised by. - let ( - ctrl, - shift, - o, - e, - t, - n, - f, - f5, - del, - cut, - copy, - paste, - plus, - minus, - alt_w, - undo, - ctrl_p, - ) = ctx.input(|i| { - ( - i.modifiers.command, - i.modifiers.shift, - i.key_pressed(egui::Key::O), - i.key_pressed(egui::Key::E), - i.key_pressed(egui::Key::T), - i.key_pressed(egui::Key::N), - i.key_pressed(egui::Key::F), - i.key_pressed(egui::Key::F5), - i.key_pressed(egui::Key::Delete), - i.events.iter().any(|e| matches!(e, egui::Event::Cut)), - i.events.iter().any(|e| matches!(e, egui::Event::Copy)), - i.events.iter().any(|e| { - matches!( - e, - egui::Event::Key { - key: egui::Key::V, - pressed: false, - modifiers, - .. - } if modifiers.command - ) - }), - i.key_pressed(egui::Key::Plus), - i.key_pressed(egui::Key::Minus), - i.modifiers.alt && i.key_pressed(egui::Key::W), - i.modifiers.command && !i.modifiers.shift && i.key_pressed(egui::Key::Z), - i.modifiers.command && i.key_pressed(egui::Key::P), - ) - }); - - // These three carry their own modifier, so they do not wait behind the - // Ctrl check below. They do belong to the filter box while it has the - // keyboard: that is a text field and they mean something there. - if !typing { - if copy { - // Ctrl+Shift+C is the same event with shift held, which is - // where the names as text went; it is also all a platform - // without a file clipboard can offer. - if shift || !clipboard::AVAILABLE { - let names = self.selected_names(); - if !names.is_empty() { - ctx.copy_text(names.join("\r\n")); - } - } else { - self.copy_to_clipboard(ctx, false); - } - } - if cut && clipboard::AVAILABLE { - self.copy_to_clipboard(ctx, true); - } - if paste && clipboard::AVAILABLE && self.archive.is_some() { - self.paste_from_clipboard(ctx); - } - } - - if f5 && !typing { - if let Some(p) = self.archive.clone() { - let keep = self.archive_password.clone(); - self.open(ctx, p); - self.archive_password = keep; - } - } - // The keypad's plus and minus, which is where WinRAR has kept picking a - // group by name since before there were menus to put it in. Its third - // one, the keypad star for inverting, cannot be told from any other - // asterisk by the toolkit, so that one stays on Ctrl+I alone. - if (plus || minus) && !typing && self.archive.is_some() { - self.picking_group = Some(plus); - } - // Everything out, beside the archive, without asking where. The whole - // point of it is that it is one keystroke: the folder the archive is in - // is where an extraction goes nine times out of ten. - if ctrl_p && !typing { - self.password_input.clear(); - self.asking_default_password = true; - } - // One step back from the last change to the archive, which is the step - // anybody wants: the one they just took by mistake. - if undo && !typing && self.undo.is_some() { - self.undo_last(ctx); - } - if alt_w && !typing { - if let Some(archive) = self.archive.clone() { - self.run_job( - ctx, - Job::Extract { - archives: vec![archive], - dest: if self.into_subfolder { - Destination::Subfolder - } else { - Destination::Beside - }, - password: self.archive_password.clone(), - }, - ); - } - } - if del && !typing && self.archive.is_some() { - let names = self.selected_names(); - if !names.is_empty() { - self.confirm_delete = Some(names); - } - } - if !ctrl { - return; - } - if o { - if let Some(p) = rfd::FileDialog::new() - .add_filter("Archives", &["zip", "tar", "gz", "tgz"]) - .pick_file() - { - self.open(ctx, p); - } - } - if e && self.archive.is_some() { - self.ask_extract(ctx, false); - } - // Verifying an archive is a job this program has had all along, reached - // from the shell menu and from the command line, and from inside the - // window there was no way to ask for it at all. - if t { - if let Some(archive) = self.archive.clone() { - self.run_job( - ctx, - Job::Test { - archive, - only: None, - }, - ); - } - } - if n { - if let Some(files) = rfd::FileDialog::new().pick_files() { - if !files.is_empty() { - self.output_name = quick_output(&files, self.format) - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default(); - self.pending_inputs = files; - self.view = View::Add; - } - } - } - if f { - // Nothing else here is a text box, so handing the keyboard to the - // filter is the whole of "find". - ctx.memory_mut(|m| m.request_focus(egui::Id::new("filter"))); - } - } - - // Opens an entry for looking at, without taking it out of the archive. - // - // Read straight into memory here rather than on a thread. Everything this - // window does on a thread it does because it might take minutes; this is - // capped at a size that comes back in the time between two frames, and a - // progress window that flashes past is worse than a pause nobody notices. - fn view_entry(&mut self, index: usize) { - let Some(archive) = self.archive.clone() else { - return; - }; - let Some(entry) = self.entries.get(index).cloned() else { - return; - }; - let s = self.s(); - if entry.is_dir { - return; - } - if entry.size > VIEW_LIMIT { - self.notice = fill(s.too_big_to_view, &[("size", &human(VIEW_LIMIT))]); - self.error = true; - return; - } - let mut bytes = Vec::with_capacity(entry.size as usize); - if let Err(e) = read_entry( - &archive, - index, - &mut bytes, - self.archive_password.as_deref(), - ) { - self.notice = e.to_string(); - self.error = true; - return; - } - - let name = entry.name.rsplit(['/', '\\']).next().unwrap_or(&entry.name); - // Asked once, and only of the names that claim to be pictures: handing - // every unknown file to a decoder to find out is a decoder run on - // whatever happens to be in the archive. - let picture = looks_like_picture(name) - && image::guess_format(&bytes).is_ok_and(|f| { - image::ImageReader::new(std::io::Cursor::new(&bytes)) - .with_guessed_format() - .is_ok_and(|r| r.format() == Some(f)) - }); - let look = if picture { - Look::Picture - } else if looks_like_text(&bytes) { - Look::Text - } else { - Look::Hex - }; - // Split now, once. The text is drawn a line at a time and only the - // lines on screen are laid out, so a log of a million lines opens as - // fast as a note of three. - let lines = String::from_utf8_lossy(&bytes) - .lines() - .map(|l| l.to_string()) - .collect(); - self.viewing = Some(Viewed { - name: name.to_string(), - bytes: bytes.into(), - look, - lines, - picture, - }); - } - - // The file being looked at, in its own window over the list. - fn viewer_window(&mut self, ctx: &egui::Context) { - let Some(view) = &mut self.viewing else { - return; - }; - let s = i18n::strings(self.settings.lang.unwrap_or_else(Lang::from_system)); - let mut open = true; - egui::Window::new(&view.name) - .open(&mut open) - .collapsible(false) - .resizable(true) - .default_size([760.0, 520.0]) - .show(ctx, |ui| { - ui.horizontal(|ui| { - ui.selectable_value(&mut view.look, Look::Text, s.as_text); - ui.selectable_value(&mut view.look, Look::Hex, s.as_hex); - // Only where there is a picture to show. A tab that says - // "picture" over a text file is a tab that lies. - if view.picture { - ui.selectable_value(&mut view.look, Look::Picture, s.as_picture); - } - ui.separator(); - ui.weak(human(view.bytes.len() as u64)); - }); - ui.separator(); - match view.look { - Look::Picture => { - egui::ScrollArea::both().show(ui, |ui| { - ui.add( - egui::Image::from_bytes( - format!("bytes://{}", view.name), - egui::load::Bytes::Shared(view.bytes.clone()), - ) - .fit_to_original_size(1.0), - ); - }); - } - Look::Text => { - let font = egui::TextStyle::Monospace.resolve(ui.style()); - let tall = ui.text_style_height(&egui::TextStyle::Monospace); - egui::ScrollArea::both().show_rows( - ui, - tall, - view.lines.len(), - |ui, range| { - for line in &view.lines[range] { - ui.add( - egui::Label::new( - egui::RichText::new(line).font(font.clone()), - ) - .wrap_mode(egui::TextWrapMode::Extend), - ); - } - }, - ); - } - Look::Hex => { - let font = egui::TextStyle::Monospace.resolve(ui.style()); - let tall = ui.text_style_height(&egui::TextStyle::Monospace); - let rows = view.bytes.len().div_ceil(16); - egui::ScrollArea::both().show_rows(ui, tall, rows, |ui, range| { - for row in range { - let at = row * 16; - let end = (at + 16).min(view.bytes.len()); - ui.add( - egui::Label::new( - egui::RichText::new(hex_line(at, &view.bytes[at..end])) - .font(font.clone()), - ) - .wrap_mode(egui::TextWrapMode::Extend), - ); - } - }); - } - } - }); - if !open || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - self.viewing = None; - } - } - - // Picking a whole group of files by what they are called: `*.txt`, `nota_?`. - // - // The two keys WinRAR has always had, on the numeric keypad, and the same - // box behind both: one adds what matches to what is picked and the other - // takes it away. It works on what is on screen, so inside a folder it is - // that folder and in the flat view it is the whole archive, which is what - // "what is on screen" means either way. - fn group_window(&mut self, ctx: &egui::Context) { - let Some(adding) = self.picking_group else { - return; - }; - let s = self.s(); - let mut go = false; - let mut cancel = false; - egui::Window::new(if adding { - s.select_group - } else { - s.deselect_group - }) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) - .show(ctx, |ui| { - ui.add_space(6.0); - ui.label(s.mask_hint); - ui.add_space(6.0); - let field = ui.add( - egui::TextEdit::singleline(&mut self.mask) - .id(egui::Id::new("arca-mask")) - .desired_width(260.0), - ); - // The box has the keyboard the moment it opens: this is a thing - // you are typing into, not a thing you are looking at. - if !field.has_focus() && !field.lost_focus() { - field.request_focus(); - } - if field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - go = true; - } - ui.add_space(10.0); - ui.horizontal(|ui| { - if ui - .button(if adding { - s.select_group - } else { - s.deselect_group - }) - .clicked() - { - go = true; - } - if ui.button(s.cancel).clicked() { - cancel = true; - } - }); - ui.add_space(4.0); - }); - if cancel || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - self.picking_group = None; - } - if go { - self.picking_group = None; - let mask = self.mask.trim().to_string(); - if mask.is_empty() { - return; - } - for row in self.visible_rows() { - if matches_mask(&mask, &row.label) { - self.set_checked(&row, adding); - } - } - } - } - - fn confirm_delete_window(&mut self, ctx: &egui::Context) { - let Some(names) = self.confirm_delete.clone() else { - return; - }; - let s = self.s(); - let mut go = false; - let mut cancel = false; - egui::Window::new(s.delete_word) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) - .show(ctx, |ui| { - ui.add_space(6.0); - ui.label(fill(s.confirm_delete, &[("n", &names.len().to_string())])); - ui.add_space(6.0); - // Enough of them to see what is about to go, not so many that - // the window becomes the list itself. - for n in names.iter().take(8) { - ui.weak(format!(" {n}")); - } - if names.len() > 8 { - ui.weak(format!(" … {}", names.len() - 8)); - } - ui.add_space(10.0); - ui.horizontal(|ui| { - if ui.button(s.delete_word).clicked() { - go = true; - } - if ui.button(s.cancel).clicked() { - cancel = true; - } - }); - ui.add_space(4.0); - }); - if cancel || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - self.confirm_delete = None; - } - if go { - self.confirm_delete = None; - if let Some(archive) = self.archive.clone() { - self.run_job( - ctx, - Job::Delete { - archive, - names, - password: self.archive_password.clone(), - }, - ); - } - } - } - - fn cancel_password(&mut self) { - let was_job = matches!(self.waiting_on_password, Some(Pending::Extract(_))); - self.waiting_on_password = None; - self.password_input.clear(); - // Only a job left the window on the running view with nothing running. - if was_job { - self.view = View::Browse; - } - } - - fn password_window(&mut self, ctx: &egui::Context) { - if self.waiting_on_password.is_none() { - return; - } - let s = self.s(); - let setting = matches!(self.waiting_on_password, Some(Pending::NewPassword(_))); - let mut go = false; - let mut cancel = false; - egui::Window::new(if setting { - s.set_password - } else { - s.password_needed - }) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) - .show(ctx, |ui| { - ui.add_space(6.0); - ui.label(if setting { - s.new_password - } else { - s.password_hint - }); - ui.add_space(8.0); - ui.horizontal(|ui| { - // Asked before the field is built. The text edit swallows - // Enter, and asking it afterwards never sees the key, so - // the window could only be dismissed with the mouse. - let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); - let field = ui.add( - egui::TextEdit::singleline(&mut self.password_input) - .password(!self.show_password) - .desired_width(240.0), - ); - field.request_focus(); - if enter { - go = true; - } - ui.checkbox(&mut self.show_password, s.show_password); - }); - ui.add_space(10.0); - ui.horizontal(|ui| { - if ui.button(s.start).clicked() { - go = true; - } - if ui.button(s.cancel).clicked() { - cancel = true; - } - }); - ui.add_space(4.0); - }); - - if cancel { - self.cancel_password(); - return; - } - if go && !self.password_input.is_empty() { - let given = std::mem::take(&mut self.password_input); - match self.waiting_on_password.take() { - Some(Pending::Extract(job)) => { - if let Job::Extract { archives, dest, .. } = *job { - self.run_job( - ctx, - Job::Extract { - archives, - dest, - password: Some(given), - }, - ); - } - } - Some(Pending::CurrentPassword(job)) => { - if let Job::Password { archive, new, .. } = *job { - self.archive_password = Some(given.clone()); - self.run_job( - ctx, - Job::Password { - archive, - current: Some(given), - new, - }, - ); - } - } - Some(Pending::OpenArchive) => self.archive_password = Some(given), - Some(Pending::NewPassword(job)) => { - if let Job::Password { - archive, current, .. - } = *job - { - self.run_job( - ctx, - Job::Password { - archive, - current, - new: Some(given), - }, - ); - } - } - None => {} - } - } - } - - fn conflict_window(&mut self, ctx: &egui::Context) { - let Some(path) = self.conflict.clone() else { - return; - }; - let s = self.s(); - let mut chosen: Option = None; - egui::Window::new(s.conflict_title) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) - .show(ctx, |ui| { - ui.add_space(4.0); - ui.label(s.already_there); - ui.add_space(2.0); - ui.label(egui::RichText::new(&path).monospace().strong()); - ui.add_space(6.0); - ui.label(s.conflict_text); - ui.add_space(10.0); - ui.horizontal(|ui| { - if ui.button(s.yes).clicked() { - chosen = Some(Answer::Replace); - } - if ui.button(s.yes_all).clicked() { - chosen = Some(Answer::ReplaceAll); - } - if ui.button(s.no).clicked() { - chosen = Some(Answer::Skip); - } - if ui.button(s.no_all).clicked() { - chosen = Some(Answer::SkipAll); - } - }); - ui.add_space(4.0); - ui.horizontal(|ui| { - if ui.button(s.rename).clicked() { - chosen = Some(Answer::Rename); - } - if ui.button(s.rename_all).clicked() { - chosen = Some(Answer::RenameAll); - } - ui.separator(); - if ui.button(s.cancel).clicked() { - chosen = Some(Answer::Cancel); - } - }); - ui.add_space(4.0); - }); - if let Some(a) = chosen { - if let Some(tx) = &self.replies { - let _ = tx.send(a); - } - self.conflict = None; - } - } - - // Two rows: what can be done, and where you are. It used to be four, with - // the archive's name on one of its own and the two ticking buttons on - // another, which is a lot of furniture above a list. The name of the file - // moved to the title bar, where the name of the open document goes in every - // other program, and the ticking buttons in beside the counts they act on. - fn toolbar(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - let s = self.s(); - ui.add_space(6.0); - ui.horizontal(|ui| { - // The password window is not modal on its own, so the toolbar behind - // it has to be shut off: a click there would run with a password - // that has not been given yet. - let idle = !self.busy && self.waiting_on_password.is_none(); - let has = self.archive.is_some() && idle; - ui.add_enabled_ui(idle, |ui| { - if tool_button(ui, glyphs::Glyph::Open, s.open, true, "Ctrl+O").clicked() { - if let Some(p) = rfd::FileDialog::new() - .add_filter("Archives", &["zip", "tar", "gz", "tgz"]) - .pick_file() - { - self.open(ctx, p); - } - } - if tool_button(ui, glyphs::Glyph::Compress, s.compress, true, "Ctrl+N").clicked() { - if let Some(files) = rfd::FileDialog::new().pick_files() { - if !files.is_empty() { - self.output_name = quick_output(&files, self.format) - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default(); - self.pending_inputs = files; - self.view = View::Add; - } - } - } - }); - ui.separator(); - if tool_button(ui, glyphs::Glyph::ExtractAll, s.extract_all, has, "Ctrl+E").clicked() { - self.ask_extract(ctx, false); - } - let n = self.checked.iter().filter(|b| **b).count(); - if tool_button( - ui, - glyphs::Glyph::ExtractPicked, - s.extract_selected, - has && n > 0, - "", - ) - .clicked() - { - self.ask_extract(ctx, true); - } - ui.separator(); - // Only a .zip has anywhere to keep a password. - let zip = has && self.format == Format::Zip; - let encrypted = self.entries.iter().any(|e| e.encrypted); - let glyph = if encrypted { - glyphs::Glyph::Unlocked - } else { - glyphs::Glyph::Locked - }; - let tip = if encrypted { - s.remove_password - } else { - s.set_password - }; - if tool_button(ui, glyph, s.password_word, zip, tip).clicked() { - let archive = self.archive.clone().unwrap_or_default(); - if encrypted { - let job = Job::Password { - archive, - current: self.archive_password.clone(), - new: None, - }; - // Cancelling the question when the archive opened leaves us - // without it, so ask again instead of failing halfway - // through the rewrite. - match self.archive_password { - Some(_) => self.run_job(ctx, job), - None => { - self.password_input.clear(); - self.waiting_on_password = - Some(Pending::CurrentPassword(Box::new(job))); - } - } - } else { - self.password_input.clear(); - self.waiting_on_password = - Some(Pending::NewPassword(Box::new(Job::Password { - archive, - current: None, - new: None, - }))); - } - } - // Everything that did not fit, behind one button. That is how a - // command bar stays coherent: every button on it says what it does, - // and the ones there is no room to name go here rather than - // becoming a row of unexplained pictures. - let mut wants = None; - let more = tool_button(ui, glyphs::Glyph::More, s.more_word, idle, ""); - // A dot on the button when there is a newer Arca. The word for it - // is inside the menu, and a notice inside a menu is a notice nobody - // reads: something has to say from the outside that there is - // anything in there worth opening. Small and in the corner, because - // it is news and not a problem. - if self.update.is_some() { - let at = more.rect.right_top() + egui::vec2(-5.0, 5.0); - ui.painter() - .circle_filled(at, 3.5, theme::cursor(ui.visuals()).color); - } - let more_id = egui::Id::new("arca-more-menu"); - if more.clicked() { - ui.memory_mut(|m| m.toggle_popup(more_id)); - } - egui::popup_below_widget( - ui, - more_id, - &more, - egui::PopupCloseBehavior::CloseOnClick, - |ui| { - ui.set_min_width(215.0); - // Con la ventana baja, catorce entradas no caben debajo del - // boton y las ultimas quedaban cortadas por el borde. Se le - // da todo el alto que queda hasta abajo y, si aun asi no - // cabe, se desplaza en vez de perderse. - // - // Un menu de verdad se saldria de la ventana, como el del - // Explorador. Aqui no: egui dibuja dentro de una superficie - // y sacar el menu a una ventana propia dejaria a cada - // submenu -- Recientes, Codificacion -- con este mismo - // problema un nivel mas abajo. - let room = - (ui.ctx().screen_rect().bottom() - ui.min_rect().top() - 14.0).max(120.0); - egui::ScrollArea::vertical() - .max_height(room) - .show(ui, |ui| { - // Only when there is one, and at the top, where something - // that was not there yesterday belongs. - if let Some(release) = self.update.clone() { - if ui - .button(fill(s.update_ready, &[("version", &release.tag)])) - .clicked() - { - wants = Some(More::Release); - } - ui.separator(); - } - if ui - .add_enabled( - has, - egui::Button::new(format!("{}\tCtrl+T", s.test_word)), - ) - .clicked() - { - wants = Some(More::Test); - } - if ui - .add_enabled( - has && self.format == Format::Zip, - egui::Button::new(s.new_folder), - ) - .clicked() - { - wants = Some(More::NewFolder); - } - if ui - .add_enabled(has, egui::Button::new(s.save_copy)) - .clicked() - { - wants = Some(More::SaveCopy); - } - if ui - .button(format!("{} Ctrl+P", s.default_password)) - .clicked() - { - wants = Some(More::DefaultPassword); - } - ui.separator(); - // Named after what it would take back, because "undo" on its - // own asks the reader to remember what they did last. - let back = self - .undo - .as_ref() - .map(|(_, what)| format!("{}: {} Ctrl+Z", s.undo_word, what)) - .unwrap_or_else(|| format!("{} Ctrl+Z", s.undo_word)); - if ui - .add_enabled(self.undo.is_some(), egui::Button::new(back)) - .clicked() - { - wants = Some(More::Undo); - } - ui.separator(); - if ui - .add_enabled( - has, - egui::Button::new(s.flat_view).selected(self.settings.flat), - ) - .clicked() - { - wants = Some(More::Flat); - } - if ui - .add_enabled( - has, - egui::Button::new(s.folder_tree).selected(self.settings.tree), - ) - .clicked() - { - wants = Some(More::Tree); - } - // Only where there is an archive whose names could be read - // another way. A tar has none of this argument. - ui.add_enabled_ui(has && self.format == Format::Zip, |ui| { - ui.menu_button(s.name_encoding, |ui| { - for (page, _, label) in arca_zip::pages::Page::ALL { - let on = self.settings.page == page; - if ui.selectable_label(on, label).clicked() { - wants = Some(More::Page(page)); - ui.close_menu(); - } - } - }); - }); - // The archives opened lately. By name, with the whole path - // on hover: a menu of paths is a menu nobody reads. - ui.add_enabled_ui(!self.settings.recent.is_empty(), |ui| { - ui.menu_button(s.recent_word, |ui| { - for path in self.settings.recent.clone() { - let p = PathBuf::from(&path); - let leaf = p - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_else(|| path.clone()); - if ui.button(leaf).on_hover_text(&path).clicked() { - wants = Some(More::Open(p)); - ui.close_menu(); - } - } - ui.separator(); - if ui.button(s.clear_history).clicked() { - wants = Some(More::Forget); - ui.close_menu(); - } - }); - }); - ui.separator(); - if ui.button(format!("{}\tCtrl+A", s.select_all)).clicked() { - wants = Some(More::All); - } - if ui - .button(format!("{}\tCtrl+I", s.invert_selection)) - .clicked() - { - wants = Some(More::Invert); - } - if ui.button(format!("{}\tEsc", s.clear_selection)).clicked() { - wants = Some(More::None_); - } - ui.separator(); - if ui.button(s.settings).clicked() { - wants = Some(More::Settings); - } - if ui.button(format!("{}\tF1", s.shortcuts_title)).clicked() { - wants = Some(More::Shortcuts); - } - }); - }, - ); - match wants { - Some(More::Test) => { - if let Some(archive) = self.archive.clone() { - self.run_job( - ctx, - Job::Test { - archive, - only: None, - }, - ); - } - } - Some(More::All) => { - let rows = self.visible_rows(); - for r in &rows { - self.set_checked(r, true); - } - } - Some(More::Invert) => { - let rows = self.visible_rows(); - let flipped: Vec = rows.iter().map(|r| !self.is_checked(r)).collect(); - for (r, on) in rows.iter().zip(flipped) { - self.set_checked(r, on); - } - } - Some(More::Flat) => { - self.settings.flat = !self.settings.flat; - // A flat list is a list of names with no folder over them, - // so the folder each one came from has to go somewhere. It - // is left on afterwards: turning the view off and on again - // should not keep undoing a column the user has since - // arranged. - if self.settings.flat && !self.settings.columns.on(SortColumn::Path) { - self.settings.columns.set(SortColumn::Path, true); - } - self.clear_picked(); - self.cursor = None; - self.settings.save(); - } - // Si esta copia la puso el instalador, se baja la nueva y se - // instala sola. Si salio de descomprimir el .zip no hay nada - // que actualizar -- son ficheros sueltos en una carpeta que - // eligio quien los puso -- y lo unico honesto es la pagina. - Some(More::Release) => match self.update.clone() { - Some(Release { - tag, - installer: Some(installer), - sums: Some(sums), - }) if installed_by_setup() => { - self.run_job( - ctx, - Job::Update { - tag, - installer, - sums, - }, - ); - } - _ => { - let _ = launch_with_system(Path::new(RELEASES_PAGE)); - } - }, - Some(More::Undo) => self.undo_last(ctx), - Some(More::NewFolder) => { - self.folder_input.clear(); - self.asking_folder = true; - } - Some(More::SaveCopy) => self.save_copy(ctx), - Some(More::DefaultPassword) => { - self.password_input.clear(); - self.asking_default_password = true; - } - Some(More::Page(p)) => self.reread_names(ctx, p), - Some(More::Tree) => { - self.settings.tree = !self.settings.tree; - self.settings.save(); - } - Some(More::Open(path)) => self.open(ctx, path), - Some(More::Forget) => { - self.settings.recent.clear(); - self.settings.save(); - } - Some(More::None_) => self.clear_picked(), - Some(More::Settings) => self.show_settings = true, - Some(More::Shortcuts) => self.show_shortcuts = true, - None => {} - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // As tall as the buttons beside it, worked out the same way - // they work theirs out, and as wide as whatever is left between - // them and the edge. A thin box floating in a gap looked like - // something that had not finished loading. - let tall = (glyphs::SIZE + ui.spacing().button_padding.y * 2.0) - .max(ui.spacing().interact_size.y); - // Todo el ancho que queda, sin restarle nada: la region en la - // que va la caja empieza ya despues del hueco que separa dos - // cosas cualesquiera de la fila, asi que la separacion esta - // puesta y quitarle otro tanto la deja mas suelta que el resto. - // - // Cuando parecia pegada al boton de al lado no era por falta de - // hueco, era por falta de sitio: la fila no cabia en la ventana - // y a la caja no le quedaban ni cuatro pixeles. Eso lo arregla - // WINDOW_MIN, que es lo ancha que tiene que poder ser la ventana - // para que la fila entera quepa. - let wide = ui.available_width(); - ui.add_sized( - egui::vec2(wide, tall), - egui::TextEdit::singleline(&mut self.filter) - .id(egui::Id::new("filter")) - .vertical_align(egui::Align::Center) - .hint_text(s.filter_hint), - ); - }); - }); - - ui.add_space(4.0); - ui.horizontal(|ui| { - let at_root = self.current_dir.is_empty(); - if tool_button(ui, glyphs::Glyph::Back, "", self.can_go_back(), s.back).clicked() { - self.go_back(); - } - if tool_button( - ui, - glyphs::Glyph::Forward, - "", - self.can_go_forward(), - s.forward, - ) - .clicked() - { - self.go_forward(); - } - if tool_button(ui, glyphs::Glyph::Up, "", !at_root, s.up).clicked() { - let parent = parent_of(&self.current_dir); - self.go_to(parent); - } - ui.add_space(4.0); - // The count is measured and set aside before the path is drawn. - // Laid out the other way round, a deep path took the whole row and - // ran out over the top of it. - let tally = self.archive.is_some().then(|| { - let n = self.checked.iter().filter(|b| **b).count(); - // The way out of the folder is not one of the things in it. - let shown = self.visible_rows().iter().filter(|r| !r.up).count(); - let all = format!( - "{shown} {} {} · {n} {}", - s.visible_of, - self.entries.len(), - s.checked - ); - if n == 0 { - return all; - } - // What is picked, weighed. WinRAR keeps this in the corner of - // its status bar and it is the answer to the question anybody - // is asking before they extract something: how much is this. - let bytes: u64 = self - .entries - .iter() - .zip(&self.checked) - .filter(|(_, &on)| on) - .map(|(e, _)| e.size) - .sum(); - format!("{all} · {}", human(bytes)) - }); - let keep = tally.as_ref().map_or(0.0, |t| { - ui.painter() - .layout_no_wrap( - t.clone(), - egui::TextStyle::Body.resolve(ui.style()), - egui::Color32::PLACEHOLDER, - ) - .size() - .x - + 16.0 - }); - let budget = (ui.available_width() - keep).max(140.0); - self.breadcrumb(ui, budget); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if let Some(t) = tally { - ui.label(egui::RichText::new(t).weak()); - } - }); - }); - ui.add_space(6.0); - } - - // Where you are, as the folders you walked through rather than as one line - // of text with slashes in it. Each one takes you back to that level, which - // is three clicks the Up arrow used to be needed for. - fn breadcrumb(&mut self, ui: &mut egui::Ui, budget: f32) { - if self.archive.is_none() { - return; - } - let root = self - .archive - .as_ref() - .and_then(|a| a.file_name()) - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default(); - let mut crumbs: Vec<(String, String)> = vec![(root, String::new())]; - let mut walked = String::new(); - for part in self.current_dir.split('/').filter(|p| !p.is_empty()) { - walked.push_str(part); - walked.push('/'); - crumbs.push((part.to_string(), walked.clone())); - } - - // A deep archive has more folders in its path than there is room for, - // and they used to run out over the count at the other end. So: measure - // first, keep the ones nearest to where you are, and put the rest - // behind a "…" that opens them as a list. Which is what the Explorer - // does with the same problem. - let font = egui::TextStyle::Body.resolve(ui.style()); - let width = |ui: &egui::Ui, t: &str| { - ui.painter() - .layout_no_wrap(t.to_owned(), font.clone(), egui::Color32::PLACEHOLDER) - .size() - .x - }; - let gap = 3.0; - let sep = width(ui, "›") + gap * 2.0; - let dots = width(ui, "…"); - let sizes: Vec = crumbs.iter().map(|(n, _)| width(ui, n)).collect(); - let first = crumbs_hidden(&sizes, sep, dots, (budget - 22.0).max(60.0)); - - let mut go: Option = None; - egui::Frame::none() - .fill(ui.visuals().window_fill) - .stroke(ui.visuals().widgets.noninteractive.bg_stroke) - .rounding(egui::Rounding::same(5.0)) - .inner_margin(egui::Margin::symmetric(9.0, 3.0)) - .show(ui, |ui| { - ui.spacing_mut().item_spacing.x = gap; - ui.set_max_width(budget); - if first > 0 { - let more = ui.add( - egui::Label::new(egui::RichText::new("…").weak()) - .selectable(false) - .sense(egui::Sense::click()), - ); - let id = egui::Id::new("arca-crumbs"); - if more.clicked() { - ui.memory_mut(|m| m.toggle_popup(id)); - } - egui::popup_below_widget( - ui, - id, - &more, - egui::PopupCloseBehavior::CloseOnClick, - |ui| { - ui.set_min_width(180.0); - for (name, path) in &crumbs[..first] { - if ui.button(name).clicked() { - go = Some(path.clone()); - } - } - }, - ); - ui.add(egui::Label::new(egui::RichText::new("›").weak()).selectable(false)); - } - let last = crumbs.len() - 1; - for (i, (name, path)) in crumbs.iter().enumerate().skip(first) { - if i > first { - ui.add(egui::Label::new(egui::RichText::new("›").weak()).selectable(false)); - } - // The one you are on is not a way to anywhere. - if i == last { - ui.add( - egui::Label::new(egui::RichText::new(name).strong()) - .selectable(false) - .truncate(), - ); - } else if ui - .add( - egui::Label::new(egui::RichText::new(name).weak()) - .selectable(false) - .sense(egui::Sense::click()), - ) - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - go = Some(path.clone()); - } - } - }); - if let Some(path) = go { - self.go_to(path); - } - } - - // Everything the keyboard does, in one place. There was nowhere to find - // this out short of reading the source, and a program whose shortcuts are a - // secret may as well not have them. - fn shortcuts_window(&mut self, ctx: &egui::Context) { - if !self.show_shortcuts { - return; - } - let s = self.s(); - // `open` gives the window its own cross, which is one button fewer at - // the bottom and one row less of height. - let mut open = true; - egui::Window::new(s.shortcuts_title) - .collapsible(false) - .resizable(false) - .open(&mut open) - .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) - .show(ctx, |ui| { - // Two columns side by side. In one column this ran taller than - // the window it belongs to and lost both ends. - // - // The keys are spelled out rather than drawn with the arrows - // and the page symbols: Consolas has the four arrows and not - // the page ones, so half of that line came out as hollow boxes. - let left: [(&str, &str); 19] = [ - ("Ctrl+O", s.open), - ("Ctrl+N", s.compress), - ("Ctrl+E", s.extract_all), - ("Alt+W", s.extract_here), - ("F3 Alt+V", s.view_word), - ("Ctrl+T", s.test_word), - ("F5", s.refresh_word), - ("Ctrl+F", s.find_word), - ("", ""), - ("Ctrl+Z", s.undo_word), - ("Ctrl+P", s.default_password), - ("Ctrl+A", s.select_all), - ("Ctrl+I", s.invert_selection), - ("Esc", s.clear_selection), - ("Space", s.toggle_word), - ("Num + -", s.select_group), - ("F2", s.rename_word), - ("Supr", s.delete_word), - ("F1", s.shortcuts_title), - ]; - let right: [(&str, &str); 12] = [ - ("Ctrl+C", s.copy_word), - ("Ctrl+X", s.cut_word), - ("Ctrl+V", s.paste_word), - ("Ctrl+Shift+C", s.copy_names), - ("", ""), - ("Enter", s.open_word), - ("Backspace", s.up), - ("Alt + \u{2191}", s.up), - ("Alt + \u{2190} \u{2192}", s.back), - ("\u{2191} \u{2193}", s.move_word), - ("Home End", s.move_word), - ("A - Z", s.jump_word), - ]; - let column = |ui: &mut egui::Ui, id: &str, rows: &[(&str, &str)]| { - egui::Grid::new(id) - .num_columns(2) - .spacing(egui::vec2(14.0, 6.0)) - .show(ui, |ui| { - for (key, what) in rows { - if key.is_empty() { - ui.end_row(); - continue; - } - ui.label(egui::RichText::new(*key).monospace().strong()); - ui.label(*what); - ui.end_row(); - } - }); - }; - // Space between the columns rather than a separator: a vertical - // separator inside a horizontal layout grows to the height - // available to it, and inside a window that is the height of - // the screen, which stretched this one until both ends of it - // were off the bottom and the top. - ui.horizontal_top(|ui| { - column(ui, "shortcuts-left", &left); - ui.add_space(28.0); - column(ui, "shortcuts-right", &right); - }); - ui.add_space(2.0); - }); - if !open { - self.show_shortcuts = false; - } - } - - fn settings_window(&mut self, ctx: &egui::Context) { - if !self.show_settings { - return; - } - let s = self.s(); - let mut open = true; - egui::Window::new(s.settings) - .open(&mut open) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) - .show(ctx, |ui| { - ui.add_space(4.0); - ui.horizontal(|ui| { - self.settings_row(ui, ctx); - }); - ui.add_space(10.0); - ui.separator(); - ui.add_space(6.0); - ui.strong(s.defaults_title); - ui.add_space(6.0); - self.format_row(ui); - ui.add_space(6.0); - ui.checkbox(&mut self.into_subfolder, s.into_subfolder); - ui.add_space(10.0); - ui.separator(); - ui.add_space(6.0); - // Which Arca this is. Baked in at build time, so it is the - // version of the program that is running and not of whatever is - // installed somewhere else -- which is the whole point of - // showing it: with two copies on the disk there was no way to - // tell them apart from the inside. Next to it, when there is - // one, the version that is out, so the two numbers that matter - // are in the same sentence. - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(format!("Arca {}", env!("CARGO_PKG_VERSION"))) - .weak() - .small(), - ); - if let Some(release) = &self.update { - ui.label( - egui::RichText::new(format!("· {}", release.tag)) - .small() - .color(theme::cursor(ui.visuals()).color), - ); - } - }); - ui.add_space(6.0); - }); - self.show_settings = open; - } - - fn add_view(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - let s = self.s(); - ui.add_space(10.0); - ui.heading(s.add_to_archive); - ui.add_space(8.0); - ui.horizontal(|ui| { - ui.label(s.output_name); - ui.add(egui::TextEdit::singleline(&mut self.output_name).desired_width(320.0)); - }); - ui.add_space(6.0); - self.format_row(ui); - ui.add_space(6.0); - // Only .zip has anywhere to put encryption, so the field is not offered - // for the other two rather than accepted and quietly ignored. - if self.format == Format::Zip { - ui.horizontal(|ui| { - ui.label(s.password_optional); - ui.add( - egui::TextEdit::singleline(&mut self.add_password) - .password(!self.show_password) - .desired_width(220.0), - ); - ui.checkbox(&mut self.show_password, s.show_password); - }); - ui.add_space(6.0); - } - let count = self.pending_inputs.len(); - ui.label(format!( - "{count} {}", - if count == 1 { - s.one_entry - } else { - s.entries_word - } - )); - ui.add_space(12.0); - ui.horizontal(|ui| { - if ui.button(s.start).clicked() { - let dir = self - .pending_inputs - .first() - .and_then(|p| p.parent()) - .map(PathBuf::from) - .unwrap_or_default(); - let mut name = self.output_name.trim().to_string(); - if name.is_empty() { - name = format!("archive.{}", self.format.extension()); - } - let job = Job::Compress { - out: dir.join(name), - inputs: self.pending_inputs.clone(), - format: self.format, - codec: self.codec, - level: self.level, - password: if self.format == Format::Zip && !self.add_password.is_empty() { - Some(self.add_password.clone()) - } else { - None - }, - }; - self.run_job(ctx, job); - } - if ui.button(s.cancel).clicked() { - self.view = View::Browse; - } - }); - } - - // What is happening, over the list it is happening to. - // - // Everything that takes a while has looked the same until now: the whole - // window turned into a progress bar and had to be dismissed by hand - // afterwards, which for a job of four seconds is three seconds of nothing - // and one of tidying up. This is the shape every other archiver uses -- - // a small window over the work, saying what, how far, how long, and how to - // stop -- and it goes away by itself when the work is done. - /// The inside of the progress window: what is being worked on, how far - /// along it is, and how long it has been going. - /// - /// Drawn the same whether it is the panel over the list or the little - /// window a job opens on its own, because it is the same thing being said. - /// Every line is there on every frame, with or without anything to put in - /// it, so the window does not change height while it works. - fn progress_body(&self, ui: &mut egui::Ui) { - let s = self.s(); - let fraction = if self.total_count == 0 { - 0.0 - } else { - self.done_count as f32 / self.total_count as f32 - }; - // What it is being done to. The verb is in the title, so this is only - // the name, and it is the line that says which of several windows this - // one is. - if !self.subject.is_empty() { - ui.add(egui::Label::new(egui::RichText::new(&self.subject).strong()).truncate()); - ui.add_space(6.0); - } - ui.add( - egui::ProgressBar::new(fraction) - .text(match (self.total_count, self.in_bytes) { - // Sin final que ensenar: un tanto por ciento de nada. - (0, _) => format!("{:.0}%", fraction * 100.0), - // Una descarga cuenta bytes, no ficheros, y "2481152 de - // 4627170" no se lo lee nadie. - (total, true) => format!( - "{} / {}", - human(self.done_count as u64), - human(total as u64) - ), - (total, false) => format!("{} / {}", self.done_count, total), - }) - .desired_width(ui.available_width()), - ); - ui.add_space(5.0); - // The file of the moment, and under it the clock. Both small and quiet: - // they change several times a second and nobody reads them line by - // line, they are there to say it is still moving. - ui.add(egui::Label::new(egui::RichText::new(&self.current_file).weak().small()).truncate()); - ui.add_space(2.0); - ui.horizontal(|ui| { - let held = self.hold.load(std::sync::atomic::Ordering::Relaxed); - if let Some(t) = self.started { - let gone = t.elapsed().as_secs_f64(); - ui.label( - egui::RichText::new(format!("{} {}", s.elapsed_word, clock(gone))) - .weak() - .small(), - ); - // Guessed from how long the part already done took, and only - // once enough of it is done for the guess to be worth reading: - // at two per cent it would say an hour and then a minute. A - // paused job is not going anywhere, so it says nothing. - if self.busy && !held && fraction > 0.05 { - let left = gone / fraction as f64 - gone; - ui.label( - egui::RichText::new(format!("· {} {}", s.time_left, clock(left))) - .weak() - .small(), - ); - } - } - if held { - ui.label( - egui::RichText::new(format!("· {}", s.paused_word)) - .weak() - .small(), - ); - } - }); - } - - /// What can be pressed while a job runs, and what is left when it stops. - /// Answers whether the window should go. - fn progress_buttons(&self, ui: &mut egui::Ui) -> bool { - use std::sync::atomic::Ordering; - let s = self.s(); - let mut close = false; - if self.busy { - let asked = self.stop.load(Ordering::Relaxed); - let held = self.hold.load(Ordering::Relaxed); - ui.horizontal(|ui| { - // Pausing lets go at the end of an entry, not the end of a - // byte, so a file that has started still has to finish. - if ui - .add_enabled( - !asked, - egui::Button::new(if held { s.resume_word } else { s.pause_word }), - ) - .clicked() - { - self.hold.store(!held, Ordering::Relaxed); - } - // A way out of anything that is going to take a while. The - // button goes quiet once it is pressed, because the job is over - // as far as the person pressing it is concerned. - if ui - .add_enabled(!asked, egui::Button::new(s.cancel)) - .clicked() - { - self.stop.store(true, Ordering::Relaxed); - // Let it go first, or the news would sit unread until - // somebody pressed Resume. - self.hold.store(false, Ordering::Relaxed); - } - if asked { - ui.label(egui::RichText::new(s.stopping).weak().small()); - } - }); - } else { - // Only ever reached when something went wrong or was given up on: a - // job that finishes takes this window with it. What it says wraps - // rather than being cut, because it is the reason the window is - // still here. - let (word, color) = if self.error { - (s.failed, ui.visuals().error_fg_color) - } else { - (s.done, ui.visuals().text_color()) - }; - ui.label(egui::RichText::new(word).color(color).strong()); - if !self.notice.is_empty() { - ui.add_space(2.0); - ui.label(&self.notice); - } - ui.add_space(10.0); - if ui.button(s.close).clicked() { - close = true; - } - } - close - } - - /// The job as a panel over the list it was started from. - fn progress_window(&mut self, ctx: &egui::Context) { - if !self.overlay { - return; - } - // The list behind is dimmed rather than left bright: it is not what is - // being asked about, and anything pressed in it would be a second job - // on an archive that is being rewritten. - let screen = ctx.screen_rect(); - ctx.layer_painter(egui::LayerId::new( - egui::Order::PanelResizeLine, - egui::Id::new("arca-dim"), - )) - .rect_filled(screen, 0.0, egui::Color32::from_black_alpha(120)); - - let mut close = false; - egui::Window::new(&self.title) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) - .show(ctx, |ui| { - ui.set_width(400.0); - ui.add_space(2.0); - self.progress_body(ui); - ui.add_space(12.0); - close = self.progress_buttons(ui); - ui.add_space(2.0); - }); - if close { - self.overlay = false; - } - } - - /// The job as the whole window, which is what a job started from the - /// Explorer gets: there is no list behind it to go back to. - fn running_view(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - // No heading here: the title bar of this window already says the verb - // and the name, and saying it twice in a window this small is most of - // the window. - ui.add_space(8.0); - self.progress_body(ui); - ui.add_space(14.0); - if self.progress_buttons(ui) { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - } - - fn rename_to(&mut self, ctx: &egui::Context, rows: &[Row], path: &str, name: &str) { - let Some(row) = rows.iter().find(|r| r.path == path) else { - return; - }; - if name == row.label { - return; - } - let s = self.s(); - if name.is_empty() || name.contains('/') || name.contains('\\') { - self.notice = s.bad_name.to_string(); - self.error = true; - return; - } - // Only against what is in this folder: the same name elsewhere in the - // archive is somebody else's business. - if rows - .iter() - .any(|r| r.path != path && r.label.eq_ignore_ascii_case(name)) - { - self.notice = fill(s.name_taken, &[("name", name)]); - self.error = true; - return; - } - let to = match path.rsplit_once('/') { - Some((parent, _)) => format!("{parent}/{name}"), - None => name.to_string(), - }; - let Some(archive) = self.archive.clone() else { - return; - }; - self.run_job( - ctx, - Job::Rename { - archive, - from: path.to_string(), - to, - folder: row.is_dir, - password: self.archive_password.clone(), - }, - ); - } - - // The rule between two columns, and the handle that moves it. - // - // The handle is a hand's width either side of the rule and only as tall as - // the header, which is where every list of files on the machine puts it. - // The table's own went from the header to the foot of the list, so six - // columns meant six invisible strips down the length of it and a press - // near any of them was a column edge rather than the start of a selection. - // The rule is still drawn the whole way down: that is what tells you which - // number belongs under which heading halfway down a page. - #[allow(clippy::too_many_arguments)] - fn column_edges( - &mut self, - ui: &mut egui::Ui, - heads: &[egui::Rect], - slots: &[usize], - cols: &[SortColumn], - rows: &[Row], - s: &Strings, - top: f32, - foot: f32, - ) { - let Some(first) = heads.first() else { - return; - }; - let grab = ui.style().interaction.resize_grab_radius_side; - // The rule sits down the middle of the gap between two cells. - let half = ui.spacing().item_spacing.x * 0.5; - let quiet = ui.visuals().widgets.noninteractive.bg_stroke; - // Every edge is read off the left of the cell that follows it, never - // off the right of the cell before. A header cell reports a rectangle - // that has been stretched to hold what was drawn in it, so its right - // hand edge wanders past the column and the rules came out scattered - // across the words; its left is where the table put it. - // - // Counted from one, so the edge in hand is the one this cell begins - // with and the column it resizes is the one before. The last column - // has no edge of its own: it ends where the table does. - for (i, cell) in heads.iter().enumerate().skip(1) { - let x = cell.left() - half; - let rect = egui::Rect::from_x_y_ranges((x - grab)..=(x + grab), first.y_range()); - // Clicks as well as drags, so that catching the edge and letting go - // again does not fall through to the heading and sort the list. - let resp = ui.interact( - rect, - egui::Id::new(("arca-column-edge", i)), - egui::Sense::click_and_drag(), - ); - if resp.hovered() || resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeColumn); - } - if resp.dragged() { - if let Some(slot) = slots.get(i - 1).copied() { - if let Some(width) = self.settings.widths.get_mut(slot) { - *width = (*width + resp.drag_delta().x).max(Settings::least(slot)); - } - } - } - // Double clicking an edge fits the column to what is in it, which - // is what the same gesture does in WinRAR and in the Explorer. - // Capped, because one absurd name in a folder of sensible ones - // should not push every other column off the window. - if resp.double_clicked() { - if let (Some(slot), Some(which)) = - (slots.get(i - 1).copied(), cols.get(i - 1).copied()) - { - if let Some(width) = self.settings.widths.get_mut(slot) { - *width = - natural_width(ui, rows, which, s).clamp(Settings::least(slot), 640.0); - } - } - self.settings.save(); - } - // Written when the hand lets go rather than on the way, so that - // pulling an edge across the window is one visit to the disk and - // not one per frame. - if resp.drag_stopped() { - self.settings.save(); - } - let stroke = if resp.dragged() { - ui.visuals().widgets.active.bg_stroke - } else if resp.hovered() { - ui.visuals().widgets.hovered.bg_stroke - } else { - quiet - }; - ui.painter() - .line_segment([egui::pos2(x, top), egui::pos2(x, foot)], stroke); - } - } - - // The selection while it is being carried: what it is over, where it would - // land, and when it stops being this window's business. - // - // The whole window is inside. Leaving it -- which is the pointer going - // somewhere the toolkit stops hearing about -- is what says the selection - // is going to another program, and only then is the system's own drag - // started. That order matters: the system's drag takes the pointer the - // instant it begins, so starting it while still over the list would make - // dropping into a folder of this archive impossible. - fn carry( - &mut self, - ui: &mut egui::Ui, - visible: &[Row], - row_rects: &[(usize, egui::Rect)], - viewport: egui::Rect, - ) { - if self.carrying.is_none() { - return; - } - let (down, at) = ui.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos())); - - // Gone from the window. Whatever happens now happens out there. - let Some(at) = at else { - if down { - let ctx = ui.ctx().clone(); - self.carrying = None; - self.drag_out(&ctx); - } else { - self.carrying = None; - } - return; - }; - - // The folder under the pointer, if it is one and it is not one of the - // things being carried: dropping a folder into itself is not a move. - let carried = self.carrying.clone().unwrap_or_default(); - let over = row_at(row_rects, at.y, visible.len()) - .and_then(|i| visible.get(i)) - .filter(|r| r.is_dir && viewport.contains(at)) - .filter(|r| { - r.up || !carried - .iter() - .any(|c| c.trim_end_matches('/') == r.path.trim_end_matches('/')) - }); - - if !down { - self.carrying = None; - if let Some(row) = over { - let target = row.path.clone(); - let ctx = ui.ctx().clone(); - self.move_into(&ctx, &carried, &target); - } - return; - } - - // While it is in the air: the folder it would go into is outlined, and - // the pointer says what would happen. - ui.ctx().set_cursor_icon(if over.is_some() { - egui::CursorIcon::Grabbing - } else { - egui::CursorIcon::NoDrop - }); - if let Some(row) = over { - if let Some((_, rect)) = row_rects - .iter() - .find(|(i, _)| visible.get(*i).is_some_and(|r| r.path == row.path)) - { - let accent = theme::cursor(ui.visuals()); - ui.painter().rect_stroke(rect.shrink(1.0), 3.0, accent); - } - } - } - - // Moves what was being carried into `target`, which is a folder's path or - // the empty string for the root. - // - // One job for all of it. A move is a rename with a different folder in - // front of it, and a rename is a rewrite of the whole archive: doing them - // one at a time would rewrite it once per file. - fn move_into(&mut self, ctx: &egui::Context, roots: &[String], target: &str) { - let Some(archive) = self.archive.clone() else { - return; - }; - let s = self.s(); - let mut moves: Vec<(String, String)> = Vec::new(); - for root in roots { - let from = root.trim_end_matches('/').to_string(); - let leaf = from.rsplit('/').next().unwrap_or(&from).to_string(); - let to = format!("{}{leaf}", target); - // Already there, or into itself: nothing to do rather than a - // rewrite that changes nothing. - if from == to || to.starts_with(&format!("{from}/")) { - continue; - } - moves.push((from, to)); - } - if moves.is_empty() { - return; - } - // Nothing in the destination may already answer to the name. The - // archive would take it -- a zip can hold the same name twice -- and - // what came out afterwards would be anybody's guess. - let taken: HashSet<&str> = self - .entries - .iter() - .map(|e| e.name.trim_end_matches('/')) - .collect(); - if let Some((_, to)) = moves.iter().find(|(_, to)| taken.contains(to.as_str())) { - let leaf = to.rsplit('/').next().unwrap_or(to); - self.notice = fill(s.name_taken, &[("name", leaf)]); - self.error = true; - return; - } - self.run_job( - ctx, - Job::Move { - archive, - moves, - password: self.archive_password.clone(), - }, - ); - } - - // The wheel used as a button: press it and the list follows the pointer - // until something puts it away. - fn wheel_scroll(&mut self, ui: &mut egui::Ui, viewport: egui::Rect, offset: f32, reach: f32) { - let (pressed, released, here, elsewhere, escaped, spun, dt) = ui.input(|i| { - ( - i.pointer.button_pressed(egui::PointerButton::Middle), - i.pointer.button_released(egui::PointerButton::Middle), - i.pointer.latest_pos(), - i.pointer.button_pressed(egui::PointerButton::Primary) - || i.pointer.button_pressed(egui::PointerButton::Secondary), - i.key_down(egui::Key::Escape), - i.raw_scroll_delta.y != 0.0, - // A frame that took a long time -- the window came back from - // being hidden, say -- would otherwise jump the list a page. - i.stable_dt.min(0.1), - ) - }); - - if pressed { - // Pressing again puts it away, the way it does in a browser. - self.wheel = match self.wheel { - Some(_) => None, - None => here.filter(|p| viewport.contains(*p)).map(|p| Wheel { - anchor: p, - at: offset, - moved: false, - }), - }; - } - let Some(mut wheel) = self.wheel else { - return; - }; - // Any other button, the wheel itself turning, or Escape: all of them - // are somebody asking for something else. - if elsewhere || escaped || spun { - self.wheel = None; - return; - } - let Some(at) = here else { - return; - }; - - let speed = wheel_speed(at.y - wheel.anchor.y); - wheel.moved |= speed != 0.0; - if released && wheel.moved { - // Held down and pulled: the gesture ends where the hand lets go. - // Let go without having pulled and it stays on, waiting. - self.wheel = None; - return; - } - wheel.at = (wheel.at + speed * dt).clamp(0.0, reach); - self.wheel = Some(wheel); - if speed != 0.0 { - // Nothing else on screen is moving, so without this the list would - // take one step per stray mouse event instead of running. - ui.ctx().request_repaint(); - } - - // Windows says which way the list is going with the pointer itself: an - // arrow up while it runs up, down while it runs down, and both ways - // while it stands still. There is no asking the system for those -- - // they live in its own resources, and the toolkit offers one - // double-headed arrow and no way to tell it apart from a resize -- so - // the real pointer is put away here and this one drawn in its place. - // - // On its own layer rather than on the table's, because the hand is free - // to wander off the list while the gesture runs and a pointer that - // vanished at the edge of it would be worse than no pointer at all. - ui.ctx().set_cursor_icon(egui::CursorIcon::None); - let paint = ui.ctx().layer_painter(egui::LayerId::new( - egui::Order::Foreground, - egui::Id::new("arca-wheel"), - )); - - // The anchor, left where the wheel went down: a ring with an arrow out - // of the top and one out of the bottom, which is the mark Windows - // leaves, so it reads as the same gesture rather than as one of ours. - let ink = ui.visuals().weak_text_color(); - paint.circle( - wheel.anchor, - 10.0, - ui.visuals().panel_fill, - egui::Stroke::new(1.0_f32, ink), - ); - paint.circle_filled(wheel.anchor, 1.5, ink); - for up in [1.0_f32, -1.0] { - let tip = wheel.anchor.y - up * 6.5; - paint.add(egui::Shape::convex_polygon( - vec![ - egui::pos2(wheel.anchor.x - 3.0, tip + up * 3.0), - egui::pos2(wheel.anchor.x + 3.0, tip + up * 3.0), - egui::pos2(wheel.anchor.x, tip), - ], - ink, - egui::Stroke::NONE, - )); - } - - // Pale with a dark edge, the way every pointer is drawn: it has to be - // seen over a picked row as easily as over an empty list, and neither - // theme gets a say in what a pointer looks like. - let face = egui::Color32::WHITE; - let edge = egui::Stroke::new(1.0_f32, egui::Color32::from_gray(30)); - let arrow = |down: f32, base: f32, tip: f32| { - egui::Shape::convex_polygon( - vec![ - egui::pos2(at.x - 5.0, at.y + down * base), - egui::pos2(at.x + 5.0, at.y + down * base), - egui::pos2(at.x, at.y + down * tip), - ], - face, - edge, - ) - }; - if speed < 0.0 { - paint.add(arrow(-1.0, 1.0, 11.0)); - } else if speed > 0.0 { - paint.add(arrow(1.0, 1.0, 11.0)); - } else { - // Still: both ways at once, held apart to leave the hot spot clear. - paint.add(arrow(-1.0, 3.0, 12.0)); - paint.add(arrow(1.0, 3.0, 12.0)); - } - } - - // Press on the list and drag: a rectangle follows the pointer and every row - // it touches gets ticked, the way it works in any file list. - // - // It is worked out again from `band_base` on every frame instead of being - // added to as the pointer moves. That is what lets dragging back over a row - // let go of it: growing a selection is easy, shrinking one is what needs - // the starting point remembered. - fn rubber_band( - &mut self, - ui: &mut egui::Ui, - visible: &[Row], - row_rects: &[(usize, egui::Rect)], - viewport: egui::Rect, - offset: f32, - reach: f32, - ) { - let (down, origin, now, ctrl) = ui.input(|i| { - ( - i.pointer.primary_down(), - i.pointer.press_origin(), - i.pointer.interact_pos(), - i.modifiers.command, - ) - }); - - // Something is already in the air. The press that is on the books - // belongs to that gesture, and a band drawn from it would follow the - // pointer around underneath what is being carried. - if self.carrying.is_some() { - return; - } - - // Just back from a drag out of the window, with the button still down - // as far as the toolkit knows. Nothing happens until it comes up for - // real; the press that is still on the books belongs to a gesture that - // is over. - if self.drag_settling { - self.band = None; - self.band_anchor = None; - self.band_scroll = None; - // Cleared by the button coming up, and also by a fresh press, in - // case the release happened over somebody else's window and this - // one never hears about it. Either way the gesture that - // `DoDragDrop` swallowed is over. - if !down || ui.input(|i| i.pointer.any_pressed()) { - self.drag_settling = false; - } - return; - } - - // Something else has the pointer: the handle that resizes a column, or - // the scroll bar. Both are drawn over the list rather than beside it, so - // a press on either lands inside the rows and used to start a band as - // well as doing its own job. A row only senses clicks and can never be - // the thing being dragged, so this cannot turn off what it is for. - if !down || ui.ctx().dragged_id().is_some() { - self.band = None; - self.band_anchor = None; - self.band_scroll = None; - return; - } - // `viewport` is the scrolling part alone, so the header is already out - // of it: dragging a column edge cannot start a selection. The scroll - // bar is a different matter, because it is drawn over the right hand - // edge of that same area rather than beside it, so a press on it lands - // inside the viewport and used to start a band. Dragging the bar is - // scrolling, not picking. - if self.band.is_none() { - let mut room = viewport; - if reach > 0.0 { - let bar = ui.spacing().scroll; - let wide = if bar.floating { - bar.bar_width - } else { - bar.bar_width + bar.bar_inner_margin - }; - room.set_right(viewport.right() - wide - bar.bar_outer_margin); - } - let Some(p) = origin.filter(|p| room.contains(*p)) else { - return; - }; - let Some(anchor) = row_at(row_rects, p.y, visible.len()) else { - return; - }; - // Pressing on a row that is already picked and pulling is how you - // take the selection somewhere else; pressing anywhere else and - // pulling draws a new one. That is the rule in the Explorer, and it - // is the only one that lets both gestures share a button. Ctrl and - // Shift are for adding to a selection, never for carrying it. - let shift = ui.input(|i| i.modifiers.shift); - let picked = anchor < visible.len() && self.is_checked(&visible[anchor]); - self.drag_ready = (picked && !ctrl && !shift).then_some(anchor); - self.band = origin; - self.band_anchor = Some(anchor); - self.band_base = if ctrl { - self.checked.clone() - } else { - vec![false; self.checked.len()] - }; - } - - let (Some(start), Some(here), Some(anchor)) = (self.band, now, self.band_anchor) else { - return; - }; - // A click is a drag of no distance. Under this it is left alone, so - // clicking a row still means clicking a row. - // - // Further than egui waits before calling a drag a drag, on purpose: a - // band that appeared first would flash over the rows for the pixel or - // two between the two thresholds every time a column was resized. - if (here - start).length() < 10.0 { - return; - } - - // Far enough to be a gesture, and it began on something picked: the - // selection is being carried somewhere, not redrawn. - // - // Where it is going is not decided yet. Inside the window it is a move - // into another folder of this archive; outside it is a drag into - // whatever is out there, and that one cannot be started early because - // the moment it is, the system takes the pointer and there is no way - // back into the list. - if self.drag_ready.take().is_some() { - self.band = None; - self.band_anchor = None; - self.band_scroll = None; - self.carrying = Some(self.selected_roots()); - return; - } - - // Past either edge the list follows the pointer, the way the Explorer - // does it. Without this a selection could never be longer than the - // window, because dragging no longer scrolls. - let over = if here.y < viewport.top() { - here.y - viewport.top() - } else if here.y > viewport.bottom() { - here.y - viewport.bottom() - } else { - 0.0 - }; - if over == 0.0 { - self.band_scroll = None; - } else { - self.band_scroll = Some((offset + over.clamp(-24.0, 24.0)).clamp(0.0, reach)); - // Nothing else is moving, so without this the list would take one - // step per stray mouse event instead of running. - ui.ctx().request_repaint(); - } - - // Two row numbers, not a rectangle in window coordinates. The list - // moves underneath while the drag is happening, and a rectangle frozen - // where the button went down stops meaning anything the moment it does; - // it also loses every row that scrolls out of sight, because those are - // the only ones the table still knows the position of. - let head = row_at(row_rects, here.y, visible.len()).unwrap_or(anchor); - let (lo, hi) = if anchor <= head { - (anchor, head) - } else { - (head, anchor) - }; - self.checked.clone_from(&self.band_base); - // Both ends past the last row means the band is entirely in the empty - // space under the list, and has reached nothing. - if lo < visible.len() { - for row in &visible[lo..=hi.min(visible.len() - 1)] { - self.set_checked(row, true); - } - } - - // Drawn from the row the drag began on rather than from the point the - // button went down, so that it stays put against the rows when the list - // scrolls under it. - let anchor_rect = if anchor < visible.len() { - row_rects.iter().find(|(i, _)| *i == anchor) - } else { - None - }; - let edge = match anchor_rect { - // Its far side, so that the row the drag began on falls inside the - // band whichever way the drag then went. - Some((_, r)) => { - if here.y < r.top() { - r.bottom() - } else { - r.top() - } - } - // Begun in the empty space under the list, where there is no row to - // hang the band on, so it hangs from where the button went down. - None if anchor >= visible.len() => start.y, - // Scrolled out of sight, which happens as soon as a drag has run - // far enough for the list to follow it. Which edge to start from is - // decided by where that row went, and that is its number against - // the ones still on screen. Asking where the pointer is instead - // said nothing about the anchor: after dragging to the bottom and - // turning back up, the band was drawn below the pointer, growing - // away from everything it had picked. - None => { - let first = row_rects.first().map(|(i, _)| *i).unwrap_or(anchor); - if anchor < first { - viewport.top() - } else { - viewport.bottom() - } - } - }; - let band = egui::Rect::from_two_pos(egui::pos2(start.x, edge), here); - let fill = ui.visuals().selection.bg_fill.linear_multiply(0.25); - ui.painter().rect( - band.intersect(viewport), - 0.0, - fill, - theme::cursor(ui.visuals()), - ); - } - - fn set_checked(&mut self, row: &Row, value: bool) { - // Nothing behind it, and its path is the folder above: ticking it would - // pick everything in the archive up to and including where you came - // from. Select all has to leave it alone. - if row.up { - return; - } - match row.entry { - Some(i) => self.checked[i] = value, - None => { - for i in entries_under(&self.entries, &row.path) { - self.checked[i] = value; - } - } - } - } - - // Double clicking a file pulls that one entry out to a temporary folder and - // hands it to whatever the system opens it with. It runs on its own thread - // because the entry can be large, and reports through the same progress - // window as everything else. - fn open_file(&mut self, ctx: &egui::Context, index: usize) { - let Some(archive) = self.archive.clone() else { - return; - }; - let Some(entry) = self.entries.get(index).cloned() else { - return; - }; - if entry.is_dir { - return; - } - let password = self.archive_password.clone(); - let s = self.s(); - let ctx2 = ctx.clone(); - // Only ever started from the list, so this is the panel over it, and it - // takes itself away when the file has been handed over. Not the whole - // window: what you were looking at is right behind it, and you are - // going to want it back the moment the other program opens. - self.close_when_done = false; - let name = entry - .name - .rsplit(['/', '\\']) - .next() - .unwrap_or(&entry.name) - .to_string(); - self.show_job(ctx, s.opening, name, true); - self.spawn(ctx, 1, move |tx| { - let _ = tx.send(Message::Progress(0, 1, entry.name.clone())); - ctx2.request_repaint(); - let outcome = extract_one(&archive, &entry, password.as_deref()) - .and_then(|path| launch_with_system(&path).map(|()| path)); - let _ = tx.send(match outcome { - Ok(path) => Message::Done(fill( - s.opened_with_system, - &[("name", &path.display().to_string())], - )), - Err(e) => Message::Failed(e.to_string()), - }); - ctx2.request_repaint(); - }); - } - - // Everything the keyboard does to the list, in one place. `rows` is what is - // on screen right now, which is what the arrows should walk: filtering or - // changing folder changes the list under the cursor, so it is clamped here - // rather than tracked separately. - fn keyboard(&mut self, ctx: &egui::Context, rows: &[Row]) { - // The box that picks a group by name has just opened and does not have - // the keyboard yet. The key that opened it is still in this frame, and - // a minus is a character the list would otherwise jump to. - if self.picking_group.is_some() { - return; - } - if rows.is_empty() { - self.cursor = None; - return; - } - // A text box has the keyboard: the filter field, or a dialog. Arrows - // and Space belong to it, not to the list. - if ctx.memory(|m| m.focused().is_some()) { - return; - } - - let last = rows.len() - 1; - let page = 12usize; - let mut moved = None; - let mut enter = false; - let mut space = false; - let mut up_level = false; - let mut shift = false; - let mut check_all = false; - let mut invert = false; - let mut typed = String::new(); - let mut rename = false; - let mut look = false; - - ctx.input(|i| { - let at = self.cursor.unwrap_or(0); - shift = i.modifiers.shift; - check_all = i.modifiers.command && i.key_pressed(egui::Key::A); - invert = i.modifiers.command && i.key_pressed(egui::Key::I); - // Alt belongs to the shortcuts that walk the folders, not to the - // cursor: Alt and up is one level out, not one row up. - if !i.modifiers.alt { - for (key, to) in [ - (egui::Key::ArrowDown, (at + 1).min(last)), - (egui::Key::ArrowUp, at.saturating_sub(1)), - (egui::Key::PageDown, (at + page).min(last)), - (egui::Key::PageUp, at.saturating_sub(page)), - (egui::Key::Home, 0), - (egui::Key::End, last), - ] { - if i.key_pressed(key) { - // The first press only lands the cursor somewhere - // visible instead of jumping a row from nowhere. - moved = Some(if self.cursor.is_none() { 0 } else { to }); - } - } - } - rename = i.key_pressed(egui::Key::F2); - // Alt+V is what WinRAR uses; F3 is what every file manager since - // Norton has used for the same thing, and it is one key. - look = (i.modifiers.alt && i.key_pressed(egui::Key::V)) || i.key_pressed(egui::Key::F3); - enter = i.key_pressed(egui::Key::Enter); - space = i.key_pressed(egui::Key::Space); - up_level = i.key_pressed(egui::Key::Backspace) - || (i.modifiers.alt && i.key_pressed(egui::Key::ArrowUp)); - // Plain letters, the way the Explorer jumps to a name. Ctrl and Alt - // are somebody else's shortcut. - if !i.modifiers.command && !i.modifiers.alt { - for e in &i.events { - if let egui::Event::Text(t) = e { - typed.push_str(t); - } - } - } - }); - - // What every file list calls inverting a selection: keep what was not - // picked and let go of what was, which is how you pick everything but - // the handful you can see. - if invert { - let flipped: Vec = rows.iter().map(|r| !self.is_checked(r)).collect(); - for (r, on) in rows.iter().zip(flipped) { - self.set_checked(r, on); - } - return; - } - - if check_all { - let value = rows.iter().any(|r| !self.is_checked(r)); - for r in rows { - self.set_checked(r, value); - } - return; - } - - // Typing jumps to the next row starting with what was typed, wrapping - // round, so pressing the same letter walks through the matches. - if !typed.is_empty() && typed != " " { - let needle = typed.to_lowercase(); - let from = self.cursor.map_or(0, |c| c + 1); - let hit = (0..rows.len()) - .map(|n| (from + n) % rows.len()) - .find(|&n| rows[n].label.to_lowercase().starts_with(&needle)); - if let Some(n) = hit { - self.cursor = Some(n); - self.scroll_to_cursor = true; - return; - } - } - - if let Some(to) = moved { - // Shift drags the ticks along with the cursor, so a run of files - // can be picked without reaching for the mouse. - if shift { - let from = self.cursor.unwrap_or(to); - let (lo, hi) = if from <= to { (from, to) } else { (to, from) }; - for r in &rows[lo..=hi] { - self.set_checked(r, true); - } - } - self.cursor = Some(to); - self.scroll_to_cursor = true; - } - - if up_level && !self.current_dir.is_empty() { - let parent = parent_of(&self.current_dir); - self.go_to(parent); - return; - } - - let Some(at) = self.cursor else { return }; - let Some(row) = rows.get(at) else { return }; - - // F2 opens the name for editing where it stands, which is what it does - // in WinRAR and in the Explorer. Only a zip can be written to, so - // anywhere else it does nothing rather than opening a box that would - // have to say no afterwards. - // Looking at what is under the cursor without taking it out, which is - // what Alt+V has always done in WinRAR. - if look { - if let Some(i) = row.entry { - self.view_entry(i); - } - return; - } - if rename && self.format == Format::Zip && !row.up { - self.renaming = Some((row.path.clone(), row.label.clone())); - self.rename_fresh = true; - return; - } - if space { - let value = !self.is_checked(row); - self.set_checked(row, value); - } - if enter { - if row.is_dir { - let path = row.path.clone(); - self.go_to(path); - } else if let Some(i) = row.entry { - self.open_file(ctx, i); - } - } - } - - // Going somewhere new drops whatever was ahead in the history, the way a - // browser does. Re-entering the folder already showing is not a move. - fn go_to(&mut self, path: String) { - if self.history.get(self.here) == Some(&path) { - return; - } - self.history.truncate(self.here + 1); - self.history.push(path.clone()); - self.here = self.history.len() - 1; - self.current_dir = path; - self.filter.clear(); - self.clear_picked(); - } - - // Every folder starts with nothing picked, the way the Explorer does. - // A folder is picked here by ticking every entry underneath it, which is - // what lets one be extracted whole, so clicking a folder and walking into - // it used to arrive with all of its contents already ticked. - fn clear_picked(&mut self) { - self.checked.iter_mut().for_each(|c| *c = false); - self.cursor = None; - // A row number means something else in the folder now on screen. - self.last_click = None; - } - - fn can_go_back(&self) -> bool { - self.here > 0 - } - - fn can_go_forward(&self) -> bool { - self.here + 1 < self.history.len() - } - - fn go_back(&mut self) { - if self.can_go_back() { - self.here -= 1; - self.current_dir = self.history[self.here].clone(); - self.filter.clear(); - self.clear_picked(); - } - } - - fn go_forward(&mut self) { - if self.can_go_forward() { - self.here += 1; - self.current_dir = self.history[self.here].clone(); - self.filter.clear(); - self.clear_picked(); - } - } - - fn is_checked(&self, row: &Row) -> bool { - // The way out of the folder is not a thing that can be picked. - if row.up { - return false; - } - match row.entry { - Some(i) => self.checked[i], - None => { - let under = entries_under(&self.entries, &row.path); - !under.is_empty() && under.iter().all(|&i| self.checked[i]) - } - } - } - - fn table(&mut self, ui: &mut egui::Ui) { - let s = self.s(); - let visible = self.visible_rows(); - // Before the table is drawn, so a move this frame is painted this - // frame rather than one behind. - self.keyboard(ui.ctx(), &visible); - if self.cursor.is_some_and(|c| c >= visible.len()) { - self.cursor = if visible.is_empty() { - None - } else { - Some(visible.len() - 1) - }; - } - - let mut requested: Option = None; - let mut opened: Option = None; - let mut clicked: Option = None; - // Only the left button, and only from a row. The row menu also fills in - // `clicked` so that right clicking something picks it, and a right - // click is not half of a double click. - let mut left_click: Option = None; - let columns = self.settings.columns; - // The ones on, in the order they are drawn. The header, the cells and - // the column widths all walk this same list, so they cannot drift. - let shown: Vec = Columns::ALL - .iter() - .map(|(which, _)| *which) - .filter(|w| columns.on(*w)) - .collect(); - // Which width belongs to each column on screen, left to right. The - // name is always the first, and the rest keep their own width whether - // they are showing or not, so turning one off and on again does not - // lose how wide it was pulled. - let slots: Vec = std::iter::once(0) - .chain( - shown - .iter() - .map(|w| Columns::ALL.iter().position(|(c, _)| c == w).unwrap_or(0) + 1), - ) - .collect(); - // The whole of each header cell, edge to edge: the rectangle of the - // cell's response, not the one `col` hands back, which is only as wide - // as the word inside it. This is what says where the column edges are, - // and the edges are where the handles go. - let mut heads: Vec = Vec::new(); - let toggle_column: std::cell::Cell> = std::cell::Cell::new(None); - // What the row menu asked for. Cells again, and acted on after the - // table: doing any of it inside the closure would be borrowing self - // while the table still holds it. - let wants_extract = std::cell::Cell::new(false); - let wants_delete = std::cell::Cell::new(false); - let wants_here = std::cell::Cell::new(false); - let wants_view: std::cell::Cell> = std::cell::Cell::new(None); - let wants_test = std::cell::Cell::new(false); - // The rename in progress, unpacked into pieces the row closure can hold - // while the table still has `self`. `finish` is how the box says it is - // done: yes to keep what was typed, no to throw it away. - let editing: Option = self.renaming.as_ref().map(|(p, _)| p.clone()); - let typing = std::cell::RefCell::new( - self.renaming - .as_ref() - .map_or(String::new(), |(_, t)| t.clone()), - ); - let fresh = std::cell::Cell::new(self.rename_fresh); - let finish: std::cell::Cell> = std::cell::Cell::new(None); - let wants_rename = std::cell::Cell::new(false); - let wants_copy_names = std::cell::Cell::new(false); - let wants_clip: std::cell::Cell> = std::cell::Cell::new(None); - let wants_paste = std::cell::Cell::new(false); - let wants_select_all = std::cell::Cell::new(false); - let picked = std::cell::Cell::new(false); - // The row the cursor is on, painted after the table: the highlight now - // belongs to the ticks, so the cursor needs a mark of its own. - let cursor_rect: std::cell::Cell> = std::cell::Cell::new(None); - // Paired with the index they came from. `body.rows` only builds the - // ones on screen, so after any scrolling these do not start at nought. - let mut row_rects: Vec<(usize, egui::Rect)> = Vec::with_capacity(visible.len()); - let pressing = ui.input(|i| i.pointer.any_down()); - let mut icons = std::mem::take(&mut self.icons); - // A RefCell rather than a plain take: the cell closures are handed out one - // per column and two of them would otherwise want the same &mut. - let types = std::cell::RefCell::new(std::mem::take(&mut self.types)); - let order = self.order; - let hint = s.sort_hint; - // The whole header cell answers, not the four letters of the name: - // aiming at the text to sort by a column is a nuisance, and the cell is - // what looks like the button. - // - // It cannot use the response the table hands back for the cell. The - // header row is built with row index 0, the same number the first row - // of the body carries, and the cell id is made out of that number and - // the column, so the two rows end up sharing ids and each one is handed - // the other's clicks: pressing a cell of the first row sorted by that - // column, and pressing a column name picked the first row. So the - // header asks for an interaction of its own, under an id of its own. - // The rules between the columns run the whole height of the list, which - // is how WinRAR and every list of files with columns has always drawn - // them: they are what tells you which number belongs under which - // heading when the eye is halfway down the page. They were taken out - // here for a while on the grounds that they looked like a spreadsheet, - // which was a change nobody asked for and the wrong call. The table - // used to draw them along with its own resize handles; they are drawn - // in `column_edges` now, with the handles. - let accent = theme::mark(ui.visuals()); - let head = |ui: &mut egui::Ui, text: &str, col: SortColumn| -> egui::Response { - let cell = ui.max_rect(); - // Asked for before the word is drawn, so the ground can be laid - // under it: a heading lights up when the pointer is on it, which is - // how WinRAR says that a column name is a thing you press and not - // just a label, and the column the list is sorted by keeps a ground - // of its own. Spread half the gap either side, the same as the fill - // on a row, so that a lit heading reaches its neighbours. - let resp = ui.interact( - cell, - egui::Id::new(("arca-head", text)), - egui::Sense::click(), - ); - let fill = if resp.hovered() { - Some(ui.visuals().widgets.hovered.bg_fill) - } else if order.0 == col { - Some(theme::header_sorted(ui.visuals())) - } else { - None - }; - if let Some(fill) = fill { - let half = ui.spacing().item_spacing.x * 0.5; - ui.painter() - .rect_filled(cell.expand2(egui::vec2(half, 0.0)), 0.0, fill); - } - // The table puts its cells in truncating mode, and a truncating - // label takes the whole width it is offered. That is right for a - // file name and wrong for a column heading: it left no room beside - // the word, so the mark that says which way the sort runs was - // allocated past the edge of the cell and clipped away. - ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); - // La misma sangria que llevan las filas, para que la palabra quede - // sobre el icono de debajo y no medio caracter a su izquierda. - if col == SortColumn::Name { - ui.add_space(NAME_INSET); - } - ui.add(egui::Label::new(egui::RichText::new(text).strong()).selectable(false)); - if order.0 == col { - // A small triangle beside the name rather than a caret typed - // into it: "Name ^" put a character in the middle of a word - // that was never part of the word. - let (mark, _) = - ui.allocate_exact_size(egui::vec2(11.0, 11.0), egui::Sense::hover()); - ui.painter().add(egui::Shape::convex_polygon( - sort_mark(mark.center(), order.1).to_vec(), - accent, - egui::Stroke::NONE, - )); - } - resp.on_hover_text(hint) - }; - - // Where the table begins, taken before it is built and kept for the - // band the headings stand on and for the top of the column rules: the - // cells themselves start a few pixels below this, and rules that began - // there left a gap of bare window above them. - let table_top = ui.cursor().top(); - // A place in the paint list held open for that band, so it can be - // filled in once the header has said how tall it is and still come out - // underneath the words rather than over them. - let band = ui.painter().add(egui::Shape::Noop); - let mut builder = TableBuilder::new(ui) - // Stripes, ticks and a highlight were three ways of saying the same - // thing. What is picked is painted; the rest is left quiet. - .striped(false) - // The table's own handles run the whole height of the list; ours - // are in the header. See `widths`. - .resizable(false) - // Without this the cells only sense hovering, and row.response() - // would never report a double click. - .sense(egui::Sense::click()) - // Dragging in a file list draws a selection; it does not push the - // list about. With this on, dragging did both at once, and the two - // pull opposite ways: dragging down moves the content down, which - // is the list scrolling up, so a downward selection ran upwards. - .drag_to_scroll(false) - .cell_layout(egui::Layout::left_to_right(egui::Align::Center)); - // Name first and wide, with the icon inside it. That is where the - // Explorer and every archiver put it, and a separate icon column only - // pushed the one thing you read away from its picture. - // - // Each column is given its exact width, so that what the header hands - // back is what was asked for: the last one takes whatever is left, as - // the date does in every file list. - for (n, slot) in slots.iter().enumerate() { - builder = if n + 1 == slots.len() { - builder.column(Column::remainder().at_least(CELL_LEAST)) - } else { - builder.column(Column::exact(self.settings.widths[*slot])) - }; - } - // Set only while a selection drag has run off the end of the list, so - // the rest of the time the table keeps its own scroll position. - if let Some(y) = self.band_scroll.or(self.wheel.map(|w| w.at)) { - builder = builder.vertical_scroll_offset(y); - } - - let out = builder - .header(32.0, |mut h| { - // Right clicking anywhere along the header offers the list of - // columns, which is where both WinRAR and NanaZip keep it. - // A Cell because every header cell hands the same menu to - // egui, and several closures cannot hold one &mut between them. - let menu = |ui: &mut egui::Ui| { - ui.label(s.columns_word); - ui.separator(); - for (which, _) in Columns::ALL { - let mut on = columns.on(which); - if ui.checkbox(&mut on, Columns::label(which, s)).clicked() { - toggle_column.set(Some(which)); - ui.close_menu(); - } - } - }; - let mut resp = None; - let (_, cell) = h.col(|ui| resp = Some(head(ui, s.col_name, SortColumn::Name))); - heads.push(cell.rect); - if let Some(r) = resp { - if r.clicked() { - requested = Some(SortColumn::Name); - } - r.context_menu(|ui| menu(ui)); - } - for which in &shown { - let mut resp = None; - let (_, cell) = - h.col(|ui| resp = Some(head(ui, Columns::label(*which, s), *which))); - heads.push(cell.rect); - if let Some(r) = resp { - if r.clicked() { - requested = Some(*which); - } - r.context_menu(|ui| menu(ui)); - } - } - }) - .body(|body| { - body.rows(ROW_HEIGHT, visible.len(), |mut row| { - let idx = row.index(); - let r = &visible[idx]; - // Ticked is selected, and selected is what gets painted, the - // way WinRAR and the Explorer do it. Highlighting only the - // row the keyboard was on said nothing about what the - // buttons were going to act on. - row.set_selected(self.is_checked(r)); - // The table works out which row the pointer is over, keeps - // it, and tints it on the next frame. One frame late is - // fine while the pointer is only passing over rows, and is - // a flicker trailing behind it once a button is held down - // and the pointer is moving with intent: rows light up as - // if picked, a step behind, and go out again. Nothing in - // the Explorer lights up under a held button either. - if pressing { - row.set_hovered(false); - } - let cut = self.cut_names.contains(&r.path) - || r.entry - .is_some_and(|i| self.cut_names.contains(&self.entries[i].name)); - row.col(|ui| { - // La lista llega al canto de la ventana; lo que hay - // dentro de ella, no. Sin esto el icono sale pegado al - // borde, que es lo que ninguna lista de ficheros hace. - ui.add_space(NAME_INSET); - // The system icon when the desktop has one, and the - // drawn one when it does not, which is every platform - // that is not Windows so far. - match system_icon(ui.ctx(), &mut icons, &r.label, r.is_dir) { - Some(tex) => { - ui.add( - egui::Image::new(&tex) - .fit_to_exact_size(egui::vec2(15.0, 15.0)), - ); - } - None => draw_icon(ui, r.kind), - } - ui.add_space(4.0); - if editing.as_deref() == Some(r.path.as_str()) { - name_box(ui, &typing, &fresh, &finish); - return; - } - let text = if r.is_dir { - egui::RichText::new(&r.label).strong() - } else { - egui::RichText::new(&r.label) - }; - // Faded while it is on the clipboard as a cut, which is - // the only sign the Explorer gives either. - let text = if cut { text.weak() } else { text }; - let room = ui.available_width(); - let name = ui.add(egui::Label::new(text).selectable(false).truncate()); - // The whole name on hover, but only when the column is - // too narrow to hold it. A tip that repeats what is - // already legible is a tip that teaches you to ignore - // tips. - if wide_of(ui, &r.label, egui::TextStyle::Body) > room { - name.on_hover_text(&r.label); - } - }); - for which in &shown { - row.col(|ui| { - // The way out of the folder has no size, no date and - // no kind: it is a door, not a thing in the room. - if r.up { - return; - } - match which { - SortColumn::Size => { - ui.monospace(human(r.size)); - } - SortColumn::Packed => { - ui.monospace(human(r.packed)); - } - SortColumn::Method => { - if r.is_dir { - ui.weak(format!("{} {}", r.count, s.items_word)); - } else if r.encrypted { - ui.label(format!("AES-256 {}", r.method)); - } else { - ui.label(r.method); - } - } - SortColumn::Saved => { - let pct = saved_of(r) * 100.0; - let value = if pct.abs() < 0.5 { 0.0 } else { pct }; - ui.monospace(format!("{value:.0}%")); - } - SortColumn::Modified => { - ui.monospace(when(r.mtime)); - } - SortColumn::Crc => { - // A folder has no contents of its own to sum. - if r.is_dir { - ui.weak(""); - } else { - ui.monospace(format!("{:08X}", r.crc32)); - } - } - SortColumn::Type => { - ui.add( - egui::Label::new(system_type( - &mut types.borrow_mut(), - &r.label, - r.is_dir, - )) - .selectable(false) - .truncate(), - ); - } - SortColumn::Created => { - ui.monospace(when(r.created)); - } - SortColumn::Accessed => { - ui.monospace(when(r.accessed)); - } - SortColumn::Attributes => { - ui.monospace(attribute_letters(r.attributes)); - } - SortColumn::Path => { - ui.add( - egui::Label::new( - egui::RichText::new(folder_of(&r.path)).weak(), - ) - .selectable(false) - .truncate(), - ); - } - SortColumn::Name => {} - } - }); - } - // The whole row answers, not just the name: aiming at the - // text to open something is a nuisance nobody expects. - let resp = row.response(); - // Only what there is something behind. A menu offering - // things this window cannot do would be worse than none. - // The way out of the folder has nothing behind it, so there is - // nothing to offer for it: no menu rather than a menu of - // things that would all do nothing. - let menu_for = (!r.up).then_some(&resp); - if let Some(resp) = menu_for { - resp.context_menu(|ui| { - // Right clicking something that is not picked picks it, - // which is what every file list does. - if !picked.get() { - clicked = Some(idx); - picked.set(true); - } - // Written out, not the return glyph: the fonts egui ships do - // not have it and it came out as an empty box. - if ui.button(format!("{} Enter", s.open_word)).clicked() { - opened = Some(idx); - ui.close_menu(); - } - if ui - .button(format!("{} Ctrl+E", s.extract_selected)) - .clicked() - { - wants_extract.set(true); - ui.close_menu(); - } - // Only a file has anything to look at. A folder is a - // prefix on some names, not a thing with bytes. - if let Some(at) = r.entry { - if ui.button(format!("{} F3", s.view_word)).clicked() { - wants_view.set(Some(at)); - ui.close_menu(); - } - } - if ui.button(format!("{} Alt+W", s.extract_here)).clicked() { - wants_here.set(true); - ui.close_menu(); - } - if ui.button(s.test_selection).clicked() { - wants_test.set(true); - ui.close_menu(); - } - ui.separator(); - // The same list the header offers by being clicked, for - // the times the pointer is already down here. WinRAR - // keeps one in its row menu too. - ui.menu_button(s.sort_by, |ui| { - for which in std::iter::once(SortColumn::Name).chain(shown.clone()) - { - let on = self.order.0 == which; - let arrow = if !on { - "" - } else if self.order.1 { - " \u{25B2}" - } else { - " \u{25BC}" - }; - let label = format!("{}{arrow}", Columns::label(which, s)); - if ui.selectable_label(on, label).clicked() { - requested = Some(which); - ui.close_menu(); - } - } - }); - ui.separator(); - // Only a zip can be written to, so anywhere else this - // is left out rather than offered and refused. - if self.format == Format::Zip - && ui.button(format!("{} F2", s.rename_word)).clicked() - { - clicked = Some(idx); - wants_rename.set(true); - ui.close_menu(); - } - if ui.button(format!("{} Supr", s.delete_word)).clicked() { - wants_delete.set(true); - ui.close_menu(); - } - ui.separator(); - // Only where the shell has somewhere to paste them. - // Offering a copy that no other window can take would - // be worse than not offering one. - if clipboard::AVAILABLE { - if ui.button(format!("{} Ctrl+C", s.copy_word)).clicked() { - wants_clip.set(Some(false)); - ui.close_menu(); - } - if ui.button(format!("{} Ctrl+X", s.cut_word)).clicked() { - wants_clip.set(Some(true)); - ui.close_menu(); - } - // Always offered rather than greyed out by looking: - // the clipboard is one global lock, and opening it - // on every frame the menu is up to find out what is - // in it would be taking it from whoever else wants - // it. An empty one says so in the status bar. - if ui.button(format!("{} Ctrl+V", s.paste_word)).clicked() { - wants_paste.set(true); - ui.close_menu(); - } - ui.separator(); - } - if ui - .button(format!("{} Ctrl+Shift+C", s.copy_names)) - .clicked() - { - wants_copy_names.set(true); - ui.close_menu(); - } - if ui.button(format!("{} Ctrl+A", s.select_all)).clicked() { - wants_select_all.set(true); - ui.close_menu(); - } - }); - } - if resp.clicked() { - clicked = Some(idx); - left_click = Some(idx); - } - row_rects.push((idx, resp.rect)); - if self.cursor == Some(idx) { - cursor_rect.set(Some(resp.rect)); - } - // Only when the keyboard moved it: doing this every frame - // would fight the scroll wheel. - if self.scroll_to_cursor && self.cursor == Some(idx) { - resp.scroll_to_me(Some(egui::Align::Center)); - } - }); - }); - - self.icons = icons; - self.types = types.into_inner(); - self.scroll_to_cursor = false; - - // Everything the two menus asked for, now that the table has let go of - // self. Turning a column off is not allowed to leave the list with - // nothing but names to look at, so the last one stays. - if let Some(which) = toggle_column.get() { - let mut c = self.settings.columns; - let turning_off = c.on(which); - if !turning_off || shown.len() > 1 { - c.set(which, !turning_off); - self.settings.columns = c; - self.settings.save(); - } - } - - // Ctrl adds or removes one, Shift takes everything between here and - // where the cursor was, and a plain click starts again with just this - // one. That is what every file list does, and the ticks are the - // selection here, so this is what they act on. - if let Some(index) = clicked { - let mods = ui.input(|i| i.modifiers); - let target = visible[index].clone(); - if mods.command { - let value = !self.is_checked(&target); - self.set_checked(&target, value); - } else if mods.shift { - let from = self.cursor.unwrap_or(index); - let (lo, hi) = if from <= index { - (from, index) - } else { - (index, from) - }; - for r in &visible[lo..=hi] { - self.set_checked(r, true); - } - } else { - self.checked.iter_mut().for_each(|c| *c = false); - self.set_checked(&target, true); - } - self.cursor = Some(index); - } - - // Opening keeps its own count of clicks rather than asking egui whether - // this was a double one. egui counts by time alone, and never counts - // back down to two: after a double click the next one is a triple, and - // so is the one after that, so opening a second folder straight after - // the first did nothing until you had waited long enough for the run to - // lapse. This count starts again every time something opens, so one - // folder after another works at whatever speed they are clicked. - if let Some(index) = left_click { - let (now, plain) = ui.input(|i| (i.time, !i.modifiers.command && !i.modifiers.shift)); - let again = plain - && self - .last_click - .is_some_and(|(i, t)| i == index && now - t < DOUBLE_CLICK); - self.last_click = if again { None } else { Some((index, now)) }; - if again { - opened = Some(index); - } - } - - if wants_select_all.get() { - for r in &visible { - self.set_checked(r, true); - } - } - if wants_copy_names.get() { - let names = self.selected_names(); - if !names.is_empty() { - ui.ctx().copy_text(names.join( - " -", - )); - } - } - if wants_delete.get() { - let names = self.selected_names(); - if !names.is_empty() { - self.confirm_delete = Some(names); - } - } - // The menu asked for a rename of the row it was opened on. - if wants_rename.get() { - if let Some(row) = clicked.and_then(|i| visible.get(i)) { - self.renaming = Some((row.path.clone(), row.label.clone())); - self.rename_fresh = true; - } - } - // What the box in the row typed, and whether it was finished or walked - // away from. Done here rather than inside the table because starting a - // job needs `self` and the table still had it. - self.rename_fresh = fresh.get(); - if let Some((path, _)) = self.renaming.clone() { - let text = typing.borrow().clone(); - self.renaming = Some((path.clone(), text.clone())); - match finish.get() { - None => {} - Some(false) => self.renaming = None, - Some(true) => { - self.renaming = None; - let ctx = ui.ctx().clone(); - self.rename_to(&ctx, &visible, &path, text.trim()); - } - } - } - if wants_extract.get() { - let ctx = ui.ctx().clone(); - self.ask_extract(&ctx, true); - } - if let Some(at) = wants_view.get() { - self.view_entry(at); - } - if wants_here.get() { - let ctx = ui.ctx().clone(); - self.extract_here(&ctx); - } - if wants_test.get() { - let names = self.selected_names(); - if let Some(archive) = self.archive.clone() { - let ctx = ui.ctx().clone(); - self.run_job( - &ctx, - Job::Test { - archive, - only: (!names.is_empty()).then(|| names.into_iter().collect()), - }, - ); - } - } - if let Some(cut) = wants_clip.get() { - let ctx = ui.ctx().clone(); - self.copy_to_clipboard(&ctx, cut); - } - if wants_paste.get() { - let ctx = ui.ctx().clone(); - self.paste_from_clipboard(&ctx); - } - - // A thin outline where the keyboard is, over the fill that says what is - // ticked. Two different things, so they cannot share the one colour. - // - // Only the top and bottom of the row are taken from the row itself. Its - // rectangle is the union of its cells', and a cell reports itself as - // wide as whatever was drawn inside it, so the sides came out wherever - // the longest name happened to end: the outline bit into the icon on - // the left and hung past the blue on the right. The blue is painted - // cell by cell, each spread half the gap between columns wider than its - // column so that the row comes out unbroken, and that is the shape the - // outline has to follow. - if let Some(rect) = cursor_rect.get() { - let half = ui.spacing().item_spacing * 0.5; - let here = egui::Rect::from_x_y_ranges( - out.inner_rect.expand(half.x).x_range(), - rect.expand(half.y).y_range(), - ); - ui.painter() - .rect_stroke(here.shrink(0.5), 0.0, theme::cursor(ui.visuals())); - } - - // The scrollable part on its own, without the header the outer rect - // takes in, and how far down the list it currently sits: both are what - // a drag needs to know when it reaches an edge. - let reach = (out.content_size.y - out.inner_rect.height()).max(0.0); - if let Some(first) = heads.first() { - let half = ui.spacing().item_spacing * 0.5; - // Por la izquierda donde estaba, con su margen. Por la derecha - // hasta el borde: el ancho de dentro del scroll deja fuera la - // franja donde vive la barra de desplazamiento, y la banda se - // quedaba a un dedo del borde derecho, con una raya negra al final - // de la fila de cabeceras que en ningun otro archivador esta. - let ends = out.inner_rect.left() - half.x..=ui.max_rect().right(); - ui.painter().set( - band, - egui::Shape::rect_filled( - egui::Rect::from_x_y_ranges(ends, table_top..=first.bottom() + half.y), - 0.0, - theme::header(ui.visuals()), - ), - ); - } - let cols: Vec = std::iter::once(SortColumn::Name) - .chain(shown.iter().copied()) - .collect(); - self.column_edges( - ui, - &heads, - &slots, - &cols, - &visible, - s, - table_top, - // The foot of the panel, not the foot of the rows: the rules run the - // whole height of the list and the list now ends where the window - // does. - ui.max_rect().bottom(), - ); - self.rubber_band( - ui, - &visible, - &row_rects, - out.inner_rect, - out.state.offset.y, - reach, - ); - self.carry(ui, &visible, &row_rects, out.inner_rect); - self.wheel_scroll(ui, out.inner_rect, out.state.offset.y, reach); - // El hueco de debajo de la ultima fila. Ahi no hay ninguna entrada, asi - // que lo que se ofrece no es lo de una fila sino lo de la lista entera: - // lo que se puede hacer sin haber senalado nada. Es donde WinRAR pone - // el suyo, y hasta ahora aqui el boton derecho no hacia nada. - let below = row_rects - .last() - .map(|(_, r)| r.bottom()) - .unwrap_or(out.inner_rect.top()) - .max(out.inner_rect.top()); - // Solo un zip se deja escribir. Se mira antes de entrar en el menu - // porque ahi dentro `self` ya no se puede tocar. - let is_zip = self.format == Format::Zip; - let mut pick_all = false; - let mut flip_all = false; - let mut drop_all = false; - let mut new_folder = false; - if out.inner_rect.bottom() - below > 4.0 { - let empty = egui::Rect::from_x_y_ranges( - out.inner_rect.x_range(), - below..=out.inner_rect.bottom(), - ); - // Sense::click y nada mas. Arrastrar por aqui dibuja la seleccion - // de banda, que lee el raton por su cuenta; algo que sintiera el - // arrastre se lo quitaria. - let hueco = ui.interact( - empty, - egui::Id::new("arca-empty-space"), - egui::Sense::click(), - ); - hueco.context_menu(|ui| { - if ui.button(format!("{}\tCtrl+A", s.select_all)).clicked() { - pick_all = true; - ui.close_menu(); - } - if ui - .button(format!("{}\tCtrl+I", s.invert_selection)) - .clicked() - { - flip_all = true; - ui.close_menu(); - } - if ui.button(format!("{}\tEsc", s.clear_selection)).clicked() { - drop_all = true; - ui.close_menu(); - } - ui.separator(); - // Solo un zip se deja escribir, asi que en lo demas esto no se - // ofrece en vez de ofrecerse y negarse. - if is_zip && ui.button(s.new_folder).clicked() { - new_folder = true; - ui.close_menu(); - } - // Siempre, sin mirar antes lo que hay dentro: el portapapeles - // es un cerrojo global y abrirlo en cada fotograma que el menu - // este abierto seria quitarselo a quien lo quiera. Si esta - // vacio lo dice la barra de estado. - if clipboard::AVAILABLE && ui.button(format!("{}\tCtrl+V", s.paste_word)).clicked() - { - wants_paste.set(true); - ui.close_menu(); - } - ui.separator(); - ui.menu_button(s.sort_by, |ui| { - for which in std::iter::once(SortColumn::Name).chain(shown.iter().copied()) { - let on = order.0 == which; - let arrow = if !on { - "" - } else if order.1 { - " \u{25B2}" - } else { - " \u{25BC}" - }; - if ui - .selectable_label(on, format!("{}{arrow}", Columns::label(which, s))) - .clicked() - { - requested = Some(which); - ui.close_menu(); - } - } - }); - ui.menu_button(s.columns_word, |ui| { - for (which, _) in Columns::ALL { - let mut on = columns.on(which); - if ui.checkbox(&mut on, Columns::label(which, s)).clicked() { - toggle_column.set(Some(which)); - ui.close_menu(); - } - } - }); - }); - } - if pick_all { - for r in &visible { - self.set_checked(r, true); - } - } - if flip_all { - let flipped: Vec = visible.iter().map(|r| !self.is_checked(r)).collect(); - for (r, on) in visible.iter().zip(flipped) { - self.set_checked(r, on); - } - } - if drop_all { - self.clear_picked(); - } - if new_folder { - self.folder_input.clear(); - self.asking_folder = true; - } - if let Some(index) = opened { - let target = &visible[index]; - if target.is_dir { - let path = target.path.clone(); - self.go_to(path); - } else if let Some(i) = target.entry { - self.open_file(ui.ctx(), i); - } - } - if let Some(c) = requested { - if self.order.0 == c { - self.order.1 = !self.order.1; - } else { - self.order = (c, true); - } - } - } -} - -impl eframe::App for Arca { - // Written when the window closes rather than every time it is dragged: the - // size and place change with every pixel of a resize and the settings file - // is not a thing to write sixty times a second. - fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { - self.drop_undo(); - if self.geometry.is_some() { - self.settings.window = self.geometry; - self.settings.save(); - } - } - - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Kept fresh every frame because `on_exit` is handed no context to ask. - // Only a window somebody is browsing in: the small one a job runs in - // would otherwise be what came back next time. - if matches!(self.view, View::Browse) { - // El sitio de fuera y el tamano de dentro, cada uno de donde toca. - // La ventana se abre pidiendo un tamano interior y aqui se guardaba - // el exterior, que es el mismo mas la barra de titulo y los bordes: - // cada vez que se cerraba y se volvia a abrir, la ventana crecia esa - // barra de titulo. Abrir y cerrar diez veces la hacia un palmo mas - // alta sin que nadie la tocase. - let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect)); - if let (Some(outer), Some(inner)) = (outer, inner) { - self.geometry = Some([outer.min.x, outer.min.y, inner.width(), inner.height()]); - } - } - self.ask_about_updates(ctx); - self.receive(ctx); - // Getting the keyboard back is when whatever was done elsewhere has - // been done. Only on the change, not every frame it is focused. - let focused = ctx.input(|i| i.focused); - if focused && !self.was_focused && matches!(self.view, View::Browse) { - self.cut_landed(ctx); - } - self.was_focused = focused; - self.shortcuts(ctx); - - // Escape backs out of whatever is on top, innermost first, the way it - // does everywhere else. The password prompt goes through the same path - // as its Cancel button so a cancelled job is cancelled once. - // One place, and a switch rather than an opening: handled where the - // window is drawn as well, the same press would open it and close it - // again inside the one frame. - if ctx.input(|i| i.key_pressed(egui::Key::F1)) { - self.show_shortcuts = !self.show_shortcuts; - } - - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - if self.waiting_on_password.is_some() { - self.cancel_password(); - } else if self.show_shortcuts { - self.show_shortcuts = false; - } else if self.show_settings { - self.show_settings = false; - // The viewer and the group box close themselves on Escape, and - // closing one of them is all that press was for: it must not also - // let go of everything that was picked underneath. - } else if self.viewing.is_some() || self.picking_group.is_some() { - } else if matches!(self.view, View::Browse) && !self.busy { - // Nothing on top of the list any more, so it backs out of the - // last thing there is to back out of: what is picked. - self.clear_picked(); - } - } - - // The side buttons on a mouse, which winit reports as Back and Forward - // and egui hands over as Extra1 and Extra2. Alt+Left and Alt+Right do - // the same, for anyone without them. - if matches!(self.view, View::Browse) && !self.busy { - let (back, forward) = ctx.input(|i| { - ( - i.pointer.button_pressed(egui::PointerButton::Extra1) - || (i.modifiers.alt && i.key_pressed(egui::Key::ArrowLeft)), - i.pointer.button_pressed(egui::PointerButton::Extra2) - || (i.modifiers.alt && i.key_pressed(egui::Key::ArrowRight)), - ) - }); - if back { - self.go_back(); - } - if forward { - self.go_forward(); - } - } - - // Not while something is running or a window is waiting on an answer: - // a drop that lands then would be acting on a state that is about to - // change under it. - if matches!(self.view, View::Browse) - && !self.busy - && self.confirm_delete.is_none() - && self.confirm_drop.is_none() - && self.waiting_on_password.is_none() - { - let dropped: Vec = ctx.input(|i| { - i.raw - .dropped_files - .iter() - .filter_map(|f| f.path.clone()) - .collect() - }); - self.dropped(ctx, dropped); - } +// Keep the GPUI view focused on presentation while exposing the application +// contract at the crate boundary. These are crate-private, not public API. +pub(crate) use archive_ops::*; +pub(crate) use controller::*; +pub(crate) use model::*; +pub(crate) use settings::*; - if self.busy { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } +use arca_core::{Codec, Level}; +use i18n::{strings, Lang, Strings}; +use tree::{parent_of, Row}; +// How wide a column starts out and the least it can be pulled down to. The +// name gets the room because it is the thing being read; the rest hold a +// number or a word and are sized for it. +const NAME_WIDE: f32 = 320.0; +const NAME_LEAST: f32 = 140.0; +const CELL_WIDE: f32 = 95.0; +const CELL_LEAST: f32 = 60.0; - let ctx2 = ctx.clone(); - match self.view { - View::Running => { - egui::CentralPanel::default().show(ctx, |ui| { - self.running_view(ui, &ctx2); - }); - self.conflict_window(&ctx2); - self.password_window(&ctx2); - } - View::Add => { - egui::CentralPanel::default().show(ctx, |ui| { - self.add_view(ui, &ctx2); - }); - } - View::Browse => { - egui::TopBottomPanel::top("toolbar").show(ctx, |ui| { - self.toolbar(ui, &ctx2); - }); - self.settings_window(&ctx2); - self.shortcuts_window(&ctx2); - self.group_window(&ctx2); - self.viewer_window(&ctx2); - self.default_password_window(&ctx2); - self.new_folder_window(&ctx2); - self.progress_window(&ctx2); - self.conflict_window(&ctx2); - self.password_window(&ctx2); - self.confirm_delete_window(&ctx2); - self.confirm_drop_window(&ctx2); - // An exact height with the content centred inside it. Padding - // above and below looked symmetrical in the source and was not - // on screen: the text sat high in the bar. - egui::TopBottomPanel::bottom("status") - .exact_height(30.0) - .show(ctx, |ui| { - ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { - // Not while the window over the list is saying the - // same thing in more detail. - if self.busy && !self.quiet && !self.overlay { - let f = if self.total_count == 0 { - 0.0 - } else { - self.done_count as f32 / self.total_count as f32 - }; - ui.add( - egui::ProgressBar::new(f) - .text(format!("{} / {}", self.done_count, self.total_count)) - .desired_width(ui.available_width()), - ); - } else { - let color = if self.error { - egui::Color32::from_rgb(220, 90, 90) - } else { - ui.visuals().weak_text_color() - }; - ui.colored_label(color, &self.notice); - } - }); - }); - // No gap above the list or below it: the headings sit against - // the line under the toolbar and the last row against the - // status bar, the way a file list meets the edges of its window - // everywhere else. That margin was also what left the rules - // between the columns short of the foot. - // - // Ni a los lados: la lista llega a los cantos de la ventana, - // como la del Explorador y la de WinRAR. Los margenes solo - // dejaban una franja negra al final de la fila de cabeceras y - // otra al principio, y una lista que no llega a su ventana - // parece una lista dentro de una caja. - // - // Se probo antes y se dejo, porque una fila senalada llega - // ahora con su azul hasta los dos cantos. Es lo que hace el - // Explorador con las suyas. - self.tree_panel(ctx); - let mut frame = egui::Frame::central_panel(&ctx.style()); - frame.inner_margin = egui::Margin::ZERO; - egui::CentralPanel::default().frame(frame).show(ctx, |ui| { - if self.entries.is_empty() { - let text = self.s().drop_here; - ui.centered_and_justified(|ui| { - ui.label(egui::RichText::new(text).size(16.0).weak()); - }); - return; - } - // The list sits on the window, not in a card on it. It was - // given a fill, a border and rounded corners back when the - // column rules were gone and the rows had no edge to end - // against; the rules are back and they do that job, so all - // the card left was a box drawn inside a box. - self.table(ui); - }); - self.drop_hint(&ctx2); - } - } - } +fn main() { + gpui_shell::run(); } -fn main() -> eframe::Result<()> { - let startup = parse_args(); - let settings = Settings::load(); - let compact = !matches!(startup, Startup::Browse(_)); +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; - // The size and place the window was left at, when there is one and this is - // a window somebody is going to browse in. The little window a job runs in - // is a different shape and a different job, and giving it the browsing - // window's size would open a progress bar the size of a desk. - let remembered = (!compact).then_some(settings.window).flatten(); - let size = match remembered { - Some([_, _, w, h]) => [w, h], - None if compact => [440.0, 192.0], - // Bastante para que la lista quepa con sus columnas y se lean los - // nombres sin tocar nada, que es lo que se hace nada mas abrir un - // archivo. Mas estrecha cabe -- el minimo esta en 840 -- pero entonces - // la ruta y la fecha se quedan a media palabra y hay que ensancharla a - // mano cada vez. - None => [970.0, 620.0], - }; - // The browsing window needs room for the row of commands; the little job - // window needs room for a progress bar and two buttons. One floor for both - // was the browsing one, so the job window was being held open at more than - // twice the size of what it had to show. - let floor = if compact { [380.0, 180.0] } else { WINDOW_MIN }; - // The icon compiled into the executable covers the Explorer and the - // shortcut, but winit does not read it for the window itself, so the title - // bar and the taskbar keep the generic one unless it is set here too. - let mut viewport = egui::ViewportBuilder::default() - .with_inner_size(size) - // Wide enough for the row of commands, now that every one of them says - // its name. Below this the filter box at the end of it has no width - // left to be given and the bar starts running off its own edge. - .with_min_inner_size(floor) - .with_title("Arca"); - if let Some([x, y, _, _]) = remembered { - viewport = viewport.with_position([x, y]); + #[test] + fn the_attribute_letters_hold_their_places() { + assert_eq!(attribute_letters(0), "----"); + assert_eq!(attribute_letters(0x01), "R---"); + assert_eq!(attribute_letters(0x20), "---A"); + assert_eq!(attribute_letters(0x01 | 0x02 | 0x04 | 0x20), "RHSA"); + // The directory bit is the list's job, not this column's. + assert_eq!(attribute_letters(0x10), "----"); } - if let Ok(icon) = eframe::icon_data::from_png_bytes(ICON_PNG) { - viewport = viewport.with_icon(icon); + + #[test] + fn text_is_told_from_the_rest_by_what_it_does_not_have() { + assert!(looks_like_text(b""), "an empty file opens as an empty page"); + assert!(looks_like_text(b"hola\r\nque tal\ttabulado\n")); + assert!( + looks_like_text("acentos y enes: aeiou \u{f1}\u{e1}".as_bytes()), + "high bytes are a name with an accent, not a program" + ); + assert!( + !looks_like_text(b"MZ\x90\x00\x03\x00\x00\x00"), + "a zero settles it" + ); + // No zeros, but nothing readable either. + let noise: Vec = (1..=200u8).map(|b| b % 0x1F + 1).collect(); + assert!(!looks_like_text(&noise)); } - let options = eframe::NativeOptions { - viewport, - ..Default::default() - }; - eframe::run_native( - "Arca", - options, - Box::new(move |cc| { - // What lets the viewer draw a picture straight from the bytes it has - // in memory, with no file on disk for it to point at. - egui_extras::install_image_loaders(&cc.egui_ctx); - cc.egui_ctx.set_fonts(theme::fonts()); - // Both, not just the one in use: the setting can be changed while - // the window is open, and egui keeps a style per theme. - cc.egui_ctx.set_visuals_of(egui::Theme::Dark, theme::dark()); - cc.egui_ctx - .set_visuals_of(egui::Theme::Light, theme::light()); - cc.egui_ctx.all_styles_mut(theme::style); - cc.egui_ctx.set_theme(settings.theme); + #[test] + fn a_later_version_is_the_one_with_the_larger_numbers() { + assert!(newer("0.5.1", "0.6.0")); + assert!(newer("0.9.0", "0.10.0"), "ten comes after nine"); + assert!(newer("0.5.1", "v0.5.2"), "a leading v is forgiven"); + assert!(!newer("0.6.0", "0.5.9"), "older is not newer"); + assert!(!newer("0.6.0", "0.6.0"), "the same is not newer"); + assert!(!newer("0.10.0", "0.9.0"), "and the other way round too"); + // Missing parts are zero, so these are the same version. + assert!(!newer("0.6", "0.6.0")); + assert!(!newer("0.6.0", "0.6")); + // A release candidate is earlier than the release it is a candidate for. + assert!(newer("0.6.0-rc1", "0.6.0")); + assert!(!newer("0.6.0", "0.6.0-rc1")); + } - let mut app = Arca::new(settings); - match startup { - Startup::Browse(Some(p)) => app.open(&cc.egui_ctx, p), - Startup::Browse(None) => {} - Startup::Run(job) => app.run_job(&cc.egui_ctx, job), - Startup::Add(files) => { - app.output_name = quick_output(&files, app.format) - .file_name() - .map(|x| x.to_string_lossy().to_string()) - .unwrap_or_default(); - app.pending_inputs = files; - app.view = View::Add; - } - } - Ok(Box::new(app)) - }), - ) -} + // Anything unreadable has to answer no. A window that cannot tell what the + // announcement said should say nothing, not guess. + #[test] + fn nonsense_never_announces_an_update() { + assert!(!newer("0.5.1", "")); + assert!(!newer("0.5.1", "manana")); + assert!(!newer("0.5.1", "0.5.uno")); + assert!(!newer("", "9.9.9")); + assert!( + !newer("0.5.1", "99999999999999999999"), + "past what a number holds" + ); + } -#[cfg(test)] -mod tests { - use super::*; + #[test] + fn the_release_name_comes_out_of_the_reply() { + let reply = r#"{"url":"https://x/1","tag_name":"v0.6.0","name":"Arca 0.6.0"}"#; + assert_eq!(tag_of(reply).as_deref(), Some("v0.6.0")); + // Spacing is the writer's business, not ours. + assert_eq!( + tag_of(r#"{ "tag_name" : "0.7.0" }"#).as_deref(), + Some("0.7.0") + ); + // And everything that is not an answer is not an answer. + assert_eq!(tag_of("{}"), None); + assert_eq!(tag_of(""), None); + assert_eq!(tag_of(r#"{"tag_name":""}"#), None, "a name of nothing"); + assert_eq!(tag_of(r#"{"tag_name":"x"}"#).as_deref(), Some("x")); + let long = format!(r#"{{"tag_name":"{}"}}"#, "v".repeat(64)); + assert_eq!(tag_of(&long), None, "somebody being funny"); + } - // Lo que se saca de la respuesta de GitHub es el nombre de la version y dos - // direcciones, y de esas dos sale un programa que se va a ejecutar. Asi que - // lo que importa aqui no es solo que las encuentre: es que no acepte una - // que apunte a otro sitio. #[test] fn la_respuesta_de_la_release_da_version_instalador_y_sumas() { let reply = r#"{"url":"https://api.github.com/x","tag_name":"v0.6.2","assets":[ @@ -8234,63 +160,9 @@ c76ecf12e8e05b8f4730fb9933450ea121fe1ce3699ab9b7d20b058f872815f4 *arca-setup-0.6 assert!(sum_for(listing, "arca-setup-0.6.2-x86_64.exe").is_none()); } - // A move is a rename with a different folder in front of it, asked of every - // entry in the archive. The three ways a folder can turn up in that list - // have to move together, and everything else has to come through untouched: - // a prefix matched too eagerly here would quietly re-file half the archive. - #[test] - fn moving_reads_an_archive_written_with_backslashes() { - // Windows's own Compress-Archive writes these, and the window shows and - // compares forward slashes. Before this they matched nothing and a - // move inside a folder did nothing without saying so. - let moves = vec![("carpeta/f1.txt".to_string(), "f1.txt".to_string())]; - assert_eq!(moved_name(r"carpeta\f1.txt", &moves), "f1.txt"); - assert_eq!(moved_name(r"carpeta\f2.txt", &moves), "carpeta/f2.txt"); - } - - #[test] - fn moving_carries_a_whole_branch_and_leaves_everything_else_alone() { - let moves = vec![ - ("docs/notas".to_string(), "notas".to_string()), - ("leeme.txt".to_string(), "docs/leeme.txt".to_string()), - ]; - let of = |n: &str| moved_name(n, &moves); - - // The folder, both ways it can be written, and what is under it. - assert_eq!(of("docs/notas"), "notas"); - assert_eq!(of("docs/notas/"), "notas/"); - assert_eq!(of("docs/notas/uno.md"), "notas/uno.md"); - assert_eq!(of("docs/notas/dos/tres.md"), "notas/dos/tres.md"); - // A file on its own. - assert_eq!(of("leeme.txt"), "docs/leeme.txt"); - // Everything else, including names that begin the same way and are not - // the same folder at all. - assert_eq!(of("docs/notas2/otro.md"), "docs/notas2/otro.md"); - assert_eq!(of("docs/uno.txt"), "docs/uno.txt"); - assert_eq!(of("leeme.txt.bak"), "leeme.txt.bak"); - assert_eq!(of("otra/cosa.bin"), "otra/cosa.bin"); - } - - #[test] - fn the_clock_reads_as_a_clock() { - assert_eq!(clock(0.0), "0:00"); - assert_eq!(clock(7.4), "0:07"); - assert_eq!(clock(98.0), "1:38"); - assert_eq!(clock(3600.0), "1:00:00"); - assert_eq!(clock(7511.0), "2:05:11"); - // A guess made from almost nothing, and one made from nonsense. - assert_eq!(clock(-5.0), "0:00"); - assert_eq!(clock(f64::NAN), "0:00"); - assert_eq!(clock(f64::INFINITY), "0:00"); - } - - // Everything that is remembered has to come back. - // - // This is here because five settings were being read at startup and never - // written: the line that built the file had stopped mentioning them and - // nothing complained. Each of them worked perfectly until the window was - // closed. Adding a setting without adding it here makes this fail, which is - // the whole point. + // Five settings were read at startup and never written, because the line + // that built the file had quietly stopped mentioning them. A round trip + // that never touches a disk is the only way that stays fixed. #[test] fn every_setting_survives_being_written_and_read_again() { let mut before = Settings { @@ -8332,83 +204,37 @@ c76ecf12e8e05b8f4730fb9933450ea121fe1ce3699ab9b7d20b058f872815f4 *arca-setup-0.6 } } - // The one thing this has to get right is the order, and the one that is - // easy to get wrong is comparing versions as text: 0.10.0 sorts before - // 0.9.0 that way, and the window would nag about an update that is older - // than what is running. - #[test] - fn a_later_version_is_the_one_with_the_larger_numbers() { - assert!(newer("0.5.1", "0.6.0")); - assert!(newer("0.9.0", "0.10.0"), "ten comes after nine"); - assert!(newer("0.5.1", "v0.5.2"), "a leading v is forgiven"); - assert!(!newer("0.6.0", "0.5.9"), "older is not newer"); - assert!(!newer("0.6.0", "0.6.0"), "the same is not newer"); - assert!(!newer("0.10.0", "0.9.0"), "and the other way round too"); - // Missing parts are zero, so these are the same version. - assert!(!newer("0.6", "0.6.0")); - assert!(!newer("0.6.0", "0.6")); - // A release candidate is earlier than the release it is a candidate for. - assert!(newer("0.6.0-rc1", "0.6.0")); - assert!(!newer("0.6.0", "0.6.0-rc1")); - } - - // Anything unreadable has to answer no. A window that cannot tell what the - // announcement said should say nothing, not guess. - #[test] - fn nonsense_never_announces_an_update() { - assert!(!newer("0.5.1", "")); - assert!(!newer("0.5.1", "manana")); - assert!(!newer("0.5.1", "0.5.uno")); - assert!(!newer("", "9.9.9")); - assert!( - !newer("0.5.1", "99999999999999999999"), - "past what a number holds" - ); - } - #[test] - fn the_release_name_comes_out_of_the_reply() { - let reply = r#"{"url":"https://x/1","tag_name":"v0.6.0","name":"Arca 0.6.0"}"#; - assert_eq!(tag_of(reply).as_deref(), Some("v0.6.0")); - // Spacing is the writer's business, not ours. - assert_eq!( - tag_of(r#"{ "tag_name" : "0.7.0" }"#).as_deref(), - Some("0.7.0") - ); - // And everything that is not an answer is not an answer. - assert_eq!(tag_of("{}"), None); - assert_eq!(tag_of(""), None); - assert_eq!(tag_of(r#"{"tag_name":""}"#), None, "a name of nothing"); - assert_eq!(tag_of(r#"{"tag_name":"x"}"#).as_deref(), Some("x")); - let long = format!(r#"{{"tag_name":"{}"}}"#, "v".repeat(64)); - assert_eq!(tag_of(&long), None, "somebody being funny"); + fn moving_reads_an_archive_written_with_backslashes() { + // Windows's own Compress-Archive writes these, and the window shows and + // compares forward slashes. Before this they matched nothing and a + // move inside a folder did nothing without saying so. + let moves = vec![("carpeta/f1.txt".to_string(), "f1.txt".to_string())]; + assert_eq!(moved_name(r"carpeta\f1.txt", &moves), "f1.txt"); + assert_eq!(moved_name(r"carpeta\f2.txt", &moves), "carpeta/f2.txt"); } #[test] - fn the_attribute_letters_hold_their_places() { - assert_eq!(attribute_letters(0), "----"); - assert_eq!(attribute_letters(0x01), "R---"); - assert_eq!(attribute_letters(0x20), "---A"); - assert_eq!(attribute_letters(0x01 | 0x02 | 0x04 | 0x20), "RHSA"); - // The directory bit is the list's job, not this column's. - assert_eq!(attribute_letters(0x10), "----"); - } + fn moving_carries_a_whole_branch_and_leaves_everything_else_alone() { + let moves = vec![ + ("docs/notas".to_string(), "notas".to_string()), + ("leeme.txt".to_string(), "docs/leeme.txt".to_string()), + ]; + let of = |n: &str| moved_name(n, &moves); - #[test] - fn text_is_told_from_the_rest_by_what_it_does_not_have() { - assert!(looks_like_text(b""), "an empty file opens as an empty page"); - assert!(looks_like_text(b"hola\r\nque tal\ttabulado\n")); - assert!( - looks_like_text("acentos y enes: aeiou \u{f1}\u{e1}".as_bytes()), - "high bytes are a name with an accent, not a program" - ); - assert!( - !looks_like_text(b"MZ\x90\x00\x03\x00\x00\x00"), - "a zero settles it" - ); - // No zeros, but nothing readable either. - let noise: Vec = (1..=200u8).map(|b| b % 0x1F + 1).collect(); - assert!(!looks_like_text(&noise)); + // The folder, both ways it can be written, and what is under it. + assert_eq!(of("docs/notas"), "notas"); + assert_eq!(of("docs/notas/"), "notas/"); + assert_eq!(of("docs/notas/uno.md"), "notas/uno.md"); + assert_eq!(of("docs/notas/dos/tres.md"), "notas/dos/tres.md"); + // A file on its own. + assert_eq!(of("leeme.txt"), "docs/leeme.txt"); + // Everything else, including names that begin the same way and are not + // the same folder at all. + assert_eq!(of("docs/notas2/otro.md"), "docs/notas2/otro.md"); + assert_eq!(of("docs/uno.txt"), "docs/uno.txt"); + assert_eq!(of("leeme.txt.bak"), "leeme.txt.bak"); + assert_eq!(of("otra/cosa.bin"), "otra/cosa.bin"); } #[test] @@ -8489,58 +315,20 @@ c76ecf12e8e05b8f4730fb9933450ea121fe1ce3699ab9b7d20b058f872815f4 *arca-setup-0.6 // Which way the sort mark points is a sign, and a sign is the one thing you // cannot check by looking at a screenshot of a list with one row in it. #[test] - fn the_sort_mark_points_up_when_the_sort_goes_up() { - let c = egui::pos2(50.0, 50.0); - - let up = sort_mark(c, true); - // Two along the bottom and the point above them. Larger y is lower down. - assert_eq!(up[0].y, up[1].y, "the base is level"); - assert!(up[2].y < up[0].y, "the point is above the base"); - assert!( - up[0].x < c.x && up[1].x > c.x, - "the base straddles the centre" - ); - assert_eq!(up[2].x, c.x, "the point is centred"); - - let down = sort_mark(c, false); - assert_eq!(down[0].y, down[1].y); - assert!(down[2].y > down[0].y, "the point is below the base"); - - // One is the other turned over, and neither leaves the little square it - // is drawn in. - assert_eq!(up[2].y - c.y, -(down[2].y - c.y)); - for p in up.iter().chain(down.iter()) { - assert!((p.x - c.x).abs() <= 5.5 && (p.y - c.y).abs() <= 5.5); - } + fn the_clock_reads_as_a_clock() { + assert_eq!(clock(0.0), "0:00"); + assert_eq!(clock(7.4), "0:07"); + assert_eq!(clock(98.0), "1:38"); + assert_eq!(clock(3600.0), "1:00:00"); + assert_eq!(clock(7511.0), "2:05:11"); + // A guess made from almost nothing, and one made from nonsense. + assert_eq!(clock(-5.0), "0:00"); + assert_eq!(clock(f64::NAN), "0:00"); + assert_eq!(clock(f64::INFINITY), "0:00"); } // The path row is the one place the window can run out of width, and it did: // a deep folder pushed the trail out over the count at the other end. - #[test] - fn a_path_too_long_gives_up_its_oldest_folders_first() { - let sep = 10.0; - let dots = 8.0; - // Five folders of 100 each: 500 of names plus 40 of separators. - let five = [100.0_f32; 5]; - - // Room to spare: nothing hidden. - assert_eq!(crumbs_hidden(&five, sep, dots, 600.0), 0); - // Exactly enough: still nothing. - assert_eq!(crumbs_hidden(&five, sep, dots, 540.0), 0); - // One short. Dropping the first leaves 400 + 30 + 18 for the mark = 448. - assert_eq!(crumbs_hidden(&five, sep, dots, 539.0), 1); - assert_eq!(crumbs_hidden(&five, sep, dots, 448.0), 1); - assert_eq!(crumbs_hidden(&five, sep, dots, 447.0), 2); - // Absurdly narrow: everything goes but the folder you are in, which is - // left to be cut short rather than dropped. - assert_eq!(crumbs_hidden(&five, sep, dots, 10.0), 4); - // A single folder is that folder, whatever the width. - assert_eq!(crumbs_hidden(&[100.0], sep, dots, 1.0), 0); - // One long name in the middle is not a reason to drop the ones after it. - let uneven = [40.0_f32, 900.0, 40.0, 40.0]; - assert_eq!(crumbs_hidden(&uneven, sep, dots, 200.0), 2); - } - #[test] fn archive_stem_strips_what_it_should() { for (name, stem) in [ diff --git a/arca-gui/src/model.rs b/arca-gui/src/model.rs new file mode 100644 index 0000000..0a3bb43 --- /dev/null +++ b/arca-gui/src/model.rs @@ -0,0 +1,265 @@ +//! Pure application model, settings, and projections shared by the controller and GPUI. + +use crate::i18n::Strings; +use crate::tree::Row; +use std::path::Path; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ThemePreference { + System, + Light, + Dark, +} + +#[derive(PartialEq, Eq, Clone, Copy)] +pub(crate) enum Format { + Zip, + Tar, + TarGz, +} + +impl Format { + pub(crate) fn extension(self) -> &'static str { + match self { + Format::Zip => "zip", + Format::Tar => "tar", + Format::TarGz => "tar.gz", + } + } + + pub(crate) fn label(self) -> &'static str { + match self { + Format::Zip => "ZIP", + Format::Tar => "TAR", + Format::TarGz => "TAR.GZ", + } + } +} + +pub(crate) fn detect(p: &Path) -> Option { + let n = p.to_string_lossy().to_ascii_lowercase(); + if n.ends_with(".zip") { + Some(Format::Zip) + } else if n.ends_with(".tar.gz") || n.ends_with(".tgz") { + Some(Format::TarGz) + } else if n.ends_with(".tar") { + Some(Format::Tar) + } else { + None + } +} + +// The little triangle beside a column name that says which way it is sorted. +// Ascending points up, which is what a file list means by it everywhere: the +// smallest, the earliest, the first alphabetically, at the top. +// +// The one thing to get wrong here is the sign. Screen coordinates grow +// downwards, so the apex of an upward triangle sits at a SMALLER y than its +// base, and writing it the other way round gives a mark that says the opposite +// of what the list is doing without anything else looking amiss. +// How many folders at the front of the path have to go behind the "…" for the +// rest to fit in `room`. Drops from the front, because the folders you are +// nearest are the ones worth seeing, and never drops the last one: the folder +// you are standing in stays whatever its name costs, cut short if it must be. +// The row a height falls on, out of the ones the table drew this frame. +// +// The rows do not touch: there is a gap of the item spacing between one and the +// next, and the table paints over it so that the stripes look continuous, but +// the rectangles it hands back stop short. Asking which rectangle *contains* a +// height therefore has no answer whenever the pointer is resting in one of +// those gaps, which is most of the way from one row to the next. So the +// question asked here is which row has started by this height, and the answer +// in a gap is the row above it. +// +// Above the first row it clamps to the first: a drag that has run off that end +// is still asking for everything up to it. Under the last row there are two +// different situations and they cannot share an answer. If there is more list +// below, still to be scrolled into view, the answer is that last row and the +// drag carries on from there. If the list has ended, the height is in the empty +// space under it and the answer is `len`, one past the end, which is not a row: +// pressing down there and moving a little must pick nothing at all rather than +// reach up and grab whatever happens to be last. +pub(crate) fn saved_of(r: &Row) -> f64 { + if r.size == 0 { + 0.0 + } else { + 1.0 - r.packed as f64 / r.size as f64 + } +} + +// A Unix timestamp as a date somebody can read. Done by hand rather than with +// a date crate: the archive formats store civil time with no zone, so there is +// nothing here worth a dependency that knows about leap seconds and Tokyo. +pub(crate) fn when(mtime: Option) -> String { + let Some(t) = mtime.filter(|t| *t > 0) else { + return String::new(); + }; + let days = t.div_euclid(86_400); + let secs = t.rem_euclid(86_400); + // Days since 1970 to a civil date, by Howard Hinnant's method: shift the + // epoch to March so the leap day lands at the end of the year and the + // month lengths follow one formula. + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = era * 400 + yoe + i64::from(month <= 2); + format!( + "{year:04}-{month:02}-{day:02} {:02}:{:02}", + secs / 3600, + (secs % 3600) / 60 + ) +} + +pub(crate) fn human(n: u64) -> String { + const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut v = n as f64; + let mut i = 0; + while v >= 1024.0 && i < U.len() - 1 { + v /= 1024.0; + i += 1; + } + if i == 0 { + format!("{n} B") + } else { + format!("{v:.1} {}", U[i]) + } +} + +pub(crate) fn archive_stem(p: &Path) -> String { + let name = p + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let lower = name.to_ascii_lowercase(); + for ext in [".tar.gz", ".tgz", ".zip", ".tar"] { + if lower.ends_with(ext) { + return name[..name.len() - ext.len()].to_string(); + } + } + name +} + +#[derive(PartialEq, Eq, Clone, Copy)] +pub(crate) enum SortColumn { + Name, + Size, + Packed, + Method, + Saved, + Modified, + Crc, + Type, + Path, + Created, + Accessed, + Attributes, +} + +// Which columns the list shows. Name is not here: a list of nothing but sizes +// would be a strange thing to allow. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) struct Columns { + size: bool, + packed: bool, + method: bool, + saved: bool, + modified: bool, + crc: bool, + type_: bool, + path: bool, + created: bool, + accessed: bool, + attributes: bool, +} + +impl Default for Columns { + fn default() -> Self { + // What was on screen before any of this was a choice, plus the date, + // which both WinRAR and NanaZip show and which people look for. + Columns { + size: true, + packed: true, + method: true, + saved: true, + modified: true, + crc: false, + type_: false, + path: false, + created: false, + accessed: false, + attributes: false, + } + } +} + +impl Columns { + pub(crate) const ALL: [(SortColumn, &'static str); 11] = [ + (SortColumn::Size, "size"), + (SortColumn::Packed, "packed"), + (SortColumn::Method, "method"), + (SortColumn::Saved, "saved"), + (SortColumn::Modified, "modified"), + (SortColumn::Crc, "crc"), + (SortColumn::Type, "type"), + (SortColumn::Path, "path"), + (SortColumn::Created, "created"), + (SortColumn::Accessed, "accessed"), + (SortColumn::Attributes, "attributes"), + ]; + + pub(crate) fn on(&self, which: SortColumn) -> bool { + match which { + SortColumn::Size => self.size, + SortColumn::Packed => self.packed, + SortColumn::Method => self.method, + SortColumn::Saved => self.saved, + SortColumn::Modified => self.modified, + SortColumn::Crc => self.crc, + SortColumn::Type => self.type_, + SortColumn::Path => self.path, + SortColumn::Created => self.created, + SortColumn::Accessed => self.accessed, + SortColumn::Attributes => self.attributes, + SortColumn::Name => true, + } + } + + pub(crate) fn set(&mut self, which: SortColumn, value: bool) { + match which { + SortColumn::Size => self.size = value, + SortColumn::Packed => self.packed = value, + SortColumn::Method => self.method = value, + SortColumn::Saved => self.saved = value, + SortColumn::Modified => self.modified = value, + SortColumn::Crc => self.crc = value, + SortColumn::Type => self.type_ = value, + SortColumn::Path => self.path = value, + SortColumn::Created => self.created = value, + SortColumn::Accessed => self.accessed = value, + SortColumn::Attributes => self.attributes = value, + SortColumn::Name => {} + } + } + + pub(crate) fn label(which: SortColumn, s: &Strings) -> &'static str { + match which { + SortColumn::Size => s.col_size, + SortColumn::Packed => s.col_packed, + SortColumn::Method => s.col_method, + SortColumn::Saved => s.col_saved, + SortColumn::Modified => s.col_modified, + SortColumn::Crc => s.col_crc, + SortColumn::Type => s.col_type, + SortColumn::Path => s.col_path, + SortColumn::Created => s.col_created, + SortColumn::Accessed => s.col_accessed, + SortColumn::Attributes => s.col_attributes, + SortColumn::Name => s.col_name, + } + } +} diff --git a/arca-gui/src/settings.rs b/arca-gui/src/settings.rs new file mode 100644 index 0000000..5164bc9 --- /dev/null +++ b/arca-gui/src/settings.rs @@ -0,0 +1,222 @@ +//! Persistent user preferences for the Arca GUI. + +use crate::i18n::Lang; +use crate::{Columns, ThemePreference, CELL_LEAST, CELL_WIDE, NAME_LEAST, NAME_WIDE}; +use std::fs; +use std::path::PathBuf; + +pub(crate) fn config_file() -> Option { + let base = if cfg!(windows) { + std::env::var_os("APPDATA").map(PathBuf::from) + } else if cfg!(target_os = "macos") { + std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support")) + } else { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + }?; + Some(base.join("Arca").join("gui.conf")) +} + +pub(crate) struct Settings { + pub(crate) lang: Option, + pub(crate) theme: ThemePreference, + pub(crate) columns: Columns, + // Whether to ask, once at startup, if there is a newer Arca. + pub(crate) updates: bool, + // Every file in the archive at once, instead of one folder at a time. + pub(crate) flat: bool, + // The folders of the archive down the left hand side. + pub(crate) tree: bool, + // Which code page an unflagged zip has its names written in. Only the + // person looking at the archive can know, so it is remembered: somebody + // whose archives all come from one machine says it once. + pub(crate) page: arca_zip::pages::Page, + // Where the window was left and how big: x, y, width, height. None until it + // has been opened once. + pub(crate) window: Option<[f32; 4]>, + // The archives opened lately, newest first. Paths, so that one that has + // since been moved can be noticed and dropped rather than opened blind. + pub(crate) recent: Vec, + // How wide each column is: the name first, then the ones that can be + // turned off, in the order of `Columns::ALL`. A column keeps its width + // while it is off, so turning one back on does not lose how it was set. + pub(crate) widths: Vec, +} + +impl Settings { + // What a column starts out at, before anybody has pulled on it. + pub(crate) fn default_widths() -> Vec { + std::iter::once(NAME_WIDE) + .chain(std::iter::repeat(CELL_WIDE).take(Columns::ALL.len())) + .collect() + } + + // The least a column can be pulled down to, by its place in `widths`. + pub(crate) fn least(slot: usize) -> f32 { + if slot == 0 { + NAME_LEAST + } else { + CELL_LEAST + } + } +} + +impl Default for Settings { + fn default() -> Self { + Settings { + lang: None, + theme: ThemePreference::System, + columns: Columns::default(), + updates: true, + flat: false, + tree: false, + page: arca_zip::pages::Page::default(), + window: None, + recent: Vec::new(), + widths: Settings::default_widths(), + } + } +} + +impl Settings { + pub(crate) fn load() -> Self { + let Some(p) = config_file() else { + return Settings::default(); + }; + let Ok(text) = fs::read_to_string(p) else { + return Settings::default(); + }; + Settings::parse(&text) + } + + /// The settings file read back out of text. + /// + /// Apart from `load` so that what `text` writes can be read back and + /// compared without going near a disk. + pub(crate) fn parse(text: &str) -> Settings { + let mut s = Settings::default(); + for line in text.lines() { + let Some((k, v)) = line.split_once('=') else { + continue; + }; + match (k.trim(), v.trim()) { + ("lang", "system") => s.lang = None, + ("lang", other) => s.lang = Lang::from_code(other), + ("theme", "light") => s.theme = ThemePreference::Light, + ("theme", "dark") => s.theme = ThemePreference::Dark, + ("theme", _) => s.theme = ThemePreference::System, + ("flat", v) => s.flat = v == "yes", + ("tree", v) => s.tree = v == "yes", + ("updates", v) => s.updates = v != "no", + ("page", v) => { + if let Some(p) = arca_zip::pages::Page::from_code(v) { + s.page = p; + } + } + // One line each, because a path can hold anything a filename + // can and there is no separator left that it could not. + ("recent", p) if !p.is_empty() => s.recent.push(p.to_string()), + ("window", v) => { + let n: Vec = v.split(',').filter_map(|x| x.trim().parse().ok()).collect(); + if let [x, y, w, h] = n[..] { + // A window smaller than the minimum, or one left on a + // screen that is no longer plugged in, is not a window + // anybody can use. + if w >= 720.0 && h >= 320.0 { + s.window = Some([x, y, w, h]); + } + } + } + // Written since the columns could first be turned off and read + // by nobody, so every window opened with the six of them + // showing however they had been left. + ("columns", list) => { + let mut c = Columns::default(); + for (which, _) in Columns::ALL { + c.set(which, false); + } + for name in list.split(',').map(str::trim) { + if let Some((which, _)) = Columns::ALL.iter().find(|(_, n)| *n == name) { + c.set(*which, true); + } + } + s.columns = c; + } + // All of them or none: a line with a column missing from it + // belongs to a different set of columns than this one has, and + // guessing which is which would put the widths on the wrong + // ones. Each is held above its floor in case the file was + // written by hand. + ("widths", list) => { + let read: Vec = list + .split(',') + .filter_map(|n| n.trim().parse::().ok()) + .enumerate() + .map(|(i, w)| w.max(Settings::least(i))) + .collect(); + if read.len() == s.widths.len() { + s.widths = read; + } + } + _ => {} + } + } + s + } + + pub(crate) fn save(&self) { + let Some(p) = config_file() else { return }; + if let Some(dir) = p.parent() { + let _ = fs::create_dir_all(dir); + } + let _ = fs::write(p, self.text()); + } + + /// The settings file as text. + /// + /// Apart from `save` so that what is written can be read back and compared + /// without going near a disk. That is not tidiness: six settings were being + /// read at startup and never written, because the line that builds this had + /// quietly stopped mentioning them -- `flat`, `tree`, `page`, `window` and + /// `recent` were all built into a string that was then thrown away. A round + /// trip that never touches a file is the only way that stays fixed. + pub(crate) fn text(&self) -> String { + let lang = self.lang.map(|l| l.code()).unwrap_or("system"); + let theme = match self.theme { + ThemePreference::Light => "light", + ThemePreference::Dark => "dark", + ThemePreference::System => "system", + }; + let yes = |b: bool| if b { "yes" } else { "no" }; + let columns: Vec<&str> = Columns::ALL + .iter() + .filter(|(which, _)| self.columns.on(*which)) + .map(|(_, name)| *name) + .collect(); + let widths: Vec = self.widths.iter().map(|w| format!("{w:.1}")).collect(); + + let mut out = String::new(); + out.push_str(&format!("lang = {lang}\n")); + out.push_str(&format!("theme = {theme}\n")); + out.push_str(&format!("flat = {}\n", yes(self.flat))); + out.push_str(&format!("tree = {}\n", yes(self.tree))); + out.push_str(&format!("updates = {}\n", yes(self.updates))); + out.push_str(&format!("page = {}\n", self.page.code())); + out.push_str(&format!("columns = {}\n", columns.join(","))); + out.push_str(&format!("widths = {}\n", widths.join(","))); + if let Some([x, y, w, h]) = self.window { + out.push_str(&format!("window = {x:.0},{y:.0},{w:.0},{h:.0}\n")); + } + // One line each: a path can hold anything a filename can and there is + // no separator left that it could not. + for path in &self.recent { + out.push_str(&format!("recent = {path}\n")); + } + out + } + + pub(crate) fn effective_lang(&self) -> Lang { + self.lang.unwrap_or_else(Lang::from_system) + } +} diff --git a/arca-gui/src/theme.rs b/arca-gui/src/theme.rs deleted file mode 100644 index 844ded2..0000000 --- a/arca-gui/src/theme.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! How Arca looks. -//! -//! Kept apart from the window because it is a different kind of decision. The -//! rest of the crate is about what happens when you press something; this is -//! about what it looks like before you do, and mixing the two means neither can -//! be changed without reading the other. -//! -//! The palette comes out of `brand/BRAND.md`: night blue, #05060A at one end of -//! the gradient and #1B2A4A at the other. Neither is usable as an accent on its -//! own -- one is nearly black, the other disappears into a dark panel -- so the -//! accent is that same hue carried up in lightness until it reads against both -//! grounds, and the greys are tinted towards it rather than being neutral. That -//! is what stops a dark window from looking like a screenshot of a terminal. - -use eframe::egui::{self, Color32, Rounding, Stroke, Visuals}; - -const fn rgb(v: u32) -> Color32 { - Color32::from_rgb((v >> 16) as u8, (v >> 8) as u8, v as u8) -} - -// The brand hue, lifted until it carries white text on a dark ground. -const ACCENT_DARK: Color32 = rgb(0x3D6FD6); -// The same hue taken the other way for a light ground, where the accent has to -// sit behind dark text instead of in front of it. -const ACCENT_LIGHT: Color32 = rgb(0x2A55B8); - -// Corners. Small enough to read as a finish rather than as a shape. -const R_WIDGET: f32 = 4.0; -const R_WINDOW: f32 = 8.0; - -/// Black and white, and the accent. -/// -/// The tinted greys the light theme is built on were tried here too and the -/// window came out looking like every other dark window; black gives the list -/// the ground a page has, and on the screens people have now it is a colour in -/// its own right rather than the absence of one. The greys above it are -/// neutral to match: a blue-grey next to true black reads as a stain. -/// -/// The ladder is deliberately short -- black, then three steps for the panels -/// and buttons -- because contrast is doing the work here that a hue does -/// elsewhere. What is picked is still the brand blue: a window with nothing -/// but white in it has no way of saying which of two white things matters. -pub fn dark() -> Visuals { - let mut v = Visuals::dark(); - v.panel_fill = Color32::BLACK; - // A shade off black, so that a dialog over the list reads as being over it - // rather than cut out of it. The border does the rest. - v.window_fill = rgb(0x0C0C0C); - v.extreme_bg_color = Color32::BLACK; - v.code_bg_color = rgb(0x0C0C0C); - // Only just off the panel. A stripe you can name the colour of is a stripe - // that competes with the selection. - v.faint_bg_color = rgb(0x0C0C0C); - v.window_stroke = Stroke::new(1.0_f32, rgb(0x333333)); - v.selection.bg_fill = rgb(0x2D5CB8); - // Not an outline colour, whatever the name says: egui_extras reads this and - // makes it the text colour of a picked row. A pale blue here left the sizes - // and dates on a selected row dimmer than on an unpicked one, which is the - // wrong way round. - v.selection.stroke = Stroke::new(1.0_f32, Color32::WHITE); - v.hyperlink_color = rgb(0x6E9BEA); - v.warn_fg_color = rgb(0xE0A85C); - v.error_fg_color = rgb(0xE06C6C); - - let w = &mut v.widgets; - // noninteractive.fg_stroke is the body text of the whole window, not a - // disabled colour: egui reads `text_color()` straight out of it. White, - // since that is the whole point of a black window; the rules and edges - // that share this group get their own dark grey below. - w.noninteractive.bg_fill = rgb(0x0C0C0C); - w.noninteractive.weak_bg_fill = rgb(0x0C0C0C); - w.noninteractive.bg_stroke = Stroke::new(1.0_f32, rgb(0x262626)); - w.noninteractive.fg_stroke = Stroke::new(1.0_f32, Color32::WHITE); - - w.inactive.bg_fill = rgb(0x161616); - w.inactive.weak_bg_fill = rgb(0x161616); - w.inactive.bg_stroke = Stroke::new(1.0_f32, rgb(0x2E2E2E)); - w.inactive.fg_stroke = Stroke::new(1.0_f32, Color32::WHITE); - - w.hovered.bg_fill = rgb(0x232323); - w.hovered.weak_bg_fill = rgb(0x232323); - w.hovered.bg_stroke = Stroke::new(1.0_f32, ACCENT_DARK); - w.hovered.fg_stroke = Stroke::new(1.0_f32, Color32::WHITE); - - // `active` is the pressed state and also where egui takes `strong` text - // from, so its foreground has to be the brightest thing here rather than - // whatever happens to look right on a pressed button. - w.active.bg_fill = ACCENT_DARK; - w.active.weak_bg_fill = ACCENT_DARK; - w.active.bg_stroke = Stroke::new(1.0_f32, rgb(0x6E9BEA)); - w.active.fg_stroke = Stroke::new(1.0_f32, Color32::WHITE); - - w.open.bg_fill = rgb(0x232323); - w.open.weak_bg_fill = rgb(0x232323); - w.open.bg_stroke = Stroke::new(1.0_f32, rgb(0x2E2E2E)); - w.open.fg_stroke = Stroke::new(1.0_f32, Color32::WHITE); - - round(&mut v); - v -} - -pub fn light() -> Visuals { - let mut v = Visuals::light(); - v.panel_fill = rgb(0xF2F4F8); - v.window_fill = rgb(0xFFFFFF); - v.extreme_bg_color = rgb(0xFFFFFF); - v.code_bg_color = rgb(0xF2F4F8); - v.faint_bg_color = rgb(0xE9EDF4); - v.window_stroke = Stroke::new(1.0_f32, rgb(0xD2D8E4)); - // Pale, because egui paints this behind text that keeps its own colour: a - // saturated blue here would leave a selected row unreadable. - v.selection.bg_fill = rgb(0xCBDCF7); - // The text of a picked row, as above. Dark, because the fill is pale. - v.selection.stroke = Stroke::new(1.0_f32, rgb(0x10141B)); - v.hyperlink_color = ACCENT_LIGHT; - v.warn_fg_color = rgb(0xA1660D); - v.error_fg_color = rgb(0xC03A3A); - - let w = &mut v.widgets; - w.noninteractive.bg_fill = rgb(0xFFFFFF); - w.noninteractive.weak_bg_fill = rgb(0xFFFFFF); - w.noninteractive.bg_stroke = Stroke::new(1.0_f32, rgb(0xE3E7EF)); - w.noninteractive.fg_stroke = Stroke::new(1.0_f32, rgb(0x1B2129)); - - w.inactive.bg_fill = rgb(0xFFFFFF); - w.inactive.weak_bg_fill = rgb(0xFFFFFF); - w.inactive.bg_stroke = Stroke::new(1.0_f32, rgb(0xCFD6E2)); - w.inactive.fg_stroke = Stroke::new(1.0_f32, rgb(0x1B2129)); - - w.hovered.bg_fill = rgb(0xEDF2FB); - w.hovered.weak_bg_fill = rgb(0xEDF2FB); - w.hovered.bg_stroke = Stroke::new(1.0_f32, ACCENT_LIGHT); - w.hovered.fg_stroke = Stroke::new(1.0_f32, rgb(0x10141B)); - - // A pale tint rather than the accent itself: this is also `strong` text, - // and strong text has to stay dark on a white window. - w.active.bg_fill = rgb(0xD8E3F8); - w.active.weak_bg_fill = rgb(0xD8E3F8); - w.active.bg_stroke = Stroke::new(1.0_f32, ACCENT_LIGHT); - w.active.fg_stroke = Stroke::new(1.0_f32, rgb(0x10141B)); - - w.open.bg_fill = rgb(0xEDF2FB); - w.open.weak_bg_fill = rgb(0xEDF2FB); - w.open.bg_stroke = Stroke::new(1.0_f32, rgb(0xCFD6E2)); - w.open.fg_stroke = Stroke::new(1.0_f32, rgb(0x1B2129)); - - round(&mut v); - v -} - -fn round(v: &mut Visuals) { - for s in [ - &mut v.widgets.noninteractive, - &mut v.widgets.inactive, - &mut v.widgets.hovered, - &mut v.widgets.active, - &mut v.widgets.open, - ] { - s.rounding = Rounding::same(R_WIDGET); - // egui grows a widget by a pixel when the pointer is over it. On a row - // in a table that reads as a twitch, and the colour already says it. - s.expansion = 0.0; - } - v.window_rounding = Rounding::same(R_WINDOW); - v.menu_rounding = Rounding::same(R_WINDOW); -} - -/// The colour that marks the row the keyboard is on. -/// -/// Deliberately not `selection.stroke`, which would be the obvious place: that -/// one is spoken for as the text colour of a picked row. Where the keyboard is -/// and what is picked are two different things and they need two colours, or -/// moving the cursor onto a picked row makes both of them disappear. -pub fn cursor(v: &Visuals) -> Stroke { - let color = if v.dark_mode { - rgb(0x7FA6F0) - } else { - ACCENT_LIGHT - }; - Stroke::new(1.0_f32, color) -} - -/// The ground the column headings stand on. -/// -/// A shade off the list, so that the row of names reads as the lid of the list -/// rather than as its first row. It is the one place in the window where a -/// panel is allowed to be a different colour from the panel next to it: what is -/// above the line is the handle and what is below it is the contents. -pub fn header(v: &Visuals) -> Color32 { - if v.dark_mode { - rgb(0x141414) - } else { - rgb(0xE7EBF2) - } -} - -/// The same, for the column the list is sorted by. -/// -/// One step further from the list than its neighbours, which is enough to pick -/// it out down the whole height of the window without a second colour. -pub fn header_sorted(v: &Visuals) -> Color32 { - if v.dark_mode { - rgb(0x1F1F1F) - } else { - rgb(0xD9E0EC) - } -} - -/// The mark that says which way a column is sorted. -/// -/// The same white as the words beside it, and solid, rather than an accent. Two -/// goes at a blue one came out looking like a stray pixel of some other -/// program's colour scheme: the window is black and white, and a coloured -/// speck in the one row that is nothing but headings has nowhere to belong. It -/// carries at eleven pixels because it is the brightest thing on the darkest -/// band, which is the same reason the headings themselves are legible. -pub fn mark(v: &Visuals) -> Color32 { - if v.dark_mode { - Color32::WHITE - } else { - rgb(0x10141B) - } -} - -/// Sizes and spacing, which are the same whichever way the theme goes. -pub fn style(style: &mut egui::Style) { - use egui::{FontFamily, FontId, TextStyle}; - - style.text_styles = [ - ( - TextStyle::Small, - FontId::new(11.0, FontFamily::Proportional), - ), - (TextStyle::Body, FontId::new(13.5, FontFamily::Proportional)), - ( - TextStyle::Button, - FontId::new(13.5, FontFamily::Proportional), - ), - ( - TextStyle::Heading, - FontId::new(18.0, FontFamily::Proportional), - ), - // The columns of numbers and dates. Slightly smaller than the body: - // a monospace face at the same size always looks a size bigger. - ( - TextStyle::Monospace, - FontId::new(12.5, FontFamily::Monospace), - ), - ] - .into(); - - style.spacing.item_spacing = egui::vec2(8.0, 6.0); - style.spacing.button_padding = egui::vec2(10.0, 5.0); - style.spacing.menu_margin = egui::Margin::same(6.0); - style.spacing.window_margin = egui::Margin::same(12.0); - style.spacing.interact_size.y = 24.0; - // Wide enough to grab a column edge without hitting the text beside it. - style.interaction.resize_grab_radius_side = 6.0; -} - -/// The letters the rest of the desktop is written in. -/// -/// egui ships Ubuntu-Light and Hack. They are good fonts and they look like -/// nothing else on Windows: a window sitting next to the Explorer that does not -/// use the Explorer's letters reads as foreign before you have looked at -/// anything in it. Whatever is missing falls back to what egui brought, so a -/// machine without these still gets a window. -/// The family the toolbar's system icons are drawn from, when there is one. -pub const ICONS: &str = "icons"; - -static HAS_ICONS: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// Whether the system icon font was there to load. Buttons ask before reaching -/// for a codepoint out of it, and fall back to the painted shapes when it is -/// not, which is every platform that is not Windows. -pub fn icons_available() -> bool { - *HAS_ICONS.get().unwrap_or(&false) -} - -#[cfg(windows)] -pub fn fonts() -> egui::FontDefinitions { - let mut defs = egui::FontDefinitions::default(); - let dir = std::env::var_os("SystemRoot") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::PathBuf::from("C:\\Windows")) - .join("Fonts"); - for (name, file, family) in [ - ("segoe-ui", "segoeui.ttf", egui::FontFamily::Proportional), - ("consolas", "consola.ttf", egui::FontFamily::Monospace), - ] { - let Ok(bytes) = std::fs::read(dir.join(file)) else { - continue; - }; - defs.font_data - .insert(name.to_owned(), egui::FontData::from_owned(bytes)); - // In front of what is already there, not instead of it: the fallbacks - // are what draws a glyph these two have not got. - defs.families - .entry(family) - .or_default() - .insert(0, name.to_owned()); - } - - // Windows ships the icons its own programs are drawn with. A padlock and a - // cogwheel out of that file are the ones the rest of the desktop uses and - // are drawn by people who do this for a living; the pair painted by hand - // here came out as a handbag and an asterisk. Segoe Fluent Icons on - // Windows 11, Segoe MDL2 Assets before it, and neither is fatal. - let icons = ["SegoeIcons.ttf", "segmdl2.ttf"] - .iter() - .find_map(|f| std::fs::read(dir.join(f)).ok()); - if let Some(bytes) = icons { - defs.font_data - .insert(ICONS.to_owned(), egui::FontData::from_owned(bytes)); - defs.families - .insert(egui::FontFamily::Name(ICONS.into()), vec![ICONS.to_owned()]); - let _ = HAS_ICONS.set(true); - } - defs -} - -#[cfg(not(windows))] -pub fn fonts() -> egui::FontDefinitions { - egui::FontDefinitions::default() -} diff --git a/arca-gui/src/tree.rs b/arca-gui/src/tree.rs index 32e145b..3c2c904 100644 --- a/arca-gui/src/tree.rs +++ b/arca-gui/src/tree.rs @@ -1,5 +1,4 @@ use arca_core::Entry; -use eframe::egui; use std::collections::BTreeMap; #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -13,20 +12,6 @@ pub enum Kind { Other, } -impl Kind { - fn color(self) -> egui::Color32 { - match self { - Kind::Dir => egui::Color32::from_rgb(232, 184, 92), - Kind::Image => egui::Color32::from_rgb(122, 192, 132), - Kind::Text => egui::Color32::from_rgb(142, 172, 214), - Kind::Archive => egui::Color32::from_rgb(190, 142, 214), - Kind::Audio => egui::Color32::from_rgb(214, 142, 160), - Kind::Video => egui::Color32::from_rgb(214, 160, 112), - Kind::Other => egui::Color32::from_rgb(150, 152, 158), - } - } -} - pub fn kind_of(name: &str, is_dir: bool) -> Kind { if is_dir { return Kind::Dir; @@ -46,45 +31,6 @@ pub fn kind_of(name: &str, is_dir: bool) -> Kind { } } -/// The hand drawn icon, in the space the layout gives it. -pub fn draw_icon(ui: &mut egui::Ui, kind: Kind) { - let (rect, _) = ui.allocate_exact_size(egui::vec2(15.0, 15.0), egui::Sense::hover()); - draw_icon_at(ui, rect, kind); -} - -/// The same, in a rectangle the caller has already decided on: the tree places -/// its own rows and has nowhere to allocate from. -pub fn draw_icon_at(ui: &mut egui::Ui, rect: egui::Rect, kind: Kind) { - let p = ui.painter(); - let c = kind.color(); - let faded = egui::Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), 110); - - if kind == Kind::Dir { - let tab = - egui::Rect::from_min_size(rect.left_top() + egui::vec2(1.0, 2.5), egui::vec2(6.0, 2.5)); - p.rect_filled(tab, 1.0, c); - let body = egui::Rect::from_min_max( - rect.left_top() + egui::vec2(1.0, 4.5), - rect.right_bottom() - egui::vec2(1.0, 2.0), - ); - p.rect_filled(body, 2.0, c); - return; - } - - let body = egui::Rect::from_min_max( - rect.left_top() + egui::vec2(2.5, 1.5), - rect.right_bottom() - egui::vec2(2.5, 1.5), - ); - p.rect_filled(body, 1.5, faded); - p.rect_stroke(body, 1.5, egui::Stroke::new(1.0_f32, c)); - let fold = vec![ - egui::pos2(body.right() - 4.5, body.top()), - egui::pos2(body.right(), body.top() + 4.5), - egui::pos2(body.right() - 4.5, body.top() + 4.5), - ]; - p.add(egui::Shape::convex_polygon(fold, c, egui::Stroke::NONE)); -} - #[derive(Clone)] pub struct Row { pub label: String, @@ -348,12 +294,6 @@ pub struct Folder { pub kids: BTreeMap, } -impl Folder { - pub fn is_empty(&self) -> bool { - self.kids.is_empty() - } -} - pub fn folders_of(entries: &[Entry]) -> Folder { let mut root = Folder::default(); for e in entries { @@ -422,7 +362,7 @@ mod folder_tests { "loose files bring no folder with them" ); assert_eq!(root.kids["a"].kids.keys().collect::>(), ["b"]); - assert!(root.kids["a"].kids["b"].is_empty()); - assert!(root.kids["empty"].is_empty()); + assert!(root.kids["a"].kids["b"].kids.is_empty()); + assert!(root.kids["empty"].kids.is_empty()); } } diff --git a/docs/plans/gpui-spike-baseline.md b/docs/plans/gpui-spike-baseline.md new file mode 100644 index 0000000..314bc57 --- /dev/null +++ b/docs/plans/gpui-spike-baseline.md @@ -0,0 +1,74 @@ +# G0/G1 GPUI spike baseline + +Estado: spike aislado; `arca-gui` ya usa GPUI y GPUI Kit. + +## G0 congelado + +- Pin de G1: `zed-industries/zed@3384317a9931a21bb5ad8706f0f9d82cb02a71ec`. + **Superado en G7**: `arca-gui` ya no depende de ese rev, sino de las cajas + publicadas de GPUI Kit (`gpui-pre` / `gpui-pre-platform` / `gpui-component`), + porque `gpui-component` se construye contra `gpui-pre ^0.3` y mantener el rev + de git dejaba dos copias de GPUI en el grafo. `spikes/gpui` conserva el pin + original: es el registro de lo que se validó en G1 y no se reescribe. +- Toolchain objetivo: Rust `1.97.1` (el árbol actual de Arca declara MSRV + `1.75`; este spike no cambia ese contrato). +- Fixture: `spikes/gpui/fixtures/entries-6000.txt`, generado de forma + determinista y validado por `cargo test` del spike. +- Capturas manuales que deben acompañar la ejecución local: `empty-window`, + `fixture-6000`, `filter-ime`, `modal-blocking`, `file-drop` y + `accessibility-list`. No se inventan capturas en CI; el resultado se anota + en esta matriz con la plataforma, backend y fecha. + +### Matriz manual + +| Caso | Windows | Linux X11/Wayland | macOS | Resultado/fecha | +| --- | --- | --- | --- | --- | +| Ventana vacía y cierre | validado manualmente | pendiente | pendiente | ventana visible y cierre disponible en Windows GNU | +| 6.000 filas virtualizadas | código + test + validación visual | pendiente | pendiente | ventana ejecutada; lista virtualizada visible | +| Ctrl/Shift, cursor y scroll al cursor | código | pendiente | pendiente | validación manual completa pendiente | +| Columnas redimensionables | código | pendiente | pendiente | celdas reales y delta relativo; validación manual pendiente | +| Filtro: selección, IME, foco y Tab | código + tests + foco observado | pendiente | pendiente | campo Filter visible y editable; IME/Tab manual pendiente | +| Lector de pantalla: filas/campo/modal/progreso | código | pendiente | pendiente | Narrator/NVDA pendiente | +| Modal bloquea clicks del fondo y Escape | validado manualmente | pendiente | pendiente | modal apareció, ocultó fondo y Escape lo cerró | +| PNG RGBA cargado | validado manualmente | pendiente | pendiente | icono cargado en ventana Windows | +| Explorer/file-drop | pendiente | pendiente | pendiente | pendiente de prueba manual | +| `arca-drag` virtual, cancelación y extracción diferida | pendiente | N/A | N/A | pendiente de prueba manual Windows | + +La ejecución disponible en esta máquina queda registrada así: + +- Windows GNU: el toolchain `1.97.1-x86_64-pc-windows-gnu` está instalado y pasan + `rustup run 1.97.1 cargo check --manifest-path spikes/gpui/Cargo.toml --locked`, + `rustup run 1.97.1 cargo test --manifest-path spikes/gpui/Cargo.toml --locked` + (4 pruebas) y `rustup run 1.97.1 cargo check --workspace --locked`. También + pasan `cargo fmt --manifest-path spikes/gpui/Cargo.toml -- --check`, el check + de `platform-probes` y la inspección manual de ventana, filtro, lista, icono, + modal y Escape. +- Linux y macOS: no se marcan como compilados; los targets + `x86_64-unknown-linux-gnu` y `x86_64-apple-darwin` no están instalados. +- Arca: `cargo check --workspace` y `cargo test --workspace` pasan. El + `cargo fmt --all -- --check` existente falla en archivos de Arca por drift + de formato; no se reformatean esos archivos para no tocar la UI productiva. + +No se marca una plataforma como validada sin prueba manual real. + +## Defecto de persistencia de `columns` (resuelto) + +`arca-gui/src/main.rs::Settings::save` escribía `columns = ...` y +`Settings::load` no leía esa clave, así que los cambios de columnas se perdían +al reiniciar aunque `gui.conf` conservara la línea. Registrado aquí durante G1 +y **no** corregido entonces a propósito: la UI productiva y el formato +`gui.conf` no debían cambiar durante el spike. + +`Settings::load` ya lee la clave. El formato de `gui.conf` no cambió. + +## G1 aislado + +El binario está en `spikes/gpui`, fuera del workspace. Solo añade `gpui` y +`gpui_platform` desde el pin anterior; `gpui_platform` usa features por target. +El binario no importa crates productivos en su camino normal, no toca la +ventana productiva y no materializa entradas para drag-out. El feature +`platform-probes` mantiene probes de compilación Windows aislados para `rfd`, +`clipboard-win` y `arca-drag`; el check realizado demuestra que sus APIs +públicas compilan juntas, pero no sustituye una prueba interactiva de OLE, +clipboard o diálogos. Si falla input/IME, AccessKit, file-drop, `arca-drag` o +el loop de UI, se detiene la migración. diff --git a/docs/plans/migration-to-gpui.md b/docs/plans/migration-to-gpui.md new file mode 100644 index 0000000..3c5fc07 --- /dev/null +++ b/docs/plans/migration-to-gpui.md @@ -0,0 +1,211 @@ +# Plan de migración de `arca-gui` a GPUI Kit + +## Resumen + +Migrar únicamente la interfaz de Arca —no `arca-core`, `arca-zip`, `arca-tar`, CLI ni los formatos— a GPUI Kit, conservando el comportamiento actual y haciendo la transición por fases. La dependencia de GPUI queda fijada por `Cargo.lock` para evitar cambios involuntarios de API. + +El punto de partida real es `arca-gui`: una ventana de aproximadamente 4.600 líneas en `src/main.rs`, más `theme.rs`, `tree.rs`, `glyphs.rs`, `clipboard.rs`, `i18n.rs` y `build.rs`. La UI actual incluye exploración jerárquica de ZIP/TAR/TAR.GZ, tabla con columnas configurables, selección de filas y carpetas, ordenación/redimensionado, filtro, breadcrumbs, doble clic, atajos, temas claro/oscuro/sistema, inglés/español, progreso, diálogos, drag-and-drop, clipboard Windows y drag-out mediante `arca-drag`. + +## Fases + +### 1. Línea base y spike de GPUI + +- Registrar el estado actual con: + - `cargo test --workspace`. + - `cargo build --release`. + - compilación/prueba específica en Windows. + - prueba manual de ZIP, TAR y TAR.GZ, contraseña, conflictos, selección, clipboard y drag-and-drop. +- Capturar una matriz de comportamiento y dimensiones mínimas de ventana para usarla como criterio de paridad. +- Añadir GPUI Kit como dependencia de `arca-gui` desde la versión fijada. +- Crear una ventana mínima GPUI que compile y arranque en las plataformas soportadas. +- Verificar en este spike: + - versión mínima de Rust requerida; + - backend de ventana/renderizado en Windows, Linux y macOS; + - texto, fuentes, imágenes, teclado, rueda, selección, menús, diálogos, drag-and-drop y accesibilidad; + - integración con `rfd`, `clipboard-win` y `arca-drag`. +- Si el commit viable no soporta Rust 1.75, elevar `rust-version` y actualizar CI/documentación al mínimo requerido por GPUI. No se mantendrá una compatibilidad artificial con una versión de Rust que GPUI no soporte. + +### 2. Separar el estado de aplicación del toolkit anterior + +Sin reescribir la lógica de compresión: + +- Extraer de `Arca` un estado/controlador de aplicación independiente del toolkit para: + - archivo abierto, entradas, carpeta actual e historial; + - selección, cursor, filtro y ordenación; + - configuración, idioma, tema y columnas; + - trabajos activos, progreso, errores y avisos; + - estados pendientes de contraseña, conflicto, borrado y drop. +- Mantener `Job`, `Message`, `Answer`, `Pending`, `Format`, `Columns`, `SortColumn` y las funciones de archivo en Rust normal, aislados de las APIs visuales. +- Mantener el controlador desacoplado mediante eventos/acciones explícitos: abrir, extraer, comprimir, borrar, añadir, copiar, pegar, navegar y cancelar. +- Mantener los workers en hilos separados y el canal de mensajes; GPUI solo recibirá eventos y solicitará actualización de la vista. Ninguna operación de disco o compresión debe bloquear el hilo de UI. +- Conservar las pruebas existentes de `tree.rs` y de `main.rs`; trasladar a pruebas puras las reglas de selección, navegación, ordenación, nombres libres, progreso y transiciones de diálogos. + +### 3. Shell de aplicación GPUI + +G3 queda implementado con GPUI Kit como backend único. El shell usa +`AppController`, toolbar, tabla y diálogos GPUI. GPUI requiere Rust 1.97.1. + +- Usar el ciclo de aplicación, ventana y root view de GPUI Kit. +- Preservar: + - título dinámico `nombre — Arca`; + - tamaño inicial compacto para acciones de línea de comandos; + - tamaño inicial normal para navegación; + - tamaño mínimo de ventana; + - icono incluido mediante `build.rs` en Windows; + - cierre automático de operaciones lanzadas desde el shell y ventana de resultados para operaciones interactivas. +- Crear una única vista raíz GPUI que derive su representación del estado independiente y procese acciones generadas por los componentes. +- Reemplazar `request_repaint`/`request_repaint_after` por el mecanismo de invalidación/notificación y temporizador de GPUI, manteniendo actualizaciones de progreso aproximadamente cada 100 ms y redibujado continuo solo cuando sea necesario. + +### 4. Migración de la UI por superficies + +Implementar y validar cada superficie con GPUI Kit: + +1. **Toolbar y navegación** + - Abrir, comprimir, extraer todo, extraer selección, contraseña y menú de overflow. + - Campo de filtro con foco, placeholder y atajos. + - Atrás, adelante, subir y breadcrumbs con truncado y menú de carpetas ocultas. + - Contador de visibles/seleccionados. + +2. **Listado de archivos** + - Usar la lista virtualizada de GPUI para la tabla dentro de `arca-gui`. + - Mantener columnas Nombre, Tamaño, Packed, Método, Ahorro, Modificado y CRC32. + - Mantener columnas configurables y persistencia en `gui.conf`. + - Mantener ordenación, indicador triangular, redimensionado desde cabecera, filas de carpetas antes que archivos y renderizado de iconos. + - Mantener selección simple, Ctrl/Cmd, Shift, selección de carpeta, cursor de teclado, Home/End/PageUp/PageDown y scroll al cursor. + - Mantener doble clic, Enter, menú contextual, goma de selección y autoscroll durante selección. + +3. **Estados vacíos y barra de estado** + - Empty state con indicación de drop. + - Progreso, archivo actual, errores, avisos y resumen del archivo abierto. + - Vista de operación con progreso, duración, resultado y cierre. + +4. **Diálogos y overlays** + - Configuración de idioma, tema, formato, codec, nivel y extracción a subcarpeta. + - Contraseña nueva/actual/necesaria, visibilidad de contraseña y Enter/Escape. + - Conflicto de destino con Replace/Skip/Rename y variantes “all”. + - Confirmación de borrado. + - Confirmación de abrir o añadir un archivo arrastrado sobre otro archivo. + - Ventana de atajos. + - Todos los diálogos serán modales o bloquearán explícitamente las acciones de fondo mientras esperan respuesta. + +### 5. Tema, tipografía, iconos y pintura + +- Convertir `theme.rs` en tokens de tema propios de Arca: colores, fondos, bordes, selección, cursor, radios, espaciado y tamaños tipográficos. +- Mantener tema claro, oscuro y sistema, y guardar/cargar la preferencia existente. +- Reutilizar las fuentes del sistema en Windows y las fuentes de fallback del backend GPUI. +- Portar `glyphs.rs` a la primitiva de dibujo de GPUI disponible; no añadir una librería de iconos para sustituir ocho figuras ya dibujadas. +- Portar el icono de tipo de archivo y la caché por extensión. La caché deberá guardar el equivalente GPUI de textura/imagen y recordar fallos igual que ahora. +- Portar las formas especiales: triángulo de ordenación, cursor, selección de goma, overlay de drop y puntero de autoscroll. +- Revisar contraste y semántica accesible de botones, filas, menús, campos y diálogos mediante AccessKit/GPUI. + +### 6. Integraciones de plataforma + +- Mantener `rfd` para selección de archivos y carpetas. +- Mantener `clipboard-win` y su implementación CF_HDROP para copiar/cortar/pegar archivos en Windows. +- Mantener `arca-drag` para drag-out en Windows y preservar su manejo de liberación/cancelación. +- Mantener drop de archivos hacia la ventana en las plataformas donde GPUI lo exponga; adaptar solo el puente de eventos. +- Mantener apertura mediante la aplicación del sistema y el comportamiento de archivos temporales. +- Si una capacidad de GPUI no tiene equivalente directo, encapsular únicamente ese puente en un módulo de plataforma; no contaminar el estado de negocio con APIs GPUI. + +### 7. Rediseño visual posterior con GPUI Kit + +Después de alcanzar la paridad funcional, validar accesibilidad y completar la matriz de plataforma, hacer un rediseño visual completo de la superficie GPUI usando [GPUI Kit](https://gpui-kit.com/apps/) como referencia de componentes y dirección visual. + +#### Decisiones tomadas al abrir la fase + +- **GPUI Kit pasa a ser dependencia real, no solo referencia.** El pin a un rev de + zed se retira: `gpui-component` se construye contra `gpui-pre ^0.3`, y mantener + el rev de git dejaba dos copias de GPUI en el grafo, que no enlazan. `arca-gui` + depende ahora de `gpui-pre` y `gpui-pre-platform` renombrados a `gpui` y + `gpui_platform` en `Cargo.toml`, de modo que ningún `use gpui::...` cambia. La + reproducibilidad la da `Cargo.lock`, igual que antes la daba el rev. +- **Monocromo con tinte de marca.** Seis grises por modo, hue 225 (el azul noche + de `brand/BRAND.md`) al 8-12% de saturación, invertidos entre claro y oscuro. +- **Sin color de acento.** Selección, cursor de teclado y anillo de foco son el + color de texto a distinta fuerza, así que el contraste está garantizado por + construcción. Los únicos píxeles saturados son `danger` y `warning`, que + distinguen "extraído" de "no extraído" y no son decoración. +- **Radio 4/6 px, fila de 26 px, fuente base 13 px.** +- Los tokens viven en `arca-gui/src/gpui_theme.rs` y son los de `gpui-component`; + Arca no añade una capa de tokens propia. +- Mantener intactos `AppController`, `AppAction`, workers, formatos de `gui.conf` e integraciones de plataforma. +- Rediseñar toolbar, navegación, tabla, estados vacíos, progreso, notificaciones, menús y diálogos con un sistema coherente de tokens, espaciado, tipografía, iconos, estados hover/focus/disabled y responsive behavior. +- Conservar roles AccessKit, foco visible, teclado, contraste y soporte de lector de pantalla; el rediseño no puede degradar la matriz de accesibilidad. +- Comparar manualmente antes/después en ventanas compactas y normales, con archivos vacíos, listas grandes, errores, progreso y diálogos abiertos. +- Gestionar esta fase con un modelo especializado en diseño de interfaces y revisión visual, separando decisiones estéticas de cambios de lógica. + +#### Estado + +- **G7.1 hecho** — dependencia GPUI Kit, `gpui_theme.rs` monocromo claro/oscuro/ + sistema, y los 67 colores incrustados de `gpui_shell.rs` sustituidos por + tokens. `cargo test --workspace` y `cargo test -p arca-gui` + en verde. +- **G7.2 hecho** — layout. La referencia es **Nohrs** (mismo problema: un + explorador de ficheros) con la densidad de **DBFlux**. La ventana deja de ser + una pila de tiras flotando en padding y pasa a ser regiones a sangre separadas + por líneas de 1 px: + + ```text + ┌─ barra de acciones (40 px) ────────────────── [filtrar] ─┐ + ├─ ← → ↑ │ ruta (34 px) ─────────────────────────────────┤ + │ carpetas │ tabla a sangre │ + │ (224 px) │ │ + ├────────────┴─────────────────────────────────────────┤ + │ resumen del archivo N visibles │ M elegidos │ + └───────────────────────────────────────────────────┘ + ``` + + - **Barra lateral con el árbol de carpetas del archivo** + (`gpui_component::sidebar`). Es contenido nuevo, no cromo: antes un archivo + profundo solo se recorría descendiendo a doble clic y volviendo atrás. La + rama de la carpeta actual se abre sola; el resto queda cerrado. El árbol se + cachea por (ruta del archivo, número de entradas). + - **Barra de estado** (`gpui_component::status_bar`) soldada abajo, con el + resumen a la izquierda y los contadores a la derecha, que antes vivían en + medio de la fila de navegación. + - **Botones sin borde**, con el fondo apareciendo solo bajo el puntero. Una + fila de siete cajas con contorno se leía como siete cosas compitiendo. + - **Flechas con icono** (`IconName`, vía `gpui-kit-assets`) en vez de ‹ › ↑. + - **Menús flotantes**: overflow y carpetas ocultas eran `absolute`, antes se + dibujaban en el flujo y empujaban media ventana hacia abajo. + - **Tabla a sangre**, sin borde ni radio propios, con cabecera fijada y + franjas alternas. +- **G7.3 pendiente** — sustituir los widgets internos por los de + `gpui-component`: `Input` (retira las ~400 líneas de `FilterInput` y su + contrato UTF-16 con el IME), `Button`, `Modal`/`Root` para los diálogos, + `Popover` real anclado al disparador en vez de `absolute` con offsets fijos, + `Table` y `Notification`. +- **G7.4 pendiente** — el diálogo de configuración de la superficie GPUI, que es + lo que permite cambiar tema e idioma sin editar `gui.conf`. Hasta entonces la + preferencia se lee al arrancar y `System` sigue al escritorio. + +### 8. Retirada del backend anterior + +Cuando la vista GPUI alcance la matriz de paridad: + +- Mantener únicamente GPUI y GPUI Kit en `Cargo.toml` y regenerar `Cargo.lock`. +- Retirar imports y tipos del toolkit anterior de los módulos de interfaz. +- Eliminar el adaptador y los tests que dependan de coordenadas del backend anterior; conservar sus invariantes como pruebas del nuevo modelo. +- Revisar comentarios para documentar GPUI o el comportamiento de Arca, no la implementación anterior. +- Actualizar README y cualquier documentación de build con la nueva dependencia, MSRV y requisitos de plataforma. + +## Criterios de aceptación + +- `cargo test --workspace` pasa sin regresiones. +- `cargo build --release` pasa en las plataformas soportadas y la build Windows conserva icono, clipboard y drag-out. +- La UI arranca con y sin archivo, y también en los modos `--extract-here`, `--extract-to-folder`, `--test`, `--add` y `--add-quick`. +- Se conserva la paridad de ZIP, TAR, TAR.GZ, cifrado AES-256, extracción, compresión, test, borrado y adición. +- Se conserva la matriz de interacción: teclado, ratón, doble clic, selección múltiple, cursor, filtro, ordenación, redimensionado, rueda/autoscroll, menús y Escape. +- Se conserva la configuración existente de idioma, tema y columnas sin cambiar el formato `gui.conf`. +- Se validan lectores de pantalla y foco de teclado en Windows. +- Se comprueba que ninguna operación pesada se ejecuta en el hilo de UI y que el progreso sigue actualizándose durante compresión/extracción. +- Se realiza una comparación visual manual de toolbar, tabla, diálogos, temas y estados vacíos contra la línea base, aceptando solo diferencias propias de GPUI que no alteren jerarquía ni legibilidad. + +## Supuestos fijados + +- El alcance es todo `arca-gui`, no una migración de los crates de compresión. +- Se busca paridad completa, no un prototipo ni una reducción temporal de funciones. +- La transición será incremental, pero GPUI será el backend final único. +- GPUI se fijará a un commit/tag reproducible del repositorio de Zed; la selección exacta se hará en el spike de compilación y quedará registrada en `Cargo.toml`/`Cargo.lock`. +- Se reutilizarán dependencias existentes (`rfd`, `clipboard-win`, `arca-drag`) y no se añadirá una librería de tabla o iconos salvo que el spike demuestre que GPUI no puede cubrir una capacidad imprescindible. +- No se migrará la lógica de archivos ni se introducirán abstracciones de negocio nuevas: solo se separará el estado mínimo necesario para que la vista no dependa del toolkit. diff --git a/docs/plans/portar-controlador-a-main.md b/docs/plans/portar-controlador-a-main.md new file mode 100644 index 0000000..53f289b --- /dev/null +++ b/docs/plans/portar-controlador-a-main.md @@ -0,0 +1,201 @@ +# Portar `AppController` al `main.rs` de hoy + +Estado: transformación hecha; `cargo test --workspace`, `cargo test -p arca-gui` y `cargo fmt -p arca-gui -- --check` pasan. + +Hecho en esta tanda: + +- `AppState` + `AppController` separados de `Arca`; la superficie GPUI conserva sus gestos y texturas. +- `AppAction` + `dispatch` disponibles para GPUI. +- `spawn` y los métodos de trabajo no dependen del toolkit visual. +- `gpui_shell` usa `tree::Folder` y `state.folders`, sin caché duplicada. + +## Dónde está cada cosa + +| | qué es | +| --- | --- | +| `gpui-kit-redesign-v2` | **la rama viva**. Sale del `main` de hoy. Trae GPUI Kit y los módulos de shell/tema declarados en `main.rs`. Verde en `cargo test --workspace` y en `cargo check -p arca-gui`. | +| `gpui-kit-redesign` | la rama vieja, sobre `c4c0354`. **No borrar**: su `main.rs` es la implementación de referencia de la extracción. | +| PR #1 | apunta a la rama vieja, en borrador y en conflicto. Cuando la v2 esté completa, se repunta o se abre otra. | + +**La implementación de referencia está en la rama vieja.** `AppState`, +`AppController`, `AppAction` y `dispatch` ya se escribieron una vez, sobre la +base de 4.441 líneas. No hay que inventarlos, hay que rehacerlos sobre un +fichero que creció: + +```sh +git show gpui-kit-redesign:arca-gui/src/main.rs > /tmp/referencia.rs +``` + +Ahí están `struct AppState` (55 campos), `struct AppController { state: +AppState }`, `struct Arca { controller, icons, band, wheel }`, el `enum +AppAction` completo y los 48 métodos del controlador, ya con los receptores +reescritos. La diferencia contra lo que hay que hacer ahora es que `main` +añadió 8 campos y 10 métodos más. + +### Regenerar los inventarios + +Las tablas de más abajo se sacaron con esto, por si el fichero se mueve otra +vez: + +```sh +# contrato que el shell necesita +rg -o 'controller\.state\.([a-z_0-9]+)' -r '$1' arca-gui/src/gpui_shell.rs | sort -u +rg -o 'controller\.([a-z_]+)\(' -r '$1' arca-gui/src/gpui_shell.rs | sort -u +rg -o 'AppAction::([A-Za-z_]+)' -r '$1' arca-gui/src/gpui_shell.rs | sort -u + +# metodos de impl Arca, en orden +rg -n '^ (pub )?fn [a-z_0-9]+' arca-gui/src/main.rs +``` + +La rama `gpui-kit-redesign` original se hizo sobre `c4c0354`. Mientras vivía, +`main` avanzó 18 commits que reescriben `arca-gui/src/main.rs`. Los dos lados +partieron de un fichero de 4.441 líneas: + +| | `main.rs` | qué añadió | +| --- | --- | --- | +| base `c4c0354` | 4.441 | — | +| `origin/main` | 6.103 | +1.662: renombrar en el archivo, visor, vista plana, grupos por máscara, columnas, árbol de carpetas, modo oscuro B/N | +| `gpui-kit-redesign` | 5.269 | +828: sacar el estado a `AppController` | + +Los 46 hunks del merge son el mismo conflicto repetido: la rama renombró +*todos* los accesos al estado y `main` escribió 1.662 líneas nuevas contra la +forma vieja. Resolverlo no es elegir un lado, es rehacer la extracción encima +del `main` de hoy. Esta es la razón de hacerlo así y no resolviendo el merge: +**el compilador verifica la transformación**. Un hunk mal resuelto compila; un +`self.archive` que se escape de la reescritura, no. + +## Contrato que `gpui_shell.rs` necesita + +No es negociable: si algo de esto no existe, la ventana GPUI no compila. + +**14 métodos de `AppController`** +`can_go_back`, `can_go_forward`, `codec_name`, `cut_landed`, `dispatch`, +`drag_out`, `is_checked`, `level_name`, `open`, `receive`, `run_job`, `s`, +`summary`, `visible_rows` + +**29 campos de `controller.state`** +`add_password`, `archive`, `busy`, `checked`, `codec`, `confirm_delete`, +`confirm_drop`, `conflict`, `current_dir`, `current_file`, `cursor`, +`cut_pending`, `done_count`, `entries`, `error`, `filter`, `format`, `level`, +`notice`, `order`, `output_name`, `password_input`, `pending_inputs`, +`replies`, `settings`, `show_password`, `total_count`, `view`, +`waiting_on_password`, `window_title` + +**28 variantes de `AppAction`** +`AnswerConflict`, `AnswerDrop`, `Back`, `BeginPasswordChange`, `CancelJob`, +`CancelPassword`, `ClearSelection`, `ConfirmDelete`, `Copy`, `Drop`, +`ExtractTo`, `Forward`, `InvertVisible`, `Navigate`, `Open`, `OpenFile`, +`Paste`, `PrepareCompress`, `RequestDelete`, `Run`, `SelectAllVisible`, +`SetChecked`, `SetFilter`, `SetPasswordInput`, `Sort`, `SubmitPassword`, +`ToggleColumn`, `TogglePasswordVisibility` + +## Reparto de campos + +`struct Arca` en `origin/main` tiene 63 campos. El reparto es: + +- **`Arca` se queda 4**: `controller`, `icons`, `band`, `wheel`. Son los únicos + con tipos específicos del backend visual o estado de gesto del ratón. +- **`AppState` se lleva los otros 60**, más 3 que añadió la extracción y que no + existen ni en la base ni en `main`: `cancel_token`, `extract_dialog`, + `window_title`. + +Los 8 campos nuevos de `main` van **todos** a `AppState`: son datos puros. + +| campo | por qué a `AppState` | +| --- | --- | +| `types` | caché de texto por extensión, no una textura (esa es `icons`) | +| `renaming`, `rename_fresh` | ruta y texto a medio escribir; GPUI también renombrará | +| `folders` | `tree::Folder`, el árbol de carpetas | +| `viewing` | `Viewed` no lleva tipos del backend visual | +| `picking_group`, `mask` | selección por máscara | +| `geometry` | cuatro `f32` para que `on_exit` tenga qué escribir | + +## Reparto de los 57 métodos de `impl Arca` + +**A `impl AppController` (35)** +`s`, `level_name`, `codec_name`, `summary`, `visible_rows`, `spawn`, +`remember`, `open`, `run_job`, `receive`, `extract_here`, `ask_extract`, +`selected_names`, `selected_roots`, `copy_to_clipboard`, `cut_landed`, +`dragged_files`, `drag_out` (×2), `paste_from_clipboard`, `add_files`, +`dropped`, `view_entry`, `cancel_password`, `rename_to`, `set_checked`, +`open_file`, `go_to`, `clear_picked`, `can_go_back`, `can_go_forward`, +`go_back`, `go_forward`, `is_checked` + +**Se quedan en `impl Arca` (22)** +`tree_panel`, `settings_row`, `format_row`, `drop_hint`, +`confirm_drop_window`, `shortcuts`, `viewer_window`, `group_window`, +`confirm_delete_window`, `password_window`, `conflict_window`, `toolbar`, +`breadcrumb`, `shortcuts_window`, `settings_window`, `add_view`, +`running_view`, `column_edges`, `wheel_scroll`, `rubber_band`, `keyboard`, +`table` + +**`new` se parte en dos**: `AppController::new(settings)` y `Arca::new(...)`. + +## El punto que no es mecánico + +`spawn` recibía contexto visual en `main` y solicitaba repintado. La +extracción tiene que quitarle ese parámetro y sustituir el repintado por algo +que los dos backends puedan pedir. Es el único sitio donde la transformación no +es renombrar: lo demás es mover el método y reescribir el receptor. + +## Orden de ejecución + +1. Partir `struct Arca` en `AppState` + `AppController` + `Arca`. +2. Partir `impl Arca` en dos bloques moviendo los métodos de UI al final. +3. Reescribir receptores, que es una regla por bloque: + - en `impl AppController`: `self.` → `self.state.` + - en `impl Arca`: `self.` → `self.controller.state.` y + `self.()` → `self.controller.()` +4. Añadir `enum AppAction` y `dispatch`. +5. Quitar el contexto visual de `spawn`. +6. Compilar y arreglar hasta que `cargo check` calle. **Este es el paso que + verifica**: cada acceso que se escape sale como error. +7. `mod gpui_shell` / `mod gpui_theme` y el `main` que arranca GPUI Kit. +8. Simplificar `gpui_shell.rs`: borrar el `struct Folder` y su caché hechos a + mano y usar `tree::folders_of` y `state.folders`, que `main` ya trae. + +## Lo que hay que comprobar al final + +Automático: + +```sh +cargo test --workspace +cargo test -p arca-gui +cargo fmt -p arca-gui -- --check +``` + +Y **a mano, que es lo que de verdad comprueba esto**: `cargo test` no toca la +UI, así que si la extracción se come una de las funciones que trajo +`main`, la suite pasa igual de verde. Hay que abrir la ventana de GPUI +(`cargo run -p arca-gui`) y probar una por una: + +- [ ] renombrar una entrada con F2 y desde el menú +- [ ] mirar un fichero sin sacarlo del archivo (el visor) +- [ ] la vista plana +- [ ] coger un grupo por máscara, y probar sólo lo elegido +- [ ] extraer aquí +- [ ] las columnas: ajustar, ordenar, y que sigan como se dejaron al reabrir +- [ ] el árbol de carpetas del panel lateral +- [ ] el modo oscuro en blanco y negro + +Y la ventana GPUI (`cargo run -p arca-gui`), que antes del +conflicto quedó funcionando: barra de acciones, ruta, árbol lateral, tabla, +barra de estado, claro y oscuro. + +## Lo que quedaba pendiente de la fase 7, aparte de esto + +No se pierde de vista por el desvío del merge; está en +`migration-to-gpui.md`, sección 7, apartado «Estado»: + +1. Cambiar los widgets hechos a mano por los de `gpui-component`: `Input` + (borra ~400 líneas de `FilterInput` y su contrato UTF-16 con el IME), + `Modal`/`Root`, `Popover`, `Table`, `Notification`. +2. Los menús flotan con `absolute` y offsets fijos (`top(38.) right(232.)`), + no anclados al disparador. Se rompe si cambia el ancho del filtro o el + tamaño de fuente. +3. No hay diálogo de configuración en la superficie GPUI. +4. Matriz de plataforma: sólo Windows GNU. +5. Lector de pantalla sin validar con Narrator/NVDA. +6. Tres etiquetas de botón reconstruidas a mano en `gpui_shell.rs` que merecen + una mirada: `"Set Password"`/`"Unlock"`, `"Keep Both"`/`"Keep Both Always"` + y `"Delete"`.