├── .github └── workflows │ ├── ci.yml │ ├── python.yml │ └── python.yml.patch ├── .gitignore ├── .mailmap ├── Cargo.lock ├── Cargo.toml ├── Justfile ├── benchmark ├── measurements.Rmd ├── measurements.pdf ├── measurements_fast_dark.svg ├── measurements_fast_light.svg ├── measurements_slow_dark.svg ├── measurements_slow_light.svg ├── raw.tsv.gz └── update_rustdoc.py ├── changelog.md ├── crates ├── benchmark │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── pycolorsaurus │ ├── .gitignore │ ├── Cargo.toml │ ├── Justfile │ ├── pyproject.toml │ ├── src │ │ └── lib.rs │ └── uv.lock ├── terminal-colorsaurus │ ├── Cargo.toml │ ├── doc │ │ ├── caveats.md │ │ ├── comparison.md │ │ ├── feature-detection.md │ │ ├── latency-rustdoc.md │ │ └── terminal-survey.md │ ├── examples-utils │ │ └── display.rs │ ├── examples │ │ ├── bg.rs │ │ ├── fg.rs │ │ ├── pager.rs │ │ └── theme.rs │ ├── license-apache.txt │ ├── license-mit.txt │ ├── readme.md │ └── src │ │ ├── color.rs │ │ ├── color_scheme_tests.rs │ │ ├── error.rs │ │ ├── fmt.rs │ │ ├── io │ │ ├── mod.rs │ │ ├── poll │ │ │ ├── macos.rs │ │ │ ├── mod.rs │ │ │ ├── unix.rs │ │ │ └── windows.rs │ │ ├── read_until.rs │ │ ├── term_reader.rs │ │ └── time_out.rs │ │ ├── lib.rs │ │ ├── quirks.rs │ │ ├── unsupported.rs │ │ └── xterm.rs ├── termtheme │ ├── Cargo.toml │ ├── license-apache.txt │ ├── license-mit.txt │ └── src │ │ └── main.rs └── xterm-color │ ├── Cargo.toml │ ├── changelog.md │ ├── license-apache.txt │ ├── license-mit.txt │ ├── readme.md │ └── src │ └── lib.rs ├── deny.toml ├── doc ├── feature-detection.md ├── latency-rustdoc.md ├── latency.md └── terminal-survey.md ├── license-apache.txt ├── license-mit.txt ├── readme.md └── typos.toml /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | RUSTFLAGS: -Dwarnings 12 | 13 | jobs: 14 | pre-build: 15 | name: Pre-Build 16 | runs-on: ubuntu-latest 17 | outputs: 18 | rust-version: ${{ steps.rust-version.outputs.rust-version }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Determine Rust Version 22 | id: rust-version 23 | run: | 24 | rust_version=$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[] | select(.name == "terminal-colorsaurus") | .rust_version') 25 | echo "rust-version=$rust_version" >> "$GITHUB_OUTPUT" 26 | build: 27 | needs: pre-build 28 | name: ${{ format('Build ({0})', matrix.rust-version) }} 29 | runs-on: ubuntu-latest 30 | strategy: 31 | matrix: 32 | rust-version: ['${{needs.pre-build.outputs.rust-version}}', stable, nightly] 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: taiki-e/install-action@v2 36 | with: 37 | tool: just 38 | - name: Use Rust ${{matrix.rust-version}} 39 | if: matrix.rust-version != 'stable' 40 | run: rustup override set '${{matrix.rust-version}}' 41 | - name: Install Components 42 | if: matrix.rust-version != 'stable' 43 | run: rustup component add clippy 44 | - name: Build 45 | run: cargo build --workspace --all-features 46 | - name: Check 47 | run: just check 48 | docs: 49 | name: Docs 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v4 53 | - uses: taiki-e/install-action@v2 54 | with: 55 | tool: just 56 | - run: rustup override set nightly 57 | - uses: dtolnay/install@cargo-docs-rs 58 | - name: Build Docs 59 | run: just doc 60 | env: 61 | RUSTDOCFLAGS: -Dwarnings 62 | - name: Build Docs 63 | run: just doc xterm-color 64 | env: 65 | RUSTDOCFLAGS: -Dwarnings 66 | lint: 67 | runs-on: ubuntu-latest 68 | name: Lint 69 | steps: 70 | - uses: actions/checkout@v4 71 | - name: Check format 72 | run: cargo fmt -- --check 73 | - name: Run clippy 74 | run: cargo clippy --workspace --all-targets --all-features -- --deny warnings 75 | - uses: EmbarkStudios/cargo-deny-action@v1 76 | - name: Check spelling 77 | uses: crate-ci/typos@v1.24.4 78 | test: 79 | name: Test 80 | strategy: 81 | matrix: 82 | os: [ubuntu-latest, macos-latest, windows-latest] 83 | runs-on: ${{ matrix.os }} 84 | steps: 85 | - uses: actions/checkout@v4 86 | - name: Run tests 87 | run: cargo test --workspace --all-features 88 | test_package: 89 | name: Test Package 90 | runs-on: ubuntu-latest 91 | steps: 92 | - uses: actions/checkout@v4 93 | - uses: taiki-e/install-action@v2 94 | with: 95 | tool: just 96 | - run: just test-package xterm-color 97 | - run: just test-package terminal-colorsaurus 98 | - run: just test-package termtheme 99 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | # This file is autogenerated by maturin v1.7.8 2 | # To update, run 3 | # 4 | # maturin generate-ci github 5 | # 6 | name: Python 7 | 8 | on: 9 | push: 10 | branches: 11 | - main 12 | tags: 13 | - 'pycolorsaurus-*' 14 | pull_request: 15 | workflow_dispatch: 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | linux: 22 | runs-on: ${{ matrix.platform.runner }} 23 | strategy: 24 | matrix: 25 | platform: 26 | - runner: ubuntu-22.04 27 | target: x86_64 28 | - runner: ubuntu-22.04 29 | target: x86 30 | - runner: ubuntu-22.04 31 | target: aarch64 32 | - runner: ubuntu-22.04 33 | target: armv7 34 | - runner: ubuntu-22.04 35 | target: s390x 36 | - runner: ubuntu-22.04 37 | target: ppc64le 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: actions/setup-python@v5 41 | with: 42 | python-version: 3.x 43 | - name: Build wheels 44 | uses: PyO3/maturin-action@v1 45 | with: 46 | target: ${{ matrix.platform.target }} 47 | args: --release --out dist --find-interpreter --manifest-path crates/pycolorsaurus/Cargo.toml 48 | sccache: 'true' 49 | manylinux: auto 50 | - name: Upload wheels 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: wheels-linux-${{ matrix.platform.target }} 54 | path: dist 55 | 56 | musllinux: 57 | runs-on: ${{ matrix.platform.runner }} 58 | strategy: 59 | matrix: 60 | platform: 61 | - runner: ubuntu-22.04 62 | target: x86_64 63 | - runner: ubuntu-22.04 64 | target: x86 65 | - runner: ubuntu-22.04 66 | target: aarch64 67 | - runner: ubuntu-22.04 68 | target: armv7 69 | steps: 70 | - uses: actions/checkout@v4 71 | - uses: actions/setup-python@v5 72 | with: 73 | python-version: 3.x 74 | - name: Build wheels 75 | uses: PyO3/maturin-action@v1 76 | with: 77 | target: ${{ matrix.platform.target }} 78 | args: --release --out dist --find-interpreter --manifest-path crates/pycolorsaurus/Cargo.toml 79 | sccache: 'true' 80 | manylinux: musllinux_1_2 81 | - name: Upload wheels 82 | uses: actions/upload-artifact@v4 83 | with: 84 | name: wheels-musllinux-${{ matrix.platform.target }} 85 | path: dist 86 | 87 | windows: 88 | runs-on: ${{ matrix.platform.runner }} 89 | strategy: 90 | matrix: 91 | platform: 92 | - runner: windows-latest 93 | target: x64 94 | - runner: windows-latest 95 | target: x86 96 | steps: 97 | - uses: actions/checkout@v4 98 | - uses: actions/setup-python@v5 99 | with: 100 | python-version: 3.x 101 | architecture: ${{ matrix.platform.target }} 102 | - name: Build wheels 103 | uses: PyO3/maturin-action@v1 104 | with: 105 | target: ${{ matrix.platform.target }} 106 | args: --release --out dist --find-interpreter --manifest-path crates/pycolorsaurus/Cargo.toml 107 | sccache: 'true' 108 | - name: Upload wheels 109 | uses: actions/upload-artifact@v4 110 | with: 111 | name: wheels-windows-${{ matrix.platform.target }} 112 | path: dist 113 | 114 | macos: 115 | runs-on: ${{ matrix.platform.runner }} 116 | strategy: 117 | matrix: 118 | platform: 119 | - runner: macos-13 120 | target: x86_64 121 | - runner: macos-14 122 | target: aarch64 123 | steps: 124 | - uses: actions/checkout@v4 125 | - uses: actions/setup-python@v5 126 | with: 127 | python-version: 3.x 128 | - name: Build wheels 129 | uses: PyO3/maturin-action@v1 130 | with: 131 | target: ${{ matrix.platform.target }} 132 | args: --release --out dist --find-interpreter --manifest-path crates/pycolorsaurus/Cargo.toml 133 | sccache: 'true' 134 | - name: Upload wheels 135 | uses: actions/upload-artifact@v4 136 | with: 137 | name: wheels-macos-${{ matrix.platform.target }} 138 | path: dist 139 | 140 | sdist: 141 | runs-on: ubuntu-latest 142 | steps: 143 | - uses: actions/checkout@v4 144 | - name: Build sdist 145 | uses: PyO3/maturin-action@v1 146 | with: 147 | command: sdist 148 | args: --out dist --manifest-path crates/pycolorsaurus/Cargo.toml 149 | - name: Upload sdist 150 | uses: actions/upload-artifact@v4 151 | with: 152 | name: wheels-sdist 153 | path: dist 154 | 155 | release: 156 | name: Release 157 | runs-on: ubuntu-latest 158 | if: "startsWith(github.ref, 'refs/tags/pycolorsaurus-')" 159 | needs: [linux, musllinux, windows, macos, sdist] 160 | environment: PyPi 161 | concurrency: PyPi 162 | permissions: 163 | # Use to sign the release artifacts 164 | id-token: write 165 | # Used to upload release artifacts 166 | contents: write 167 | # Used to generate artifact attestation 168 | attestations: write 169 | steps: 170 | - uses: actions/download-artifact@v4 171 | - name: Generate artifact attestation 172 | uses: actions/attest-build-provenance@v1 173 | with: 174 | subject-path: 'wheels-*/*' 175 | - name: Publish to PyPI 176 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 177 | uses: PyO3/maturin-action@v1 178 | env: 179 | MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 180 | with: 181 | command: upload 182 | args: --non-interactive --skip-existing wheels-*/* 183 | -------------------------------------------------------------------------------- /.github/workflows/python.yml.patch: -------------------------------------------------------------------------------- 1 | diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml 2 | index b4f6ef6..4c7203a 100644 3 | --- a/.github/workflows/python.yml 4 | +++ b/.github/workflows/python.yml 5 | @@ -3,15 +3,14 @@ 6 | # 7 | # maturin generate-ci github 8 | # 9 | -name: CI 10 | +name: Python 11 | 12 | on: 13 | push: 14 | branches: 15 | - main 16 | - - master 17 | tags: 18 | - - '*' 19 | + - 'pycolorsaurus-*' 20 | pull_request: 21 | workflow_dispatch: 22 | 23 | @@ -45,7 +44,7 @@ jobs: 24 | uses: PyO3/maturin-action@v1 25 | with: 26 | target: ${{ matrix.platform.target }} 27 | - args: --release --out dist --find-interpreter 28 | + args: --release --out dist --find-interpreter --manifest-path crates/pycolorsaurus/Cargo.toml 29 | sccache: 'true' 30 | manylinux: auto 31 | - name: Upload wheels 32 | @@ -76,7 +75,7 @@ jobs: 33 | uses: PyO3/maturin-action@v1 34 | with: 35 | target: ${{ matrix.platform.target }} 36 | - args: --release --out dist --find-interpreter 37 | + args: --release --out dist --find-interpreter --manifest-path crates/pycolorsaurus/Cargo.toml 38 | sccache: 'true' 39 | manylinux: musllinux_1_2 40 | - name: Upload wheels 41 | @@ -104,7 +103,7 @@ jobs: 42 | uses: PyO3/maturin-action@v1 43 | with: 44 | target: ${{ matrix.platform.target }} 45 | - args: --release --out dist --find-interpreter 46 | + args: --release --out dist --find-interpreter --manifest-path crates/pycolorsaurus/Cargo.toml 47 | sccache: 'true' 48 | - name: Upload wheels 49 | uses: actions/upload-artifact@v4 50 | @@ -130,7 +129,7 @@ jobs: 51 | uses: PyO3/maturin-action@v1 52 | with: 53 | target: ${{ matrix.platform.target }} 54 | - args: --release --out dist --find-interpreter 55 | + args: --release --out dist --find-interpreter --manifest-path crates/pycolorsaurus/Cargo.toml 56 | sccache: 'true' 57 | - name: Upload wheels 58 | uses: actions/upload-artifact@v4 59 | @@ -146,7 +145,7 @@ jobs: 60 | uses: PyO3/maturin-action@v1 61 | with: 62 | command: sdist 63 | - args: --out dist 64 | + args: --out dist --manifest-path crates/pycolorsaurus/Cargo.toml 65 | - name: Upload sdist 66 | uses: actions/upload-artifact@v4 67 | with: 68 | @@ -156,8 +155,10 @@ jobs: 69 | release: 70 | name: Release 71 | runs-on: ubuntu-latest 72 | - if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} 73 | + if: "startsWith(github.ref, 'refs/tags/pycolorsaurus-')" 74 | needs: [linux, musllinux, windows, macos, sdist] 75 | + environment: PyPi 76 | + concurrency: PyPi 77 | permissions: 78 | # Use to sign the release artifacts 79 | id-token: write 80 | @@ -175,7 +176,7 @@ jobs: 81 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 82 | uses: PyO3/maturin-action@v1 83 | env: 84 | - MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 85 | + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 86 | with: 87 | command: upload 88 | args: --non-interactive --skip-existing wheels-*/* 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /benchmark/*.tsv 3 | 4 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Tau Gärtli 2 | Tau Gärtli <4602612+bash@users.noreply.github.com> 3 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "anstream" 7 | version = "0.6.18" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 10 | dependencies = [ 11 | "anstyle", 12 | "anstyle-parse", 13 | "anstyle-query", 14 | "anstyle-wincon", 15 | "colorchoice", 16 | "is_terminal_polyfill", 17 | "utf8parse", 18 | ] 19 | 20 | [[package]] 21 | name = "anstyle" 22 | version = "1.0.10" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 25 | 26 | [[package]] 27 | name = "anstyle-parse" 28 | version = "0.2.6" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 31 | dependencies = [ 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle-query" 37 | version = "1.1.2" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 40 | dependencies = [ 41 | "windows-sys 0.59.0", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle-wincon" 46 | version = "3.0.8" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa" 49 | dependencies = [ 50 | "anstyle", 51 | "once_cell_polyfill", 52 | "windows-sys 0.59.0", 53 | ] 54 | 55 | [[package]] 56 | name = "autocfg" 57 | version = "1.4.0" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 60 | 61 | [[package]] 62 | name = "benchmark" 63 | version = "1.0.0" 64 | dependencies = [ 65 | "anstyle", 66 | "clap", 67 | "indicatif", 68 | "terminal-colorsaurus", 69 | ] 70 | 71 | [[package]] 72 | name = "bumpalo" 73 | version = "3.17.0" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 76 | 77 | [[package]] 78 | name = "bytemuck" 79 | version = "1.23.0" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c" 82 | 83 | [[package]] 84 | name = "cfg-if" 85 | version = "1.0.0" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 88 | 89 | [[package]] 90 | name = "clap" 91 | version = "4.5.38" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" 94 | dependencies = [ 95 | "clap_builder", 96 | "clap_derive", 97 | ] 98 | 99 | [[package]] 100 | name = "clap_builder" 101 | version = "4.5.38" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" 104 | dependencies = [ 105 | "anstream", 106 | "anstyle", 107 | "clap_lex", 108 | "strsim", 109 | ] 110 | 111 | [[package]] 112 | name = "clap_derive" 113 | version = "4.5.32" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 116 | dependencies = [ 117 | "heck", 118 | "proc-macro2", 119 | "quote", 120 | "syn", 121 | ] 122 | 123 | [[package]] 124 | name = "clap_lex" 125 | version = "0.7.4" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 128 | 129 | [[package]] 130 | name = "colorchoice" 131 | version = "1.0.3" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 134 | 135 | [[package]] 136 | name = "colorsaurus" 137 | version = "0.1.0" 138 | dependencies = [ 139 | "pyo3", 140 | "terminal-colorsaurus", 141 | ] 142 | 143 | [[package]] 144 | name = "console" 145 | version = "0.15.11" 146 | source = "registry+https://github.com/rust-lang/crates.io-index" 147 | checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" 148 | dependencies = [ 149 | "encode_unicode", 150 | "libc", 151 | "once_cell", 152 | "unicode-width", 153 | "windows-sys 0.59.0", 154 | ] 155 | 156 | [[package]] 157 | name = "encode_unicode" 158 | version = "1.0.0" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" 161 | 162 | [[package]] 163 | name = "heck" 164 | version = "0.5.0" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 167 | 168 | [[package]] 169 | name = "indicatif" 170 | version = "0.17.11" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" 173 | dependencies = [ 174 | "console", 175 | "number_prefix", 176 | "portable-atomic", 177 | "unicode-width", 178 | "web-time", 179 | ] 180 | 181 | [[package]] 182 | name = "indoc" 183 | version = "2.0.6" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" 186 | 187 | [[package]] 188 | name = "is_terminal_polyfill" 189 | version = "1.70.1" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 192 | 193 | [[package]] 194 | name = "js-sys" 195 | version = "0.3.77" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 198 | dependencies = [ 199 | "once_cell", 200 | "wasm-bindgen", 201 | ] 202 | 203 | [[package]] 204 | name = "libc" 205 | version = "0.2.172" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 208 | 209 | [[package]] 210 | name = "log" 211 | version = "0.4.27" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 214 | 215 | [[package]] 216 | name = "memchr" 217 | version = "2.7.4" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 220 | 221 | [[package]] 222 | name = "memoffset" 223 | version = "0.9.1" 224 | source = "registry+https://github.com/rust-lang/crates.io-index" 225 | checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" 226 | dependencies = [ 227 | "autocfg", 228 | ] 229 | 230 | [[package]] 231 | name = "mio" 232 | version = "1.0.3" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" 235 | dependencies = [ 236 | "libc", 237 | "wasi", 238 | "windows-sys 0.52.0", 239 | ] 240 | 241 | [[package]] 242 | name = "number_prefix" 243 | version = "0.4.0" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" 246 | 247 | [[package]] 248 | name = "once_cell" 249 | version = "1.21.3" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 252 | 253 | [[package]] 254 | name = "once_cell_polyfill" 255 | version = "1.70.1" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 258 | 259 | [[package]] 260 | name = "portable-atomic" 261 | version = "1.11.0" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" 264 | 265 | [[package]] 266 | name = "proc-macro2" 267 | version = "1.0.95" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 270 | dependencies = [ 271 | "unicode-ident", 272 | ] 273 | 274 | [[package]] 275 | name = "pyo3" 276 | version = "0.23.5" 277 | source = "registry+https://github.com/rust-lang/crates.io-index" 278 | checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872" 279 | dependencies = [ 280 | "cfg-if", 281 | "indoc", 282 | "libc", 283 | "memoffset", 284 | "once_cell", 285 | "portable-atomic", 286 | "pyo3-build-config", 287 | "pyo3-ffi", 288 | "pyo3-macros", 289 | "unindent", 290 | ] 291 | 292 | [[package]] 293 | name = "pyo3-build-config" 294 | version = "0.23.5" 295 | source = "registry+https://github.com/rust-lang/crates.io-index" 296 | checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb" 297 | dependencies = [ 298 | "once_cell", 299 | "target-lexicon", 300 | ] 301 | 302 | [[package]] 303 | name = "pyo3-ffi" 304 | version = "0.23.5" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d" 307 | dependencies = [ 308 | "libc", 309 | "pyo3-build-config", 310 | ] 311 | 312 | [[package]] 313 | name = "pyo3-macros" 314 | version = "0.23.5" 315 | source = "registry+https://github.com/rust-lang/crates.io-index" 316 | checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da" 317 | dependencies = [ 318 | "proc-macro2", 319 | "pyo3-macros-backend", 320 | "quote", 321 | "syn", 322 | ] 323 | 324 | [[package]] 325 | name = "pyo3-macros-backend" 326 | version = "0.23.5" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028" 329 | dependencies = [ 330 | "heck", 331 | "proc-macro2", 332 | "pyo3-build-config", 333 | "quote", 334 | "syn", 335 | ] 336 | 337 | [[package]] 338 | name = "quote" 339 | version = "1.0.40" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 342 | dependencies = [ 343 | "proc-macro2", 344 | ] 345 | 346 | [[package]] 347 | name = "rgb" 348 | version = "0.8.50" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" 351 | dependencies = [ 352 | "bytemuck", 353 | ] 354 | 355 | [[package]] 356 | name = "strsim" 357 | version = "0.11.1" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 360 | 361 | [[package]] 362 | name = "syn" 363 | version = "2.0.101" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" 366 | dependencies = [ 367 | "proc-macro2", 368 | "quote", 369 | "unicode-ident", 370 | ] 371 | 372 | [[package]] 373 | name = "target-lexicon" 374 | version = "0.12.16" 375 | source = "registry+https://github.com/rust-lang/crates.io-index" 376 | checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" 377 | 378 | [[package]] 379 | name = "terminal-colorsaurus" 380 | version = "0.4.8" 381 | dependencies = [ 382 | "anstyle", 383 | "cfg-if", 384 | "libc", 385 | "memchr", 386 | "mio", 387 | "rgb", 388 | "terminal-trx", 389 | "windows-sys 0.59.0", 390 | "xterm-color", 391 | ] 392 | 393 | [[package]] 394 | name = "terminal-trx" 395 | version = "0.2.4" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "975b4233aefa1b02456d5e53b22c61653c743e308c51cf4181191d8ce41753ab" 398 | dependencies = [ 399 | "cfg-if", 400 | "libc", 401 | "windows-sys 0.59.0", 402 | ] 403 | 404 | [[package]] 405 | name = "termtheme" 406 | version = "0.1.0" 407 | dependencies = [ 408 | "anstyle", 409 | "anstyle-query", 410 | "clap", 411 | "terminal-colorsaurus", 412 | ] 413 | 414 | [[package]] 415 | name = "unicode-ident" 416 | version = "1.0.18" 417 | source = "registry+https://github.com/rust-lang/crates.io-index" 418 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 419 | 420 | [[package]] 421 | name = "unicode-width" 422 | version = "0.2.0" 423 | source = "registry+https://github.com/rust-lang/crates.io-index" 424 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 425 | 426 | [[package]] 427 | name = "unindent" 428 | version = "0.2.4" 429 | source = "registry+https://github.com/rust-lang/crates.io-index" 430 | checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" 431 | 432 | [[package]] 433 | name = "utf8parse" 434 | version = "0.2.2" 435 | source = "registry+https://github.com/rust-lang/crates.io-index" 436 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 437 | 438 | [[package]] 439 | name = "wasi" 440 | version = "0.11.0+wasi-snapshot-preview1" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 443 | 444 | [[package]] 445 | name = "wasm-bindgen" 446 | version = "0.2.100" 447 | source = "registry+https://github.com/rust-lang/crates.io-index" 448 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 449 | dependencies = [ 450 | "cfg-if", 451 | "once_cell", 452 | "wasm-bindgen-macro", 453 | ] 454 | 455 | [[package]] 456 | name = "wasm-bindgen-backend" 457 | version = "0.2.100" 458 | source = "registry+https://github.com/rust-lang/crates.io-index" 459 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 460 | dependencies = [ 461 | "bumpalo", 462 | "log", 463 | "proc-macro2", 464 | "quote", 465 | "syn", 466 | "wasm-bindgen-shared", 467 | ] 468 | 469 | [[package]] 470 | name = "wasm-bindgen-macro" 471 | version = "0.2.100" 472 | source = "registry+https://github.com/rust-lang/crates.io-index" 473 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 474 | dependencies = [ 475 | "quote", 476 | "wasm-bindgen-macro-support", 477 | ] 478 | 479 | [[package]] 480 | name = "wasm-bindgen-macro-support" 481 | version = "0.2.100" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 484 | dependencies = [ 485 | "proc-macro2", 486 | "quote", 487 | "syn", 488 | "wasm-bindgen-backend", 489 | "wasm-bindgen-shared", 490 | ] 491 | 492 | [[package]] 493 | name = "wasm-bindgen-shared" 494 | version = "0.2.100" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 497 | dependencies = [ 498 | "unicode-ident", 499 | ] 500 | 501 | [[package]] 502 | name = "web-time" 503 | version = "1.1.0" 504 | source = "registry+https://github.com/rust-lang/crates.io-index" 505 | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 506 | dependencies = [ 507 | "js-sys", 508 | "wasm-bindgen", 509 | ] 510 | 511 | [[package]] 512 | name = "windows-sys" 513 | version = "0.52.0" 514 | source = "registry+https://github.com/rust-lang/crates.io-index" 515 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 516 | dependencies = [ 517 | "windows-targets", 518 | ] 519 | 520 | [[package]] 521 | name = "windows-sys" 522 | version = "0.59.0" 523 | source = "registry+https://github.com/rust-lang/crates.io-index" 524 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 525 | dependencies = [ 526 | "windows-targets", 527 | ] 528 | 529 | [[package]] 530 | name = "windows-targets" 531 | version = "0.52.6" 532 | source = "registry+https://github.com/rust-lang/crates.io-index" 533 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 534 | dependencies = [ 535 | "windows_aarch64_gnullvm", 536 | "windows_aarch64_msvc", 537 | "windows_i686_gnu", 538 | "windows_i686_gnullvm", 539 | "windows_i686_msvc", 540 | "windows_x86_64_gnu", 541 | "windows_x86_64_gnullvm", 542 | "windows_x86_64_msvc", 543 | ] 544 | 545 | [[package]] 546 | name = "windows_aarch64_gnullvm" 547 | version = "0.52.6" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 550 | 551 | [[package]] 552 | name = "windows_aarch64_msvc" 553 | version = "0.52.6" 554 | source = "registry+https://github.com/rust-lang/crates.io-index" 555 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 556 | 557 | [[package]] 558 | name = "windows_i686_gnu" 559 | version = "0.52.6" 560 | source = "registry+https://github.com/rust-lang/crates.io-index" 561 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 562 | 563 | [[package]] 564 | name = "windows_i686_gnullvm" 565 | version = "0.52.6" 566 | source = "registry+https://github.com/rust-lang/crates.io-index" 567 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 568 | 569 | [[package]] 570 | name = "windows_i686_msvc" 571 | version = "0.52.6" 572 | source = "registry+https://github.com/rust-lang/crates.io-index" 573 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 574 | 575 | [[package]] 576 | name = "windows_x86_64_gnu" 577 | version = "0.52.6" 578 | source = "registry+https://github.com/rust-lang/crates.io-index" 579 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 580 | 581 | [[package]] 582 | name = "windows_x86_64_gnullvm" 583 | version = "0.52.6" 584 | source = "registry+https://github.com/rust-lang/crates.io-index" 585 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 586 | 587 | [[package]] 588 | name = "windows_x86_64_msvc" 589 | version = "0.52.6" 590 | source = "registry+https://github.com/rust-lang/crates.io-index" 591 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 592 | 593 | [[package]] 594 | name = "xterm-color" 595 | version = "1.0.1" 596 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/*"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | version = "0.4.8" 7 | 8 | [workspace.dependencies] 9 | terminal-colorsaurus = { path = "crates/terminal-colorsaurus", version = "0.4.7" } 10 | 11 | [workspace.lints.rust] 12 | missing_debug_implementations = "warn" 13 | missing_docs = "warn" 14 | unexpected_cfgs = { level = "warn", check-cfg = ["cfg(terminal_colorsaurus_test_unsupported)"] } 15 | 16 | [workspace.lints.clippy] 17 | dbg_macro = "warn" 18 | exhaustive_enums = "warn" 19 | exhaustive_structs = "warn" 20 | undocumented_unsafe_blocks = "deny" 21 | unimplemented = "warn" 22 | uninlined_format_args = "warn" 23 | unnested_or_patterns = "warn" 24 | unwrap_used = "deny" 25 | use_debug = "warn" 26 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | default: 2 | just --list 3 | 4 | test-package name *args: 5 | #!/usr/bin/env bash 6 | set -euxo pipefail 7 | CARGO_TARGET_DIR=$(mktemp -d); export CARGO_TARGET_DIR 8 | trap 'rm -rf "$CARGO_TARGET_DIR"' EXIT 9 | cargo package -p "{{name}}" {{args}} 10 | (cd $CARGO_TARGET_DIR/package/{{name}}-*/ && cargo test) 11 | 12 | check: clippy check-no-default-features check-unsupported 13 | 14 | clippy: 15 | cargo clippy --workspace --tests --all-features --all-targets 16 | 17 | check-no-default-features: 18 | cargo clippy -p terminal-colorsaurus --no-default-features 19 | 20 | check-unsupported: 21 | RUSTFLAGS='--cfg terminal_colorsaurus_test_unsupported -Dwarnings' cargo clippy --workspace 22 | 23 | doc name="terminal-colorsaurus": 24 | cargo +nightly docs-rs -p {{name}} 25 | 26 | update-locked-deps: 27 | CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback cargo +nightly -Zmsrv-policy generate-lockfile 28 | 29 | update-python-ci: 30 | (cd crates/pycolorsaurus && maturin generate-ci github > ../../.github/workflows/python.yml) 31 | git apply .github/workflows/python.yml.patch 32 | -------------------------------------------------------------------------------- /benchmark/measurements.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Measurements" 3 | author: "Jan Hohenheim" 4 | date: "`r Sys.Date()`" 5 | header-includes: 6 | - \usepackage{fontspec} 7 | output: 8 | pdf_document: 9 | latex_engine: xelatex 10 | --- 11 | 12 | ```{r setup, include=FALSE} 13 | knitr::opts_chunk$set(echo = TRUE) 14 | ``` 15 | 16 | ## R Markdown 17 | 18 | ```{r} 19 | library(tidyverse); 20 | library(ggthemes); 21 | library(svglite) 22 | theme_set(theme_solarized_2(light = TRUE)); 23 | ``` 24 | 25 | ```{r} 26 | dat_raw <- read_tsv("raw.tsv"); 27 | dat_raw$term <- as.factor(dat_raw$term); 28 | dat_raw$machine <- as.factor(dat_raw$machine) 29 | dat_raw$supported <- as.logical(dat_raw$supported); 30 | ``` 31 | 32 | ```{r} 33 | message("Raw data"); 34 | dat_raw |> summary(maxsum = max(lengths(lapply(dat_raw, unique)))) 35 | 36 | dat_raw |> 37 | group_by(term) |> 38 | summarise( 39 | "mean [ns]" = mean(duration_ns), 40 | "median [ns]" = median(duration_ns), 41 | "sd [ns]" = sd(duration_ns), 42 | ); 43 | ``` 44 | 45 | ```{r} 46 | message("Filtered data"); 47 | alpha <- 0.05; 48 | dat <- dat_raw |> 49 | filter(duration_ns > quantile(duration_ns, alpha / 2) & duration_ns < quantile(duration_ns, 1 - alpha / 2)) |> 50 | mutate(duration_us = duration_ns / 1000) |> 51 | select(-duration_ns); 52 | 53 | dat$machine <- dat$machine |> 54 | recode( 55 | "linux" = "Linux Desktop", 56 | "macbook" = "MacBook Pro" 57 | ); 58 | 59 | 60 | dat |> summary(maxsum = max(lengths(lapply(dat, unique)))); 61 | 62 | dat |> 63 | group_by(term) |> 64 | summarise( 65 | "mean [μs]" = mean(duration_us), 66 | "median [μs]" = median(duration_us), 67 | "sd [μs]" = sd(duration_us), 68 | ); 69 | 70 | ``` 71 | 72 | ## Violin plots 73 | 74 | 75 | ```{r} 76 | for (current_term in unique(dat$term)) { 77 | machine <- dat |> 78 | filter(term == current_term) |> 79 | pull(machine) |> 80 | unique(); 81 | plt <- dat |> 82 | filter(term == current_term) |> 83 | ggplot(aes(x = term, y = duration_us)) + 84 | geom_violin() + 85 | ggtitle(glue::glue("Violin plot for {current_term} on {machine}")) + 86 | ylab("Duration [μs]"); 87 | print(plt); 88 | } 89 | ``` 90 | 91 | 92 | ## Histograms 93 | 94 | ```{r} 95 | for (current_term in unique(dat$term)) { 96 | machine <- dat |> 97 | filter(term == current_term) |> 98 | pull(machine) |> 99 | unique(); 100 | plt <- dat |> 101 | filter(term == current_term) |> 102 | ggplot(aes(x = duration_us)) + 103 | geom_histogram(bins = 200) + 104 | ggtitle(glue::glue("Histogram for {current_term} on {machine}")) + 105 | xlab("Duration [μs]"); 106 | print(plt); 107 | } 108 | ``` 109 | 110 | ## Median plot 111 | 112 | ```{r} 113 | dat.median <- dat |> 114 | group_by(term, machine) |> 115 | summarise( 116 | median = median(duration_us), 117 | supported = ifelse(first(supported), "True", "False"), 118 | fast = median(duration_us) < 2000, 119 | .groups = "keep", 120 | ); 121 | 122 | dat.median |> 123 | filter(fast) |> 124 | ggplot(aes(x = term, y = median, fill = supported)) + 125 | geom_bar(stat = "identity", position = "dodge") + 126 | ggtitle("Median duration per terminal for fast terminals") + 127 | ylab("Median duration [μs]") + 128 | xlab("Term") + 129 | scale_fill_manual(values = c(True = "steelblue", False = "coral2")) + 130 | theme(axis.text.x = element_text(angle = 45, hjust = 1)); 131 | 132 | ggsave("measurements_fast.svg", width = 10, height = 8) 133 | 134 | dat.median |> 135 | filter(!fast) |> 136 | ggplot(aes(x = term, y = median, fill = supported)) + 137 | geom_bar(stat = "identity", position = "dodge") + 138 | ggtitle("Median duration per terminal for slow terminals") + 139 | ylab("Median duration [μs]") + 140 | xlab("Term") + 141 | scale_fill_manual(values = c(True = "steelblue", False = "coral2")) + 142 | theme(axis.text.x = element_text(angle = 45, hjust = 1)); 143 | 144 | ggsave("measurements_slow.svg", width = 10, height = 8) 145 | ``` 146 | 147 | -------------------------------------------------------------------------------- /benchmark/measurements.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/terminal-colorsaurus/a9190ed1c7801119eb1daf6b9838d492f2ca0447/benchmark/measurements.pdf -------------------------------------------------------------------------------- /benchmark/measurements_fast_dark.svg: -------------------------------------------------------------------------------- 1 | 050100150200Alacrittycool-retro-termFleetfootIntelliJ IDEAKonsolelinuxQMLKonsoleQTerminalRiorxvt-unicodestTerminal.appTerminologyxtermTermMedian duration [μs]supportedFalseTrueMedian duration per terminal for fast terminals -------------------------------------------------------------------------------- /benchmark/measurements_fast_light.svg: -------------------------------------------------------------------------------- 1 | 050100150200Alacrittycool-retro-termFleetfootIntelliJ IDEAKonsolelinuxQMLKonsoleQTerminalRiorxvt-unicodestTerminal.appTerminologyxtermTermMedian duration [μs]supportedFalseTrueMedian duration per terminal for fast terminals -------------------------------------------------------------------------------- /benchmark/measurements_slow_dark.svg: -------------------------------------------------------------------------------- 1 | 01000020000HyperiTerm2kittyVSCodevteWezTermTermMedian duration [μs]supportedTrueMedian duration per terminal for slow terminals -------------------------------------------------------------------------------- /benchmark/measurements_slow_light.svg: -------------------------------------------------------------------------------- 1 | 01000020000HyperiTerm2kittyVSCodevteWezTermTermMedian duration [μs]supportedTrueMedian duration per terminal for slow terminals -------------------------------------------------------------------------------- /benchmark/raw.tsv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bash/terminal-colorsaurus/a9190ed1c7801119eb1daf6b9838d492f2ca0447/benchmark/raw.tsv.gz -------------------------------------------------------------------------------- /benchmark/update_rustdoc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from base64 import b64encode 4 | 5 | 6 | def update_rustdoc(): 7 | with open("doc/latency-rustdoc.md", "w+") as fp: 8 | fp.write( 9 | template( 10 | fast_dark=base64_encode("benchmark/measurements_fast_dark.svg"), 11 | fast_light=base64_encode("benchmark/measurements_fast_light.svg"), 12 | slow_dark=base64_encode("benchmark/measurements_slow_dark.svg"), 13 | slow_light=base64_encode("benchmark/measurements_slow_light.svg"), 14 | ) 15 | ) 16 | 17 | 18 | def base64_encode(file_path): 19 | with open(file_path, "rb") as fp: 20 | return b64encode(fp.read()).decode("utf-8") 21 | 22 | 23 | def template(*, fast_dark, fast_light, slow_dark, slow_light): 24 | return f"""What kind of latency do I have to expect? 25 | 26 | 27 | *A picture is worth a thousand words.* 28 | 29 | ## Fast Terminals 30 | 31 | 32 | 33 | 34 | 35 | 36 | ## Slow Terminals 37 | 38 | 39 | 40 | 41 | 42 | 43 | > **ℹ️ Note:** 44 | > The macOS terminals were not tested on the same machine as the Linux terminals. 45 | """ 46 | 47 | 48 | if __name__ == "__main__": 49 | update_rustdoc() 50 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## 0.4.8 3 | * 🐛 Fixed an error on windows where the query would not 4 | succeed when the standard input was redirected. 5 | * ⚡ The color parsing code has been extracted into its own crate 6 | that terminal-colorsaurus now depends on: [`xterm-color`](https://crates.io/crates/xterm-color). 7 | * 📝 The terminal survey has been extended and updated. 8 | 9 | ## 0.4.7 10 | * 🐛 Re-add missing license texts to the published crate 11 | (this was a regression introduced in `0.4.5`). 12 | * ✨ Recognize `Eterm` as unsupported. 13 | 14 | ## 0.4.6 15 | * 🐛 Switch the string terminator back to `BEL` to work around 16 | and issue in urxvt. Previously this was done only when urxvt 17 | was detected. Unfortunately this detection was not reliable. 18 | 19 | ## 0.4.5 20 | * ✨ Added support for Windows (starting with Windows Terminal v1.22, in preview at the time of writing). 21 | ### Docs 22 | * Included more terminals in terminal survey. 23 | * The top level crate docs have been reduced to improve readability. 24 | 25 | ## 0.4.4 26 | * Bump `mio` dependency to 1.0. 27 | * ✨ Add helpful aliases to the docs. 28 | 29 | ## 0.4.3 30 | * Remove private `docs` crate feature. 31 | * 🐛 Fix broken link in docs. 32 | 33 | ## 0.4.2 34 | * ✨ Add optional dependency on `anstyle` to enable conversions from `Color` to `anstyle::RgbColor`. 35 | * ✨ Add conversion from `Color` to `rgb::RGB8`. 36 | * ✨ Treat environments with no `TERM` env var as unsupported. 37 | * Add `keywords` to package metadata. 38 | * Remove dependency on `thiserror`. 39 | 40 | ## 0.4.1 41 | * 🐛 Fixed `OSC 11` response being visible to users of GNU Screen 42 | by detecting Screen and erroring before sending any control sequences (bash/terminal-colorsaurus#16). 43 | 44 | ## 0.4.0 45 | * ⚡ Renamed «color scheme» to «color palette». 46 | * ⚡ Removed `is_dark_on_light` and `is_light_on_dark` functions. Use `color_scheme` instead. 47 | * Add new convenience function `color_scheme` which returns a nice `Dark / Light` enum. 48 | * Add support for urxvt's `rgba:` color format. 49 | * Further refined the documentation (more organized terminal list, new terminals tested). 50 | * Improved handling of ambiguous color palettes (e.g. when background color is the same as foreground). 51 | * Queries are now terminated with `ST` (the standard string terminator) instead of `BEL` (which is an xterm extension). 52 | 53 | ## 0.3.3 54 | * Feature: Add new `Color::scale_to_8bit` function. 55 | * Fix: Correctly scale colors up to 16 bits per channel. 56 | * Fix: Support full range of `#r(rrr)g(ggg)b(bbb)` color syntax. 57 | ### Docs 58 | * Update terminal survey docs. 59 | * Replace table with pretty graphs for latency docs ✨. 60 | 61 | ## 0.3.2 62 | * Add support for Terminology's color format. 63 | * Bump `mio` dependency. 64 | 65 | ### Docs 66 | * Include benchmark results in rustdocs. 67 | * Extend terminal survey to more terminals. 68 | 69 | ## 0.3.1 70 | * Remove support for Windows. [Why?](./doc/windows.md) 71 | * Remove preconditions from public API. 72 | 73 | ## 0.2.3 74 | * Updated to latest version of `terminal-trx`. 75 | * Improved docs: Terminal Survey has been simplified. 76 | 77 | ## 0.2.2 78 | * Added missing docs and clarified them in some places. 79 | 80 | ## 0.2.1 81 | * Exposed pager detection heuristic. 82 | 83 | ## 0.2.0 84 | * Improved detection of terminals that support querying for colors. 85 | * Renamed `QueryOptions.max_timeout` -> `QueryOptions.timeout`. 86 | 87 | ## 0.1.0 88 | * Initial release 89 | -------------------------------------------------------------------------------- /crates/benchmark/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "benchmark" 3 | edition = "2021" 4 | version = "1.0.0" 5 | publish = false 6 | 7 | [dependencies] 8 | clap = { version = "4.4", features = ["derive"] } 9 | anstyle = "1.0.8" 10 | indicatif = "0.17.7" 11 | terminal-colorsaurus.workspace = true 12 | -------------------------------------------------------------------------------- /crates/benchmark/src/main.rs: -------------------------------------------------------------------------------- 1 | use anstyle::Style; 2 | use clap::Parser; 3 | use indicatif::{ProgressBar, ProgressStyle}; 4 | use std::fs::OpenOptions; 5 | use std::hint::black_box; 6 | use std::io::{self, Write as _}; 7 | use std::time::{Duration, Instant}; 8 | use terminal_colorsaurus::{color_palette, Error, QueryOptions, Result}; 9 | 10 | #[derive(Parser, Debug)] 11 | struct Args { 12 | term: String, 13 | machine: String, 14 | #[arg(short = 'I', long, default_value_t = 10_000)] 15 | iterations: u32, 16 | } 17 | 18 | fn main() -> Result<()> { 19 | let args = Args::parse(); 20 | 21 | #[cfg(debug_assertions)] 22 | eprintln!( 23 | "{label_style}warning{label_style:#}{style}: you should run this example in release mode{style:#}", 24 | label_style = Style::new().bold().fg_color(Some(anstyle::AnsiColor::Yellow.into())), 25 | style = Style::new().bold() 26 | ); 27 | 28 | eprintln!( 29 | "{style}Running benchmark with {iterations} iterations{style:#}", 30 | style = Style::new().bold(), 31 | iterations = args.iterations 32 | ); 33 | 34 | let bar = ProgressBar::new(args.iterations as u64) 35 | .with_style(ProgressStyle::default_bar().progress_chars("██░")); 36 | 37 | let measurements = (0..args.iterations) 38 | .map(|_| bench()) 39 | .inspect(|_| bar.inc(1)) 40 | .collect::>>()?; 41 | bar.finish(); 42 | 43 | let supported = match color_palette(QueryOptions::default()) { 44 | Ok(_) => true, 45 | Err(Error::UnsupportedTerminal) => false, 46 | Err(e) => return Err(e), 47 | }; 48 | 49 | save_results(&measurements, args.term, supported, args.machine)?; 50 | 51 | Ok(()) 52 | } 53 | 54 | fn bench() -> Result { 55 | let start = Instant::now(); 56 | match black_box(color_palette(QueryOptions::default())) { 57 | Ok(_) | Err(Error::UnsupportedTerminal) => Ok(start.elapsed()), 58 | Err(err) => Err(err), 59 | } 60 | } 61 | 62 | fn save_results( 63 | results: &[Duration], 64 | term: String, 65 | supported: bool, 66 | machine: String, 67 | ) -> io::Result<()> { 68 | let mut file = OpenOptions::new() 69 | .append(true) 70 | .create(true) 71 | .open("benchmark/raw.tsv")?; 72 | for result in results { 73 | writeln!( 74 | file, 75 | "{}\t{}\t{}\t{}", 76 | term, 77 | result.as_nanos(), 78 | supported as u8, 79 | machine 80 | )?; 81 | } 82 | Ok(()) 83 | } 84 | -------------------------------------------------------------------------------- /crates/pycolorsaurus/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | .pytest_cache/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | .venv/ 14 | env/ 15 | bin/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | include/ 26 | man/ 27 | venv/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | pip-selfcheck.json 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # Translations 46 | *.mo 47 | 48 | # Mr Developer 49 | .mr.developer.cfg 50 | .project 51 | .pydevproject 52 | 53 | # Rope 54 | .ropeproject 55 | 56 | # Django stuff: 57 | *.log 58 | *.pot 59 | 60 | .DS_Store 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | 65 | # PyCharm 66 | .idea/ 67 | 68 | # VSCode 69 | .vscode/ 70 | 71 | # Pyenv 72 | .python-version 73 | -------------------------------------------------------------------------------- /crates/pycolorsaurus/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "colorsaurus" 3 | version = "0.1.0" 4 | edition = "2021" 5 | license = "MIT OR Apache-2.0" 6 | publish = false 7 | 8 | [lib] 9 | name = "colorsaurus" 10 | crate-type = ["cdylib"] 11 | 12 | [dependencies] 13 | pyo3 = "0.23.0" 14 | terminal-colorsaurus.workspace = true 15 | -------------------------------------------------------------------------------- /crates/pycolorsaurus/Justfile: -------------------------------------------------------------------------------- 1 | run: 2 | maturin develop --uv 3 | uv run --no-sync python -i -c 'import colorsaurus' 4 | -------------------------------------------------------------------------------- /crates/pycolorsaurus/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.7,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "colorsaurus" 7 | requires-python = ">=3.9" 8 | classifiers = [ 9 | "Programming Language :: Rust", 10 | "Programming Language :: Python :: Implementation :: CPython", 11 | "Programming Language :: Python :: Implementation :: PyPy", 12 | ] 13 | dynamic = ["version"] 14 | 15 | [tool.maturin] 16 | features = ["pyo3/extension-module"] 17 | -------------------------------------------------------------------------------- /crates/pycolorsaurus/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Useless conversion is in code generated by PYO3 2 | // FIXME(msrv): Use `#[allow(..., reason = "...")]` 3 | #![allow(clippy::useless_conversion)] 4 | 5 | use pyo3::{ 6 | create_exception, 7 | exceptions::{PyException, PyIndexError, PyValueError}, 8 | prelude::*, 9 | types::PyString, 10 | PyTypeInfo, 11 | }; 12 | use std::time::Duration; 13 | use terminal_colorsaurus as imp; 14 | 15 | /// Determines the background and foreground color of the terminal 16 | /// using the OSC 10 and OSC 11 escape sequences. 17 | /// 18 | /// This package helps answer the question "Is this terminal dark or light?". 19 | #[pymodule] 20 | fn colorsaurus(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { 21 | m.add_function(wrap_pyfunction!(color_scheme, m)?)?; 22 | m.add_function(wrap_pyfunction!(foreground_color, m)?)?; 23 | m.add_function(wrap_pyfunction!(background_color, m)?)?; 24 | m.add_function(wrap_pyfunction!(color_palette, m)?)?; 25 | m.add("ColorsaurusError", py.get_type::())?; 26 | m.add("ColorScheme", py.get_type::())?; 27 | m.add("ColorPalette", py.get_type::())?; 28 | m.add("Color", py.get_type::())?; 29 | Ok(()) 30 | } 31 | 32 | create_exception!(colorsaurus, ColorsaurusError, PyException); 33 | 34 | /// Detects if the terminal is dark or light. 35 | #[pyfunction] 36 | #[pyo3(signature = (*, timeout=None))] 37 | fn color_scheme(timeout: Option) -> PyResult { 38 | imp::color_scheme(query_options(timeout)) 39 | .map(ColorScheme::from) 40 | .map_err(to_py_error) 41 | } 42 | 43 | /// Queries the terminal for it's foreground and background color. 44 | #[pyfunction] 45 | #[pyo3(signature = (*, timeout=None))] 46 | fn color_palette(timeout: Option) -> PyResult { 47 | imp::color_palette(query_options(timeout)) 48 | .map(ColorPalette) 49 | .map_err(to_py_error) 50 | } 51 | 52 | /// Queries the terminal for it's foreground color. 53 | #[pyfunction] 54 | #[pyo3(signature = (*, timeout=None))] 55 | fn foreground_color(timeout: Option) -> PyResult { 56 | imp::foreground_color(query_options(timeout)) 57 | .map(Color) 58 | .map_err(to_py_error) 59 | } 60 | 61 | /// Queries the terminal for it's background color. 62 | #[pyfunction] 63 | #[pyo3(signature = (*, timeout=None))] 64 | fn background_color(timeout: Option) -> PyResult { 65 | imp::background_color(query_options(timeout)) 66 | .map(Color) 67 | .map_err(to_py_error) 68 | } 69 | 70 | fn query_options(timeout: Option) -> imp::QueryOptions { 71 | let mut options = imp::QueryOptions::default(); 72 | options.timeout = timeout.map(|t| t.0).unwrap_or(options.timeout); 73 | options 74 | } 75 | 76 | fn to_py_error(err: imp::Error) -> PyErr { 77 | ColorsaurusError::new_err(err.to_string()) 78 | } 79 | 80 | struct Timeout(Duration); 81 | 82 | impl<'py> FromPyObject<'py> for Timeout { 83 | fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { 84 | Duration::extract_bound(ob) 85 | .or_else(|_| u64::extract_bound(ob).map(Duration::from_secs)) 86 | .or_else(|_| { 87 | f64::extract_bound(ob).and_then(|x| { 88 | Duration::try_from_secs_f64(x).map_err(|e| PyValueError::new_err(e.to_string())) 89 | }) 90 | }) 91 | .map(Timeout) 92 | } 93 | } 94 | 95 | /// The color scheme of the terminal. 96 | /// This can be retrieved by calling the color_scheme function. 97 | #[pyclass( 98 | eq, 99 | eq_int, 100 | frozen, 101 | hash, 102 | module = "colorsaurus", 103 | rename_all = "SCREAMING_SNAKE_CASE" 104 | )] 105 | #[derive(PartialEq, Eq, Hash)] 106 | enum ColorScheme { 107 | /// The terminal uses a dark background with light text. 108 | Dark, 109 | /// The terminal uses a light background with dark text. 110 | Light, 111 | } 112 | 113 | impl From for ColorScheme { 114 | fn from(value: imp::ColorScheme) -> Self { 115 | match value { 116 | imp::ColorScheme::Dark => Self::Dark, 117 | imp::ColorScheme::Light => Self::Light, 118 | } 119 | } 120 | } 121 | 122 | /// The color palette i.e. foreground and background colors of the terminal. 123 | /// Retrieved by calling the color_palette function. 124 | #[pyclass(eq, frozen, module = "colorsaurus")] 125 | #[derive(Debug, Clone, PartialEq, Eq)] 126 | pub struct ColorPalette(imp::ColorPalette); 127 | 128 | #[pymethods] 129 | impl ColorPalette { 130 | #[getter] 131 | fn foreground(&self) -> Color { 132 | Color(self.0.foreground.clone()) 133 | } 134 | 135 | #[getter] 136 | fn background(&self) -> Color { 137 | Color(self.0.background.clone()) 138 | } 139 | 140 | #[getter] 141 | fn color_scheme(&self) -> ColorScheme { 142 | self.0.color_scheme().into() 143 | } 144 | 145 | #[pyo3(name = "__repr__")] 146 | fn repr(&self, python: Python<'_>) -> PyResult { 147 | let ty = type_name::(&python)?; 148 | Ok(format!( 149 | "<{ty} foreground={fg}, background={bg}>", 150 | fg = self.foreground().repr(python)?, 151 | bg = self.background().repr(python)? 152 | )) 153 | } 154 | } 155 | 156 | /// An RGB color with 8 bits per channel. 157 | #[derive(Debug, Clone, Eq, PartialEq)] 158 | #[pyclass(eq, frozen, module = "colorsaurus")] 159 | pub struct Color(imp::Color); 160 | 161 | #[pymethods] 162 | impl Color { 163 | #[classattr] 164 | #[pyo3(name = "BLACK")] 165 | fn black() -> Self { 166 | Self(imp::Color::default()) 167 | } 168 | 169 | #[new] 170 | fn new(red: u8, green: u8, blue: u8) -> Self { 171 | Self(imp::Color { 172 | r: scale_to_u16(red), 173 | g: scale_to_u16(green), 174 | b: scale_to_u16(blue), 175 | }) 176 | } 177 | 178 | #[getter] 179 | fn red(&self) -> u8 { 180 | self.0.scale_to_8bit().0 181 | } 182 | 183 | #[getter] 184 | fn green(&self) -> u8 { 185 | self.0.scale_to_8bit().1 186 | } 187 | 188 | #[getter] 189 | fn blue(&self) -> u8 { 190 | self.0.scale_to_8bit().2 191 | } 192 | 193 | /// The perceived lightness of the color 194 | /// as a value between 0 (black) and 100 (white) 195 | /// where 50 is the perceptual "middle grey". 196 | fn perceived_lightness(&self) -> u8 { 197 | self.0.perceived_lightness() 198 | } 199 | 200 | #[pyo3(name = "__len__")] 201 | fn get_length(&self) -> usize { 202 | 3 203 | } 204 | 205 | #[pyo3(name = "__getitem__")] 206 | fn get_item(&self, n: usize) -> PyResult { 207 | match n { 208 | 0 => Ok(self.red()), 209 | 1 => Ok(self.green()), 210 | 2 => Ok(self.blue()), 211 | _ => Err(PyIndexError::new_err(())), 212 | } 213 | } 214 | 215 | #[pyo3(name = "__repr__")] 216 | fn repr(&self, python: Python<'_>) -> PyResult { 217 | let (r, g, b) = self.0.scale_to_8bit(); 218 | let ty = type_name::(&python)?; 219 | Ok(format!("<{ty} #{r:02x}{g:02x}{b:02x}>")) 220 | } 221 | } 222 | 223 | fn scale_to_u16(channel: u8) -> u16 { 224 | (channel as u32 * (u16::MAX as u32) / (u8::MAX as u32)) as u16 225 | } 226 | 227 | fn type_name<'py, T: PyTypeInfo>(python: &Python<'py>) -> PyResult> { 228 | python.get_type::().name() 229 | } 230 | -------------------------------------------------------------------------------- /crates/pycolorsaurus/uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.8" 3 | 4 | [[package]] 5 | name = "colorsaurus" 6 | version = "0.1.0" 7 | source = { editable = "." } 8 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "terminal-colorsaurus" 3 | description = "A cross-platform library for determining the terminal's background and foreground color. It answers the question «Is this terminal dark or light?»." 4 | readme = "readme.md" 5 | repository = "https://github.com/bash/terminal-colorsaurus" 6 | categories = ["command-line-interface"] 7 | keywords = ["terminal", "light", "dark", "color-scheme", "cli"] 8 | license = "MIT OR Apache-2.0" 9 | version.workspace = true 10 | edition = "2021" 11 | rust-version = "1.70.0" # Search for `FIXME(msrv)` when bumping. 12 | exclude = [".github", ".gitignore", "*.sh", "benchmark/**/*", "doc/issues.md", "deny.toml"] 13 | 14 | [dependencies] 15 | rgb = { version = "0.8.37", optional = true } 16 | anstyle = { version = "1.0.7", optional = true } 17 | cfg-if = "1.0.0" 18 | xterm-color = { path = "../xterm-color", version = "1.0" } 19 | 20 | [target.'cfg(any(unix, windows))'.dependencies] 21 | memchr = "2.7.1" 22 | terminal-trx = "0.2.4" 23 | 24 | [target.'cfg(unix)'.dependencies] 25 | mio = { version = "1", features = ["os-ext"], default-features = false } 26 | 27 | [target.'cfg(target_os = "macos")'.dependencies] 28 | libc = "0.2.151" 29 | 30 | [target.'cfg(windows)'.dependencies] 31 | windows-sys = { version = "0.59.0", features = ["Win32_System_Threading"] } # Keep this in sync with terminal-trx's version to avoid duplicate deps. 32 | 33 | [lints] 34 | workspace = true 35 | 36 | [package.metadata.docs.rs] 37 | all-features = true 38 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/doc/caveats.md: -------------------------------------------------------------------------------- 1 | ## Caveats 2 | 3 | Extra care needs to be taken on Unix if your program might share 4 | the terminal with another program. This might be the case 5 | if you expect your output to be used with a pager e.g. `your_program` | `less`. 6 | In that case, a race condition exists because the pager will also set the terminal to raw mode. 7 | The `pager` example shows a heuristic to deal with this issue. 8 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/doc/comparison.md: -------------------------------------------------------------------------------- 1 | Comparison with other crates in the ecosystem. 2 | 3 | ### [termbg] 4 | * Is hardcoded to use stdin/stderr for communicating with the terminal. \ 5 | This means that it does not work if some or all of these streams are redirected. 6 | * Pulls in an async runtime for the timeout. 7 | * Does not calculate the perceived lightness, but another metric. 8 | 9 | ### [terminal-light] 10 | * Is hardcoded to use stdout for communicating with the terminal. 11 | * Does not report the colors, only the color's luma. 12 | * Does not calculate the perceived lightness, but another metric. 13 | 14 | [termbg]: https://docs.rs/termbg 15 | [terminal-light]: https://docs.rs/terminal-light 16 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/doc/feature-detection.md: -------------------------------------------------------------------------------- 1 | ../../../doc/feature-detection.md -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/doc/latency-rustdoc.md: -------------------------------------------------------------------------------- 1 | ../../../doc/latency-rustdoc.md -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/doc/terminal-survey.md: -------------------------------------------------------------------------------- 1 | ../../../doc/terminal-survey.md -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/examples-utils/display.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | pub(crate) struct DisplayAsDebug(pub(crate) T); 4 | 5 | impl From for DisplayAsDebug { 6 | fn from(value: T) -> Self { 7 | DisplayAsDebug(value) 8 | } 9 | } 10 | 11 | impl fmt::Debug for DisplayAsDebug { 12 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 13 | self.0.fmt(f) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/examples/bg.rs: -------------------------------------------------------------------------------- 1 | //! This example shows how to retrieve the terminal's background color. 2 | 3 | use terminal_colorsaurus::{background_color, Error, QueryOptions}; 4 | 5 | fn main() -> Result<(), display::DisplayAsDebug> { 6 | let bg = background_color(QueryOptions::default())?; 7 | let bg_8bit = bg.scale_to_8bit(); 8 | println!("rgb16({}, {}, {})", bg.r, bg.g, bg.b); 9 | println!("rgb8({}, {}, {})", bg_8bit.0, bg_8bit.1, bg_8bit.2); 10 | Ok(()) 11 | } 12 | 13 | #[path = "../examples-utils/display.rs"] 14 | mod display; 15 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/examples/fg.rs: -------------------------------------------------------------------------------- 1 | //! This example shows how to retrieve the terminal's foreground color. 2 | 3 | use terminal_colorsaurus::{foreground_color, Error, QueryOptions}; 4 | 5 | fn main() -> Result<(), display::DisplayAsDebug> { 6 | let fg = foreground_color(QueryOptions::default())?; 7 | let fg_8bit = fg.scale_to_8bit(); 8 | println!("rgb16({}, {}, {})", fg.r, fg.g, fg.b); 9 | println!("rgb8({}, {}, {})", fg_8bit.0, fg_8bit.1, fg_8bit.2); 10 | Ok(()) 11 | } 12 | 13 | #[path = "../examples-utils/display.rs"] 14 | mod display; 15 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/examples/pager.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::use_debug)] 2 | 3 | //! This example shows how to heuristically avoid having a race condition with a pager (e.g. `less`). 4 | //! The race condition occurs because the pager and colorsaurus simultaneously 5 | //! enable/disable raw mode and read/write to the same terminal. 6 | //! 7 | //! The heuristic checks if stdout is connected to a pipe which is a strong 8 | //! indicator that the output is redirected to another process, for instance a pager. 9 | //! Note that this heuristic has both 10 | //! false negatives (output not piped to a pager) and 11 | //! false positives (stderr piped to a pager). 12 | //! 13 | //! You might want to have an explicit option in your CLI app that 14 | //! allows users to override that heuristic (similar to --color=always/never/auto). 15 | //! 16 | //! Test this example as follows: 17 | //! 1. `cargo run --example pager`—should print the color scheme. 18 | //! 2. `cargo run --example pager | less`—should not print the color scheme. 19 | //! 3. `cargo run --example pager | cat`—should not print the color scheme. This is a false negatives. 20 | //! 4. `cargo run --example pager 2>&1 >/dev/tty | less`—should print the color scheme (or error). This is a false positive. 21 | 22 | use std::io::{stdout, IsTerminal as _}; 23 | use terminal_colorsaurus::{color_palette, Error, QueryOptions}; 24 | 25 | fn main() -> Result<(), display::DisplayAsDebug> { 26 | if stdout().is_terminal() { 27 | eprintln!( 28 | "Here's the color scheme: {:#?}", 29 | color_palette(QueryOptions::default())? 30 | ); 31 | } else { 32 | eprintln!("No color scheme for you today :/"); 33 | } 34 | 35 | Ok(()) 36 | } 37 | 38 | #[path = "../examples-utils/display.rs"] 39 | mod display; 40 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/examples/theme.rs: -------------------------------------------------------------------------------- 1 | //! This example shows how to detect if the terminal uses 2 | //! a dark-on-light or a light-on-dark theme. 3 | 4 | use terminal_colorsaurus::{color_palette, ColorScheme, Error, QueryOptions}; 5 | 6 | fn main() -> Result<(), display::DisplayAsDebug> { 7 | let colors = color_palette(QueryOptions::default())?; 8 | 9 | let theme = match colors.color_scheme() { 10 | ColorScheme::Dark => "dark", 11 | ColorScheme::Light => "light", 12 | }; 13 | 14 | println!( 15 | "{theme}, fg: {}, bg: {}", 16 | colors.foreground.perceived_lightness(), 17 | colors.background.perceived_lightness() 18 | ); 19 | 20 | Ok(()) 21 | } 22 | 23 | #[path = "../examples-utils/display.rs"] 24 | mod display; 25 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/license-apache.txt: -------------------------------------------------------------------------------- 1 | ../../license-apache.txt -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/license-mit.txt: -------------------------------------------------------------------------------- 1 | ../../license-mit.txt -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/readme.md: -------------------------------------------------------------------------------- 1 | ../../readme.md -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/color.rs: -------------------------------------------------------------------------------- 1 | /// An RGB color with 16 bits per channel. 2 | /// You can use [`Color::scale_to_8bit`] to convert to an 8bit RGB color. 3 | #[derive(Debug, Clone, Default, Eq, PartialEq)] 4 | #[allow(clippy::exhaustive_structs)] 5 | pub struct Color { 6 | /// Red 7 | pub r: u16, 8 | /// Green 9 | pub g: u16, 10 | /// Blue 11 | pub b: u16, 12 | } 13 | 14 | impl Color { 15 | /// The perceived lightness of the color 16 | /// as a value between `0` (black) and `100` (white) 17 | /// where `50` is the perceptual "middle grey". 18 | /// ``` 19 | /// # use terminal_colorsaurus::Color; 20 | /// # let color = Color::default(); 21 | /// let is_dark = color.perceived_lightness() <= 50; 22 | /// ``` 23 | pub fn perceived_lightness(&self) -> u8 { 24 | (self.perceived_lightness_f32() * 100.) as u8 25 | } 26 | 27 | /// Converts the color to 8 bit precision per channel by scaling each channel. 28 | /// 29 | /// ``` 30 | /// # use terminal_colorsaurus::Color; 31 | /// let white = Color { r: u16::MAX, g: u16::MAX, b: u16::MAX }; 32 | /// assert_eq!((u8::MAX, u8::MAX, u8::MAX), white.scale_to_8bit()); 33 | /// 34 | /// let black = Color { r: 0, g: 0, b: 0 }; 35 | /// assert_eq!((0, 0, 0), black.scale_to_8bit()); 36 | /// ``` 37 | pub fn scale_to_8bit(&self) -> (u8, u8, u8) { 38 | ( 39 | scale_to_u8(self.r), 40 | scale_to_u8(self.g), 41 | scale_to_u8(self.b), 42 | ) 43 | } 44 | 45 | pub(crate) fn perceived_lightness_f32(&self) -> f32 { 46 | let color = xterm_color::Color::rgb(self.r, self.g, self.b); 47 | color.perceived_lightness() 48 | } 49 | } 50 | 51 | fn scale_to_u8(channel: u16) -> u8 { 52 | (channel as u32 * (u8::MAX as u32) / (u16::MAX as u32)) as u8 53 | } 54 | 55 | #[cfg(feature = "rgb")] 56 | impl From for rgb::RGB16 { 57 | fn from(value: Color) -> Self { 58 | rgb::RGB16 { 59 | r: value.r, 60 | g: value.g, 61 | b: value.b, 62 | } 63 | } 64 | } 65 | 66 | #[cfg(feature = "rgb")] 67 | impl From for rgb::RGB8 { 68 | fn from(value: Color) -> Self { 69 | let (r, g, b) = value.scale_to_8bit(); 70 | rgb::RGB8 { r, g, b } 71 | } 72 | } 73 | 74 | #[cfg(feature = "rgb")] 75 | impl From for Color { 76 | fn from(value: rgb::RGB16) -> Self { 77 | Color { 78 | r: value.r, 79 | g: value.g, 80 | b: value.b, 81 | } 82 | } 83 | } 84 | 85 | #[cfg(feature = "anstyle")] 86 | impl From for anstyle::RgbColor { 87 | fn from(value: Color) -> Self { 88 | let (r, g, b) = value.scale_to_8bit(); 89 | anstyle::RgbColor(r, g, b) 90 | } 91 | } 92 | 93 | #[cfg(test)] 94 | mod tests { 95 | use super::*; 96 | 97 | #[test] 98 | fn black_has_perceived_lightness_zero() { 99 | let black = Color::default(); 100 | assert_eq!(0, black.perceived_lightness()) 101 | } 102 | 103 | #[test] 104 | fn white_has_perceived_lightness_100() { 105 | let white = Color { 106 | r: u16::MAX, 107 | g: u16::MAX, 108 | b: u16::MAX, 109 | }; 110 | assert_eq!(100, white.perceived_lightness()) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/color_scheme_tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use ColorScheme::*; 3 | 4 | const BLACK: Color = Color { r: 0, g: 0, b: 0 }; 5 | const WHITE: Color = Color { 6 | r: u16::MAX, 7 | g: u16::MAX, 8 | b: u16::MAX, 9 | }; 10 | const DARK_GRAY: Color = Color { 11 | r: 0x44ff, 12 | g: 0x44ff, 13 | b: 0x44ff, 14 | }; 15 | const DARKER_GRAY: Color = Color { 16 | r: 0x22ff, 17 | g: 0x22ff, 18 | b: 0x22ff, 19 | }; 20 | const LIGHT_GRAY: Color = Color { 21 | r: 0xccff, 22 | g: 0xccff, 23 | b: 0xccff, 24 | }; 25 | const LIGHTER_GRAY: Color = Color { 26 | r: 0xeeff, 27 | g: 0xeeff, 28 | b: 0xeeff, 29 | }; 30 | 31 | mod dark { 32 | use super::*; 33 | 34 | #[test] 35 | fn black_white() { 36 | let palette = ColorPalette { 37 | foreground: WHITE, 38 | background: BLACK, 39 | }; 40 | assert_eq!(Dark, palette.color_scheme()); 41 | } 42 | 43 | #[test] 44 | fn same_color_for_fg_and_bg() { 45 | for color in [BLACK, DARKER_GRAY, DARKER_GRAY] { 46 | let palette = ColorPalette { 47 | foreground: color.clone(), 48 | background: color, 49 | }; 50 | assert_eq!(Dark, palette.color_scheme()); 51 | } 52 | } 53 | 54 | #[test] 55 | fn fg_and_bg_both_dark() { 56 | for (foreground, background) in [(DARK_GRAY, DARKER_GRAY), (DARKER_GRAY, BLACK)] { 57 | assert!(foreground.perceived_lightness_f32() < 0.5); 58 | assert!(background.perceived_lightness_f32() < 0.5); 59 | assert!(foreground.perceived_lightness_f32() != background.perceived_lightness_f32()); 60 | 61 | let palette = ColorPalette { 62 | foreground, 63 | background, 64 | }; 65 | assert_eq!(Dark, palette.color_scheme()); 66 | } 67 | } 68 | } 69 | 70 | mod light { 71 | use super::*; 72 | 73 | #[test] 74 | fn black_white() { 75 | let palette = ColorPalette { 76 | foreground: BLACK, 77 | background: WHITE, 78 | }; 79 | assert_eq!(Light, palette.color_scheme()); 80 | } 81 | 82 | #[test] 83 | fn same_color_for_fg_and_bg() { 84 | for color in [WHITE, LIGHT_GRAY, LIGHTER_GRAY] { 85 | let palette = ColorPalette { 86 | foreground: color.clone(), 87 | background: color, 88 | }; 89 | assert_eq!(Light, palette.color_scheme()); 90 | } 91 | } 92 | 93 | #[test] 94 | fn fg_and_bg_both_light() { 95 | for (foreground, background) in [(LIGHT_GRAY, LIGHTER_GRAY), (LIGHTER_GRAY, WHITE)] { 96 | assert!(foreground.perceived_lightness_f32() > 0.5); 97 | assert!(background.perceived_lightness_f32() > 0.5); 98 | assert!( 99 | (foreground.perceived_lightness_f32() - background.perceived_lightness_f32()).abs() 100 | >= f32::EPSILON 101 | ); 102 | 103 | let palette = ColorPalette { 104 | foreground, 105 | background, 106 | }; 107 | assert_eq!(Light, palette.color_scheme()); 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::fmt::CaretNotation; 2 | use core::fmt; 3 | use std::time::Duration; 4 | use std::{error, io}; 5 | 6 | /// An error returned by this library. 7 | #[derive(Debug)] 8 | #[non_exhaustive] 9 | pub enum Error { 10 | /// I/O error 11 | Io(io::Error), 12 | /// The terminal responded using an unsupported response format. 13 | Parse(Vec), 14 | /// The query timed out. This can happen because \ 15 | /// either the terminal does not support querying for colors \ 16 | /// or the terminal has a lot of latency (e.g. when connected via SSH). 17 | Timeout(Duration), 18 | /// The terminal does not support querying for the foreground or background color. 19 | UnsupportedTerminal, 20 | } 21 | 22 | impl error::Error for Error { 23 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 24 | match self { 25 | Error::Io(source) => Some(source), 26 | _ => None, 27 | } 28 | } 29 | } 30 | 31 | impl fmt::Display for Error { 32 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 33 | match self { 34 | Error::Io(e) => write!(f, "I/O error: {e}"), 35 | Error::Parse(data) => write!( 36 | f, 37 | "failed to parse response: {0}", 38 | // FIXME(msrv): Use `.utf8_chunks()` to avoid allocating. 39 | CaretNotation(String::from_utf8_lossy(data).as_ref()), 40 | ), 41 | #[allow(clippy::use_debug)] 42 | Error::Timeout(timeout) => { 43 | write!(f, "operation did not complete within {timeout:?}") 44 | } 45 | Error::UnsupportedTerminal => { 46 | write!(f, "the terminal does not support querying for its colors") 47 | } 48 | } 49 | } 50 | } 51 | 52 | impl From for Error { 53 | fn from(source: io::Error) -> Self { 54 | Error::Io(source) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/fmt.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | // Caret notation encodes control characters as ^char. 4 | // See: https://en.wikipedia.org/wiki/Caret_notation 5 | pub(crate) struct CaretNotation<'a>(pub(crate) &'a str); 6 | 7 | impl fmt::Display for CaretNotation<'_> { 8 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 9 | for c in self.0.chars() { 10 | if c.is_control() { 11 | write!(f, "{}", EscapeCaret(c))?; 12 | } else { 13 | write!(f, "{c}")?; 14 | } 15 | } 16 | Ok(()) 17 | } 18 | } 19 | 20 | struct EscapeCaret(char); 21 | 22 | impl fmt::Display for EscapeCaret { 23 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 24 | if let Some(escaped) = char::from_u32(u32::from(self.0) ^ 0x40) { 25 | write!(f, "^{escaped}") 26 | } else { 27 | write!(f, "{}", self.0) 28 | } 29 | } 30 | } 31 | 32 | #[cfg(test)] 33 | mod tests { 34 | use super::*; 35 | 36 | #[test] 37 | fn escapes_control_chars() { 38 | assert_eq!("^@", format!("{}", CaretNotation("\x00"))); 39 | assert_eq!( 40 | "^[]11;rgba:0000/0000/4443/cccc^G", 41 | format!("{}", CaretNotation("\x1b]11;rgba:0000/0000/4443/cccc\x07")) 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/io/mod.rs: -------------------------------------------------------------------------------- 1 | mod time_out; 2 | use time_out::*; 3 | mod poll; 4 | pub(crate) use poll::*; 5 | mod read_until; 6 | pub(crate) use read_until::*; 7 | mod term_reader; 8 | pub(crate) use term_reader::*; 9 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/io/poll/macos.rs: -------------------------------------------------------------------------------- 1 | use super::super::read_timed_out; 2 | use libc::{c_int, pselect, time_t, timespec, FD_ISSET, FD_SET}; 3 | use std::io; 4 | use std::mem::zeroed; 5 | use std::ptr::{null, null_mut}; 6 | use std::time::Duration; 7 | use terminal_trx::Transceive; 8 | 9 | // macOS does not support polling /dev/tty using kqueue, so we have to 10 | // resort to pselect/select. See https://nathancraddock.com/blog/macos-dev-tty-polling/. 11 | pub(crate) fn poll_read(terminal: &dyn Transceive, timeout: Duration) -> io::Result<()> { 12 | if timeout.is_zero() { 13 | return Err(read_timed_out()); 14 | } 15 | 16 | let fd = terminal.as_raw_fd(); 17 | let timespec = to_timespec(timeout); 18 | // SAFETY: A zeroed fd_set is valid (FD_ZERO zeroes an existing fd_set so this state must be fine). 19 | // Our file descriptor is valid since we get it from safe code. 20 | unsafe { 21 | let mut readfds = zeroed(); 22 | FD_SET(fd, &mut readfds); 23 | // The nfds argument is not "number of file descriptors" but biggest file descriptor + 1. 24 | to_io_result(pselect( 25 | fd + 1, 26 | &mut readfds, 27 | null_mut(), 28 | null_mut(), 29 | ×pec, 30 | null(), 31 | ))?; 32 | if FD_ISSET(fd, &readfds) { 33 | Ok(()) 34 | } else { 35 | Err(read_timed_out()) 36 | } 37 | } 38 | } 39 | 40 | fn to_timespec(duration: Duration) -> timespec { 41 | timespec { 42 | tv_sec: duration.as_secs() as time_t, 43 | #[cfg(all(target_arch = "x86_64", target_pointer_width = "32"))] 44 | tv_nsec: duration.subsec_nanos() as i64, 45 | #[cfg(not(all(target_arch = "x86_64", target_pointer_width = "32")))] 46 | tv_nsec: duration.subsec_nanos() as libc::c_long, 47 | } 48 | } 49 | 50 | fn to_io_result(value: c_int) -> io::Result { 51 | if value == -1 { 52 | Err(io::Error::last_os_error()) 53 | } else { 54 | Ok(value) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/io/poll/mod.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | 3 | cfg_if! { 4 | if #[cfg(target_os = "macos")] { 5 | mod macos; 6 | pub(crate) use macos::*; 7 | } else if #[cfg(unix)] { 8 | mod unix; 9 | pub(crate) use unix::*; 10 | } else if #[cfg(windows)] { 11 | mod windows; 12 | pub(crate) use windows::*; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/io/poll/unix.rs: -------------------------------------------------------------------------------- 1 | use super::super::read_timed_out; 2 | use mio::unix::SourceFd; 3 | use mio::{Events, Interest, Poll, Token}; 4 | use std::io; 5 | use std::time::Duration; 6 | use terminal_trx::Transceive; 7 | 8 | pub(crate) fn poll_read(terminal: &dyn Transceive, timeout: Duration) -> io::Result<()> { 9 | if timeout.is_zero() { 10 | return Err(read_timed_out()); 11 | } 12 | 13 | let mut poll = Poll::new()?; 14 | let mut events = Events::with_capacity(1024); 15 | let token = Token(0); 16 | poll.registry().register( 17 | &mut SourceFd(&terminal.as_raw_fd()), 18 | token, 19 | Interest::READABLE, 20 | )?; 21 | poll.poll(&mut events, Some(timeout))?; 22 | for event in &events { 23 | if event.token() == token { 24 | return Ok(()); 25 | } 26 | } 27 | Err(read_timed_out()) 28 | } 29 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/io/poll/windows.rs: -------------------------------------------------------------------------------- 1 | use super::super::read_timed_out; 2 | use std::io; 3 | use std::os::windows::io::AsRawHandle as _; 4 | use std::time::Duration; 5 | use terminal_trx::Transceive; 6 | use windows_sys::Win32::Foundation::{WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT}; 7 | use windows_sys::Win32::System::Threading::WaitForSingleObject; 8 | 9 | pub(crate) fn poll_read(terminal: &dyn Transceive, timeout: Duration) -> io::Result<()> { 10 | let handle = terminal.input_buffer_handle(); 11 | match unsafe { WaitForSingleObject(handle.as_raw_handle(), timeout.as_millis() as u32) } { 12 | // The state of the specified object is signaled. 13 | WAIT_OBJECT_0 => Ok(()), 14 | WAIT_ABANDONED | WAIT_TIMEOUT => Err(read_timed_out()), 15 | _ => Err(io::Error::last_os_error()), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/io/read_until.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, BufRead}; 2 | 3 | // Copied from the standard library with modification 4 | // to support searching for two bytes. 5 | // https://github.com/rust-lang/rust/blob/e35a56d96f7d9d4422f2b7b00bf0bf282b2ec782/library/std/src/io/mod.rs#L2067 6 | pub(crate) fn read_until2( 7 | r: &mut R, 8 | delim1: u8, 9 | delim2: u8, 10 | buf: &mut Vec, 11 | ) -> io::Result { 12 | let mut read = 0; 13 | loop { 14 | let (done, used) = { 15 | let available = match r.fill_buf() { 16 | Ok(n) => n, 17 | Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue, 18 | Err(e) => return Err(e), 19 | }; 20 | if let Some(i) = memchr::memchr2(delim1, delim2, available) { 21 | buf.extend_from_slice(&available[..=i]); 22 | (true, i + 1) 23 | } else { 24 | buf.extend_from_slice(available); 25 | (false, available.len()) 26 | } 27 | }; 28 | r.consume(used); 29 | read += used; 30 | if done || used == 0 { 31 | return Ok(read); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/io/term_reader.rs: -------------------------------------------------------------------------------- 1 | use super::poll_read; 2 | use std::io; 3 | use std::time::{Duration, Instant}; 4 | use terminal_trx::Transceive; 5 | 6 | #[derive(Debug)] 7 | pub(crate) struct TermReader { 8 | inner: R, 9 | timeout: Duration, 10 | first_read: Option, 11 | } 12 | 13 | impl io::Read for TermReader 14 | where 15 | R: Transceive, 16 | { 17 | fn read(&mut self, buf: &mut [u8]) -> io::Result { 18 | let timeout = self.remaining_timeout(); 19 | poll_read(&self.inner, timeout)?; 20 | self.inner.read(buf) 21 | } 22 | } 23 | 24 | impl TermReader { 25 | pub(crate) fn new(inner: R, timeout: Duration) -> Self { 26 | Self { 27 | inner, 28 | timeout, 29 | first_read: None, 30 | } 31 | } 32 | 33 | fn remaining_timeout(&mut self) -> Duration { 34 | let first_read = self.first_read.get_or_insert_with(Instant::now); 35 | self.timeout.saturating_sub(first_read.elapsed()) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/io/time_out.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::{fmt, io}; 3 | 4 | pub(crate) fn read_timed_out() -> io::Error { 5 | io::Error::new(io::ErrorKind::TimedOut, PollReadTimedOutError) 6 | } 7 | 8 | #[derive(Debug)] 9 | struct PollReadTimedOutError; 10 | 11 | impl fmt::Display for PollReadTimedOutError { 12 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 13 | write!(f, "poll_read timed out") 14 | } 15 | } 16 | 17 | impl Error for PollReadTimedOutError {} 18 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] 2 | 3 | //! Determines the background and foreground color of the terminal 4 | //! using the `OSC 10` and `OSC 11` terminal sequence. 5 | //! 6 | //! This crate helps answer the question *"Is this terminal dark or light?"*. 7 | //! 8 | //! ## Features 9 | //! * Background and foreground color detection. 10 | //! * Uses a fast and reliable heuristic to detect if the terminal supports color querying. 11 | //! * *Correct* perceived lightness calculation. 12 | //! * Works on Windows (starting with Windows Terminal v1.22). 13 | //! * Safely restores the terminal from raw mode even if the library errors or panicks. 14 | //! * Does not send any escape sequences if `TERM=dumb`. 15 | //! * Works even if all of stderr, stdout and stdin are redirected. 16 | //! * Supports a timeout (for situations with high latency such as an SSH connection). 17 | //! 18 | //! ## Terminal Support 19 | //! `terminal-colorsaurus` works with most modern terminals and has been [tested extensively](`terminal_survey`). 20 | //! It's also really good at [detecting](`feature_detection`) when querying for the terminal's colors is not supported. 21 | //! 22 | //! ## Example 1: Test If the Terminal Uses a Dark Background 23 | //! ```no_run 24 | //! use terminal_colorsaurus::{color_scheme, QueryOptions, ColorScheme}; 25 | //! 26 | //! let color_scheme = color_scheme(QueryOptions::default()).unwrap(); 27 | //! dbg!(color_scheme == ColorScheme::Dark); 28 | //! ``` 29 | //! 30 | //! ## Example 2: Get the Terminal's Foreground Color 31 | //! ```no_run 32 | //! use terminal_colorsaurus::{foreground_color, QueryOptions}; 33 | //! 34 | //! let fg = foreground_color(QueryOptions::default()).unwrap(); 35 | //! println!("rgb({}, {}, {})", fg.r, fg.g, fg.b); 36 | //! ``` 37 | //! 38 | //! ## Optional Dependencies 39 | //! * [`rgb`] — Enable this feature to convert between [`Color`] and [`rgb::RGB16`] / [`rgb::RGB8`]. 40 | //! * [`anstyle`] — Enable this feature to convert [`Color`] to [`anstyle::RgbColor`]. 41 | 42 | use cfg_if::cfg_if; 43 | 44 | mod color; 45 | mod error; 46 | mod fmt; 47 | 48 | cfg_if! { 49 | if #[cfg(all(any(unix, windows), not(terminal_colorsaurus_test_unsupported)))] { 50 | mod io; 51 | mod quirks; 52 | mod xterm; 53 | use xterm as imp; 54 | } else { 55 | mod unsupported; 56 | use unsupported as imp; 57 | } 58 | } 59 | 60 | cfg_if! { 61 | if #[cfg(docsrs)] { 62 | #[doc(cfg(docsrs))] 63 | #[doc = include_str!("../doc/terminal-survey.md")] 64 | pub mod terminal_survey {} 65 | 66 | #[doc(cfg(docsrs))] 67 | #[doc = include_str!("../doc/latency-rustdoc.md")] 68 | pub mod latency {} 69 | 70 | #[doc(cfg(docsrs))] 71 | #[doc = include_str!("../doc/feature-detection.md")] 72 | pub mod feature_detection {} 73 | 74 | #[doc(cfg(docsrs))] 75 | #[doc = include_str!("../doc/comparison.md")] 76 | pub mod comparison {} 77 | } 78 | } 79 | 80 | #[cfg(doctest)] 81 | #[doc = include_str!("../readme.md")] 82 | pub mod readme_doctests {} 83 | 84 | pub use color::*; 85 | 86 | /// The color palette i.e. foreground and background colors of the terminal. 87 | /// Retrieved by calling [`color_palette`]. 88 | #[derive(Debug, Clone, PartialEq, Eq)] 89 | #[non_exhaustive] 90 | pub struct ColorPalette { 91 | /// The foreground color of the terminal. 92 | pub foreground: Color, 93 | /// The background color of the terminal. 94 | pub background: Color, 95 | } 96 | 97 | /// The color scheme of the terminal. 98 | /// 99 | /// The easiest way to retrieve the color scheme 100 | /// is by calling [`color_scheme`]. 101 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 102 | #[allow(clippy::exhaustive_enums)] 103 | #[doc(alias = "Theme")] 104 | pub enum ColorScheme { 105 | /// The terminal uses a dark background with light text. 106 | #[default] 107 | Dark, 108 | /// The terminal uses a light background with dark text. 109 | Light, 110 | } 111 | 112 | impl ColorPalette { 113 | /// Determines if the terminal uses a dark or light background. 114 | pub fn color_scheme(&self) -> ColorScheme { 115 | let fg = self.foreground.perceived_lightness_f32(); 116 | let bg = self.background.perceived_lightness_f32(); 117 | if bg < fg { 118 | ColorScheme::Dark 119 | } else if bg > fg || bg > 0.5 { 120 | ColorScheme::Light 121 | } else { 122 | ColorScheme::Dark 123 | } 124 | } 125 | } 126 | 127 | /// Result used by this library. 128 | pub type Result = std::result::Result; 129 | pub use error::Error; 130 | 131 | /// Options to be used with [`foreground_color`] and [`background_color`]. 132 | /// You should almost always use the unchanged [`QueryOptions::default`] value. 133 | #[derive(Debug, Clone, PartialEq, Eq)] 134 | #[non_exhaustive] 135 | pub struct QueryOptions { 136 | /// The maximum time spent waiting for a response from the terminal. Defaults to 1 s. 137 | /// 138 | /// Consider leaving this on a high value as there might be a lot of latency \ 139 | /// between you and the terminal (e.g. when you're connected via SSH). 140 | /// 141 | /// Terminals that don't support querying for colors will 142 | /// almost always be detected as such before this timeout elapses. 143 | /// 144 | /// See [Feature Detection](`feature_detection`) for details on how this works. 145 | pub timeout: std::time::Duration, 146 | } 147 | 148 | impl Default for QueryOptions { 149 | fn default() -> Self { 150 | Self { 151 | timeout: std::time::Duration::from_secs(1), 152 | } 153 | } 154 | } 155 | 156 | /// Detects if the terminal is dark or light. 157 | #[doc = include_str!("../doc/caveats.md")] 158 | #[doc(alias = "theme")] 159 | pub fn color_scheme(options: QueryOptions) -> Result { 160 | color_palette(options).map(|p| p.color_scheme()) 161 | } 162 | 163 | /// Queries the terminal for it's color scheme (foreground and background color). 164 | #[doc = include_str!("../doc/caveats.md")] 165 | pub fn color_palette(options: QueryOptions) -> Result { 166 | imp::color_palette(options) 167 | } 168 | 169 | /// Queries the terminal for it's foreground color. \ 170 | /// If you also need the foreground color it is more efficient to use [`color_palette`] instead. 171 | #[doc = include_str!("../doc/caveats.md")] 172 | #[doc(alias = "fg")] 173 | pub fn foreground_color(options: QueryOptions) -> Result { 174 | imp::foreground_color(options) 175 | } 176 | 177 | /// Queries the terminal for it's background color. \ 178 | /// If you also need the foreground color it is more efficient to use [`color_palette`] instead. 179 | #[doc = include_str!("../doc/caveats.md")] 180 | #[doc(alias = "bg")] 181 | pub fn background_color(options: QueryOptions) -> Result { 182 | imp::background_color(options) 183 | } 184 | 185 | #[cfg(test)] 186 | #[path = "color_scheme_tests.rs"] 187 | mod tests; 188 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/quirks.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::io::{self, Write}; 3 | use std::sync::OnceLock; 4 | 5 | pub(crate) fn terminal_quirks_from_env() -> TerminalQuirks { 6 | // This OnceLock is not here for efficiency, it's here so that 7 | // we have consistent results in case a consumer uses `set_var`. 8 | static TERMINAL_QUIRK: OnceLock = OnceLock::new(); 9 | *TERMINAL_QUIRK.get_or_init(terminal_quirk_from_env_eager) 10 | } 11 | 12 | fn terminal_quirk_from_env_eager() -> TerminalQuirks { 13 | use TerminalQuirks::*; 14 | match env::var("TERM") { 15 | // Something is very wrong if we don't have a TERM env var 16 | // or if it's not valid unicode. 17 | Err(env::VarError::NotUnicode(_)) => Unsupported, 18 | // Something is very wrong if we don't have a TERM env var. 19 | #[cfg(unix)] 20 | Err(env::VarError::NotPresent) => Unsupported, 21 | // On Windows the TERM convention is not universally followed. 22 | #[cfg(not(unix))] 23 | Err(env::VarError::NotPresent) => None, 24 | // `TERM=dumb` indicates that the terminal supports very little features. 25 | // We don't want to send any escape sequences to those terminals. 26 | Ok(term) if term == "dumb" => Unsupported, 27 | // Why is GNU Screen unsupported? 28 | // 29 | // Note: The following only applies if screen was compiled with `--enable-rxvt_osc`. 30 | // Homebrew is a notable packager who doesn't enable this feature. 31 | // 32 | // 1. Screen only supports `OSC 11` (background) and not `OSC 10` (foreground) 33 | // 34 | // 2. Screen replies to queries in the incorrect order. 35 | // We send `OSC 11` + `DA1` and expect the answers to also be in that order. 36 | // However, as far as I can tell, Screen relays the `OSC 11` query to the underlying terminal, 37 | // and so we get the `DA1` response back *first*. This is usually an indicator that 38 | // the terminal doesn't support the `OSC` query. 39 | // 40 | // There are two both equally broken workarounds: 41 | // 42 | // * Don't send `DA1`, just `OSC 11`. \ 43 | // Since Screen forwards the query to the underlying terminal, we won't get an answer 44 | // if the underlying terminal doesn't support it. And we don't have a way to detect that 45 | // => we hit the 1s timeout :/ 46 | // 47 | // * Send the query (`OSC 11` + `DA1`) to the underlying terminal by wrapping it between `CSI P` and `ST`. 48 | // (There's a reverted commit that does exactly this: f06206b53d2499e95627ef29e5e35278209725db) 49 | // * If there's exactly one attached display (underlying terminal) 50 | // => everything works as usual. 51 | // * If there's no attached display we don't get an answer to DA1 52 | // => we hit the 1s timeout :/ 53 | // * If there are multiple displays attached (yes this is supported and quite fun to try) we get back multiple responses 54 | // => since there's no way to know that we need to expect multiple responses 55 | // some of them are not consumed by us and end up on the user's screen :/ 56 | Ok(term) if term == "screen" || term.starts_with("screen.") => Unsupported, 57 | // Eterm doesn't even support `DA1`, so we list it here to avoid running into the timeout. 58 | Ok(term) if term == "Eterm" => Unsupported, 59 | Ok(_) => None, 60 | } 61 | } 62 | 63 | #[derive(Debug, Copy, Clone)] 64 | pub(crate) enum TerminalQuirks { 65 | None, 66 | Unsupported, 67 | } 68 | 69 | impl TerminalQuirks { 70 | pub(crate) fn is_known_unsupported(self) -> bool { 71 | matches!(self, TerminalQuirks::Unsupported) 72 | } 73 | 74 | pub(crate) fn string_terminator(self) -> &'static [u8] { 75 | // The currently released version of rxvt-unicode (urxvt) has a bug where it terminates the response with `ESC` instead of `ST` (`ESC \`). 76 | // This causes us to run into the timeout because we get stuck waiting for a `\` that never arrives. 77 | // Fixed by revision [1.600](http://cvs.schmorp.de/rxvt-unicode/src/command.C?revision=1.600&view=markup). 78 | // The bug can be worked around by sending a query with `BEL` which will result in a `BEL`-terminated response. 79 | // 80 | // Originally, we used `BEL` only for urxvt. However, after a discussion in delta [1], 81 | // I noticed that there are quite a few people who use urxvt with a different `TERM` 82 | // env var (e.g. `urxvt`, `xterm`, or even `screen`) [2]. 83 | // 84 | // [1]: https://github.com/dandavison/delta/issues/1897 85 | // [2]: https://github.com/search?q=URxvt*termName&type=code 86 | const BEL: u8 = 0x07; 87 | &[BEL] 88 | } 89 | 90 | pub(crate) fn write_all(self, w: &mut dyn Write, bytes: &[u8]) -> io::Result<()> { 91 | w.write_all(bytes) 92 | } 93 | 94 | pub(crate) fn write_string_terminator(self, writer: &mut dyn Write) -> io::Result<()> { 95 | self.write_all(writer, self.string_terminator()) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/unsupported.rs: -------------------------------------------------------------------------------- 1 | use crate::{Color, ColorPalette, Error, QueryOptions, Result}; 2 | 3 | pub(crate) fn color_palette(_options: QueryOptions) -> Result { 4 | Err(Error::UnsupportedTerminal) 5 | } 6 | 7 | pub(crate) fn foreground_color(_options: QueryOptions) -> Result { 8 | Err(Error::UnsupportedTerminal) 9 | } 10 | 11 | pub(crate) fn background_color(_options: QueryOptions) -> Result { 12 | Err(Error::UnsupportedTerminal) 13 | } 14 | -------------------------------------------------------------------------------- /crates/terminal-colorsaurus/src/xterm.rs: -------------------------------------------------------------------------------- 1 | use crate::io::{read_until2, TermReader}; 2 | use crate::quirks::{terminal_quirks_from_env, TerminalQuirks}; 3 | use crate::{Color, ColorPalette, Error, QueryOptions, Result}; 4 | use std::io::{self, BufRead, BufReader, Write as _}; 5 | use std::time::Duration; 6 | use terminal_trx::{terminal, RawModeGuard}; 7 | 8 | const QUERY_FG: &[u8] = b"\x1b]10;?"; 9 | const FG_RESPONSE_PREFIX: &[u8] = b"\x1b]10;"; 10 | const QUERY_BG: &[u8] = b"\x1b]11;?"; 11 | const BG_RESPONSE_PREFIX: &[u8] = b"\x1b]11;"; 12 | 13 | pub(crate) fn foreground_color(options: QueryOptions) -> Result { 14 | let quirks = terminal_quirks_from_env(); 15 | let response = query( 16 | &options, 17 | quirks, 18 | |w| write_query(w, quirks, QUERY_FG), 19 | read_color_response, 20 | ) 21 | .map_err(map_timed_out_err(options.timeout))?; 22 | parse_response(response, FG_RESPONSE_PREFIX) 23 | } 24 | 25 | pub(crate) fn background_color(options: QueryOptions) -> Result { 26 | let quirks = terminal_quirks_from_env(); 27 | let response = query( 28 | &options, 29 | quirks, 30 | |w| write_query(w, quirks, QUERY_BG), 31 | read_color_response, 32 | ) 33 | .map_err(map_timed_out_err(options.timeout))?; 34 | parse_response(response, BG_RESPONSE_PREFIX) 35 | } 36 | 37 | pub(crate) fn color_palette(options: QueryOptions) -> Result { 38 | let quirks = terminal_quirks_from_env(); 39 | let (fg_response, bg_response) = query( 40 | &options, 41 | quirks, 42 | |w| write_query(w, quirks, QUERY_FG).and_then(|_| write_query(w, quirks, QUERY_BG)), 43 | |r| Ok((read_color_response(r)?, read_color_response(r)?)), 44 | ) 45 | .map_err(map_timed_out_err(options.timeout))?; 46 | let foreground = parse_response(fg_response, FG_RESPONSE_PREFIX)?; 47 | let background = parse_response(bg_response, BG_RESPONSE_PREFIX)?; 48 | Ok(ColorPalette { 49 | foreground, 50 | background, 51 | }) 52 | } 53 | 54 | fn write_query(w: &mut dyn io::Write, quirks: TerminalQuirks, query: &[u8]) -> io::Result<()> { 55 | quirks.write_all(w, query)?; 56 | quirks.write_string_terminator(w)?; 57 | Ok(()) 58 | } 59 | 60 | fn map_timed_out_err(timeout: Duration) -> impl Fn(Error) -> Error { 61 | move |e| match e { 62 | Error::Io(io) if io.kind() == io::ErrorKind::TimedOut => Error::Timeout(timeout), 63 | e => e, 64 | } 65 | } 66 | 67 | const ST: &[u8] = b"\x1b\\"; 68 | const DA1: &[u8] = b"\x1b[c"; 69 | const ESC: u8 = 0x1b; 70 | const BEL: u8 = 0x07; 71 | 72 | fn parse_response(response: Vec, prefix: &[u8]) -> Result { 73 | response 74 | .strip_prefix(prefix) 75 | .and_then(|r| r.strip_suffix(ST).or(r.strip_suffix(&[BEL]))) 76 | .and_then(xparsecolor) 77 | .ok_or(Error::Parse(response)) 78 | } 79 | 80 | fn xparsecolor(input: &[u8]) -> Option { 81 | let xterm_color::Color { 82 | red: r, 83 | green: g, 84 | blue: b, 85 | .. 86 | } = xterm_color::Color::parse(input).ok()?; 87 | Some(Color { r, g, b }) 88 | } 89 | 90 | type Reader<'a> = BufReader>>; 91 | 92 | // We detect terminals that don't support the color query in quite a smart way: 93 | // First, we send the color query and then a query that we know is well-supported (DA1). 94 | // Since queries are answered sequentially, if a terminal answers to DA1 first, we know that 95 | // it does not support querying for colors. 96 | // 97 | // Source: https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/8#note_151381 98 | fn query( 99 | options: &QueryOptions, 100 | quirks: TerminalQuirks, 101 | write_query: impl FnOnce(&mut dyn io::Write) -> io::Result<()>, 102 | read_response: impl FnOnce(&mut Reader<'_>) -> Result, 103 | ) -> Result { 104 | if quirks.is_known_unsupported() { 105 | return Err(Error::UnsupportedTerminal); 106 | } 107 | 108 | let mut tty = terminal()?; 109 | let mut tty = tty.lock(); 110 | let mut tty = tty.enable_raw_mode()?; 111 | 112 | write_query(&mut tty)?; 113 | quirks.write_all(&mut tty, DA1)?; 114 | tty.flush()?; 115 | 116 | let mut reader = BufReader::with_capacity(32, TermReader::new(tty, options.timeout)); 117 | 118 | let response = read_response(&mut reader)?; 119 | 120 | // We still need to consume the response to DA1 121 | // Let's ignore errors, they are not that important. 122 | _ = consume_da1_response(&mut reader, true); 123 | 124 | Ok(response) 125 | } 126 | 127 | fn read_color_response(r: &mut Reader<'_>) -> Result> { 128 | let mut buf = Vec::new(); 129 | r.read_until(ESC, &mut buf)?; // Both responses start with ESC 130 | 131 | // If we get the response for DA1 back first, then we know that 132 | // the terminal does not recocgnize the color query. 133 | if !r.buffer().starts_with(b"]") { 134 | _ = consume_da1_response(r, false); 135 | return Err(Error::UnsupportedTerminal); 136 | } 137 | 138 | // Some terminals always respond with BEL (see terminal survey). 139 | read_until2(r, BEL, ESC, &mut buf)?; 140 | if buf.last() == Some(&ESC) { 141 | r.read_until(b'\\', &mut buf)?; 142 | } 143 | 144 | Ok(buf) 145 | } 146 | 147 | fn consume_da1_response(r: &mut impl BufRead, consume_esc: bool) -> io::Result<()> { 148 | let mut buf = Vec::new(); 149 | if consume_esc { 150 | r.read_until(ESC, &mut buf)?; 151 | } 152 | r.read_until(b'[', &mut buf)?; 153 | r.read_until(b'c', &mut buf)?; 154 | Ok(()) 155 | } 156 | -------------------------------------------------------------------------------- /crates/termtheme/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "termtheme" 3 | version = "0.1.0" 4 | edition = "2021" 5 | license = "MIT OR Apache-2.0" 6 | rust-version = "1.70.0" # Search for `FIXME(msrv)` when bumping. 7 | publish = false # Not ready yet 8 | 9 | [dependencies] 10 | anstyle = "1.0.8" 11 | anstyle-query = "1.1.1" 12 | clap = { version = "4.4", features = ["derive"] } 13 | terminal-colorsaurus.workspace = true 14 | -------------------------------------------------------------------------------- /crates/termtheme/license-apache.txt: -------------------------------------------------------------------------------- 1 | ../../license-apache.txt -------------------------------------------------------------------------------- /crates/termtheme/license-mit.txt: -------------------------------------------------------------------------------- 1 | ../../license-mit.txt -------------------------------------------------------------------------------- /crates/termtheme/src/main.rs: -------------------------------------------------------------------------------- 1 | use anstyle::{AnsiColor, Style}; 2 | use clap::Parser; 3 | use std::{ 4 | fmt::{self, Display}, 5 | io::{self, stdout, IsTerminal}, 6 | process::exit, 7 | }; 8 | use terminal_colorsaurus::{color_scheme, ColorScheme, QueryOptions}; 9 | 10 | fn main() { 11 | let args = Args::parse(); 12 | if !stdout().is_terminal() && !args.force { 13 | display_error("stdout is not connected to a terminal"); 14 | display_help( 15 | "use '--force' if you're sure that no other process is trying to write to the terminal", 16 | ); 17 | exit(1); 18 | } 19 | match color_scheme(QueryOptions::default()) { 20 | Ok(s) => display_theme(s, !args.no_newline), 21 | Err(e) => { 22 | display_error(e); 23 | exit(1); 24 | } 25 | } 26 | } 27 | 28 | fn display_theme(color_scheme: ColorScheme, newline: bool) { 29 | if newline { 30 | println!("{}", DisplayName(color_scheme)) 31 | } else { 32 | print!("{}", DisplayName(color_scheme)) 33 | } 34 | } 35 | 36 | fn display_error(e: impl Display) { 37 | if use_colors(&io::stderr()) { 38 | let style = Style::new().bold().fg_color(Some(AnsiColor::Red.into())); 39 | eprintln!("{style}error:{style:#} {e}"); 40 | } else { 41 | eprintln!("error: {e}"); 42 | } 43 | } 44 | 45 | fn display_help(e: impl Display) { 46 | if use_colors(&io::stderr()) { 47 | let style = Style::new() 48 | .bold() 49 | .fg_color(Some(AnsiColor::BrightBlue.into())); 50 | eprintln!("{style}tip:{style:#} {e}"); 51 | } else { 52 | eprintln!("tip: {e}"); 53 | } 54 | } 55 | 56 | struct DisplayName(ColorScheme); 57 | 58 | impl fmt::Display for DisplayName { 59 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 60 | match self.0 { 61 | ColorScheme::Dark => f.write_str("dark"), 62 | ColorScheme::Light => f.write_str("light"), 63 | } 64 | } 65 | } 66 | 67 | #[derive(Parser, Debug)] 68 | #[command(version, about, long_about = None)] 69 | struct Args { 70 | /// Do not output a newline. 71 | #[arg(short = 'n')] 72 | no_newline: bool, 73 | /// Always query the terminal even when stdout is redirected. 74 | #[arg(short = 'f', long)] 75 | force: bool, 76 | } 77 | 78 | trait Stream: io::Write + io::IsTerminal {} 79 | 80 | impl Stream for T where T: io::Write + io::IsTerminal {} 81 | 82 | // Copied from 83 | // which is licensed under Apache 2.0 or MIT. 84 | fn use_colors(raw: &dyn Stream) -> bool { 85 | let clicolor = anstyle_query::clicolor(); 86 | let clicolor_enabled = clicolor.unwrap_or(false); 87 | let clicolor_disabled = !clicolor.unwrap_or(true); 88 | if anstyle_query::no_color() { 89 | false 90 | } else if anstyle_query::clicolor_force() { 91 | true 92 | } else if clicolor_disabled { 93 | false 94 | } else { 95 | raw.is_terminal() 96 | && (anstyle_query::term_supports_color() || clicolor_enabled || anstyle_query::is_ci()) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /crates/xterm-color/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "xterm-color" 3 | description = "Parses the subset of X11 Color Strings emitted by terminals in response to OSC color queries" 4 | readme = "readme.md" 5 | repository = "https://github.com/bash/terminal-colorsaurus" 6 | categories = ["command-line-interface"] 7 | keywords = ["x11", "xterm", "color"] 8 | license = "MIT OR Apache-2.0" 9 | version = "1.0.1" 10 | edition = "2021" 11 | rust-version = "1.70.0" 12 | exclude = ["changelog.md"] 13 | 14 | [lints] 15 | workspace = true 16 | -------------------------------------------------------------------------------- /crates/xterm-color/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## 1.0.1 3 | * Fix typo in docs and mention `XParseColor`. 4 | 5 | ## 1.0.0 6 | Initial release 7 | -------------------------------------------------------------------------------- /crates/xterm-color/license-apache.txt: -------------------------------------------------------------------------------- 1 | ../../license-apache.txt -------------------------------------------------------------------------------- /crates/xterm-color/license-mit.txt: -------------------------------------------------------------------------------- 1 | ../../license-mit.txt -------------------------------------------------------------------------------- /crates/xterm-color/readme.md: -------------------------------------------------------------------------------- 1 | # xterm-color 2 | 3 | [![Docs](https://img.shields.io/docsrs/xterm-color/latest)](https://docs.rs/xterm-color) 4 | [![Crate Version](https://img.shields.io/crates/v/xterm-color)](https://crates.io/crates/xterm-color) 5 | 6 | Parses the subset of X11 [Color Strings][x11] emitted by terminals in response to [`OSC` color queries][osc] (`OSC 10`, `OSC 11`, ...). 7 | 8 | [osc]: https://www.invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands 9 | [x11]: https://www.x.org/releases/current/doc/libX11/libX11/libX11.html#Color_Strings 10 | 11 | ## Example 12 | ```rust 13 | use xterm_color::Color; 14 | let color = Color::parse(b"rgb:11/aa/ff").unwrap(); 15 | assert_eq!(color, Color::rgb(0x1111, 0xaaaa, 0xffff)); 16 | ``` 17 | 18 | ## [Docs](https://docs.rs/xterm-color) 19 | 20 | ## License 21 | Licensed under either of 22 | 23 | * Apache License, Version 2.0 24 | ([license-apache.txt](license-apache.txt) or ) 25 | * MIT license 26 | ([license-mit.txt](license-mit.txt) or ) 27 | 28 | at your option. 29 | 30 | ## Contribution 31 | Unless you explicitly state otherwise, any contribution intentionally submitted 32 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall be 33 | dual licensed as above, without any additional terms or conditions. 34 | -------------------------------------------------------------------------------- /crates/xterm-color/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Parses the subset of X11 [Color Strings][x11] emitted by terminals in response to [`OSC` color queries][osc] (`OSC 10`, `OSC 11`, ...). 2 | //! 3 | //! [osc]: https://www.invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands 4 | //! [x11]: https://www.x.org/releases/current/doc/libX11/libX11/libX11.html#Color_Strings 5 | //! 6 | //! ``` 7 | //! use xterm_color::Color; 8 | //! 9 | //! assert_eq!( 10 | //! Color::parse(b"rgb:11/aa/ff").unwrap(), 11 | //! Color::rgb(0x1111, 0xaaaa, 0xffff) 12 | //! ); 13 | //! ``` 14 | 15 | use core::fmt; 16 | use std::error; 17 | use std::marker::PhantomData; 18 | use std::str::from_utf8; 19 | 20 | /// An RGB color with 16 bits per channel and an optional alpha channel. 21 | #[derive(Debug, Clone, Eq, PartialEq)] 22 | #[allow(clippy::exhaustive_structs)] 23 | pub struct Color { 24 | /// Red 25 | pub red: u16, 26 | /// Green 27 | pub green: u16, 28 | /// Blue 29 | pub blue: u16, 30 | /// Alpha. 31 | /// 32 | /// Can almost always be ignored as it is rarely set to 33 | /// something other than the default (`0xffff`). 34 | pub alpha: u16, 35 | } 36 | 37 | impl Color { 38 | /// Construct a new [`Color`] from (r, g, b) components, with the default alpha (`0xffff`). 39 | pub const fn rgb(red: u16, green: u16, blue: u16) -> Self { 40 | Self { 41 | red, 42 | green, 43 | blue, 44 | alpha: u16::MAX, 45 | } 46 | } 47 | 48 | /// Parses the subset of X11 [Color Strings](https://www.x.org/releases/current/doc/libX11/libX11/libX11.html#Color_Strings) 49 | /// emitted by terminals in response to `OSC` color queries (`OSC 10`, `OSC 11`, ...). 50 | /// 51 | /// This function is a rough analogue to `XParseColor`. 52 | /// 53 | /// ## Accepted Formats 54 | /// * `#` 55 | /// * `rgb://` 56 | /// * `rgba:///` (rxvt-unicode extension) 57 | /// 58 | /// where ``, `` and `` are hexadecimal numbers with 1-4 digits. 59 | #[doc(alias = "XParseColor")] 60 | pub fn parse(input: &[u8]) -> Result { 61 | xparsecolor(input).ok_or(ColorParseError(PhantomData)) 62 | } 63 | } 64 | 65 | /// Error which can be returned when parsing a color. 66 | #[derive(Debug, Clone)] 67 | pub struct ColorParseError(PhantomData<()>); 68 | 69 | impl error::Error for ColorParseError {} 70 | 71 | impl fmt::Display for ColorParseError { 72 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 73 | f.write_str("invalid color spec") 74 | } 75 | } 76 | 77 | fn xparsecolor(input: &[u8]) -> Option { 78 | if let Some(stripped) = input.strip_prefix(b"#") { 79 | parse_sharp(from_utf8(stripped).ok()?) 80 | } else if let Some(stripped) = input.strip_prefix(b"rgb:") { 81 | parse_rgb(from_utf8(stripped).ok()?) 82 | } else if let Some(stripped) = input.strip_prefix(b"rgba:") { 83 | parse_rgba(from_utf8(stripped).ok()?) 84 | } else { 85 | None 86 | } 87 | } 88 | 89 | /// From the `xparsecolor` man page: 90 | /// > For backward compatibility, an older syntax for RGB Device is supported, 91 | /// > but its continued use is not encouraged. The syntax is an initial sharp sign character 92 | /// > followed by a numeric specification, in one of the following formats: 93 | /// > 94 | /// > The R, G, and B represent single hexadecimal digits. 95 | /// > When fewer than 16 bits each are specified, they represent the most significant bits of the value 96 | /// > (unlike the `rgb:` syntax, in which values are scaled). 97 | /// > For example, the string `#3a7` is the same as `#3000a0007000`. 98 | fn parse_sharp(input: &str) -> Option { 99 | const NUM_COMPONENTS: usize = 3; 100 | let len = input.len(); 101 | if len % NUM_COMPONENTS == 0 && len <= NUM_COMPONENTS * 4 { 102 | let chunk_size = input.len() / NUM_COMPONENTS; 103 | let red = parse_channel_shifted(&input[0..chunk_size])?; 104 | let green = parse_channel_shifted(&input[chunk_size..chunk_size * 2])?; 105 | let blue = parse_channel_shifted(&input[chunk_size * 2..])?; 106 | Some(Color::rgb(red, green, blue)) 107 | } else { 108 | None 109 | } 110 | } 111 | 112 | fn parse_channel_shifted(input: &str) -> Option { 113 | let value = u16::from_str_radix(input, 16).ok()?; 114 | Some(value << ((4 - input.len()) * 4)) 115 | } 116 | 117 | /// From the `xparsecolor` man page: 118 | /// > An RGB Device specification is identified by the prefix `rgb:` and conforms to the following syntax: 119 | /// > ```text 120 | /// > rgb:// 121 | /// > 122 | /// > , , := h | hh | hhh | hhhh 123 | /// > h := single hexadecimal digits (case insignificant) 124 | /// > ``` 125 | /// > Note that *h* indicates the value scaled in 4 bits, 126 | /// > *hh* the value scaled in 8 bits, *hhh* the value scaled in 12 bits, 127 | /// > and *hhhh* the value scaled in 16 bits, respectively. 128 | fn parse_rgb(input: &str) -> Option { 129 | let mut parts = input.split('/'); 130 | let red = parse_channel_scaled(parts.next()?)?; 131 | let green = parse_channel_scaled(parts.next()?)?; 132 | let blue = parse_channel_scaled(parts.next()?)?; 133 | if parts.next().is_none() { 134 | Some(Color::rgb(red, green, blue)) 135 | } else { 136 | None 137 | } 138 | } 139 | 140 | /// Some terminals such as urxvt (rxvt-unicode) optionally support 141 | /// an alpha channel and sometimes return colors in the format `rgba:///`. 142 | /// 143 | /// Dropping the alpha channel is a best-effort thing as 144 | /// the effective color (when combined with a background color) 145 | /// could have a completely different perceived lightness value. 146 | /// 147 | /// Test with `urxvt -depth 32 -fg grey90 -bg rgba:0000/0000/4444/cccc` 148 | fn parse_rgba(input: &str) -> Option { 149 | let mut parts = input.split('/'); 150 | let red = parse_channel_scaled(parts.next()?)?; 151 | let green = parse_channel_scaled(parts.next()?)?; 152 | let blue = parse_channel_scaled(parts.next()?)?; 153 | let alpha = parse_channel_scaled(parts.next()?)?; 154 | if parts.next().is_none() { 155 | Some(Color { 156 | red, 157 | green, 158 | blue, 159 | alpha, 160 | }) 161 | } else { 162 | None 163 | } 164 | } 165 | 166 | fn parse_channel_scaled(input: &str) -> Option { 167 | let len = input.len(); 168 | if (1..=4).contains(&len) { 169 | let max = u32::pow(16, len as u32) - 1; 170 | let value = u32::from_str_radix(input, 16).ok()?; 171 | Some((u16::MAX as u32 * value / max) as u16) 172 | } else { 173 | None 174 | } 175 | } 176 | 177 | // Implementation of determining the perceived lightness 178 | // follows this excellent answer: https://stackoverflow.com/a/56678483 179 | impl Color { 180 | /// Perceptual lightness (L*) as a value between 0.0 (black) and 1.0 (white) 181 | /// where 0.5 is the perceptual middle gray. 182 | /// 183 | /// Note that the color's alpha is ignored. 184 | pub fn perceived_lightness(&self) -> f32 { 185 | luminance_to_perceived_lightness(self.luminance()) / 100. 186 | } 187 | 188 | /// Luminance (`Y`) calculated using the [CIE XYZ formula](https://en.wikipedia.org/wiki/Relative_luminance). 189 | fn luminance(&self) -> f32 { 190 | let r = gamma_function(f32::from(self.red) / f32::from(u16::MAX)); 191 | let g = gamma_function(f32::from(self.green) / f32::from(u16::MAX)); 192 | let b = gamma_function(f32::from(self.blue) / f32::from(u16::MAX)); 193 | 0.2126 * r + 0.7152 * g + 0.0722 * b 194 | } 195 | } 196 | 197 | /// Converts a non-linear sRGB value to a linear one via [gamma correction](https://en.wikipedia.org/wiki/Gamma_correction). 198 | // Taken from bevy_color: https://github.com/bevyengine/bevy/blob/0403948aa23a748abd2a2aac05eef1209d66674e/crates/bevy_color/src/srgba.rs#L211 199 | fn gamma_function(value: f32) -> f32 { 200 | if value <= 0.0 { 201 | return value; 202 | } 203 | if value <= 0.04045 { 204 | value / 12.92 // linear falloff in dark values 205 | } else { 206 | ((value + 0.055) / 1.055).powf(2.4) // gamma curve in other area 207 | } 208 | } 209 | 210 | /// Perceptual lightness (L*) calculated using the [CIEXYZ to CIELAB formula](https://en.wikipedia.org/wiki/CIELAB_color_space). 211 | fn luminance_to_perceived_lightness(luminance: f32) -> f32 { 212 | if luminance <= 216. / 24389. { 213 | luminance * (24389. / 27.) 214 | } else { 215 | luminance.cbrt() * 116. - 16. 216 | } 217 | } 218 | 219 | #[cfg(doctest)] 220 | #[doc = include_str!("../readme.md")] 221 | pub mod readme_doctests {} 222 | 223 | #[cfg(test)] 224 | #[allow(clippy::unwrap_used)] 225 | mod tests { 226 | use super::*; 227 | 228 | // Tests adapted from alacritty/vte: 229 | // https://github.com/alacritty/vte/blob/ed51aa19b7ad060f62a75ec55ebb802ced850b1a/src/ansi.rs#L2134 230 | #[test] 231 | fn parses_valid_rgb_color() { 232 | assert_eq!( 233 | Color::parse(b"rgb:f/e/d").unwrap(), 234 | Color { 235 | red: 0xffff, 236 | green: 0xeeee, 237 | blue: 0xdddd, 238 | alpha: u16::MAX, 239 | } 240 | ); 241 | assert_eq!( 242 | Color::parse(b"rgb:11/aa/ff").unwrap(), 243 | Color { 244 | red: 0x1111, 245 | green: 0xaaaa, 246 | blue: 0xffff, 247 | alpha: u16::MAX, 248 | } 249 | ); 250 | assert_eq!( 251 | Color::parse(b"rgb:f/ed1/cb23").unwrap(), 252 | Color { 253 | red: 0xffff, 254 | green: 0xed1d, 255 | blue: 0xcb23, 256 | alpha: u16::MAX, 257 | } 258 | ); 259 | assert_eq!( 260 | Color::parse(b"rgb:ffff/0/0").unwrap(), 261 | Color { 262 | red: 0xffff, 263 | green: 0x0, 264 | blue: 0x0, 265 | alpha: u16::MAX, 266 | } 267 | ); 268 | } 269 | 270 | #[test] 271 | fn parses_valid_rgba_color() { 272 | assert_eq!( 273 | Color::parse(b"rgba:0000/0000/4443/cccc").unwrap(), 274 | Color { 275 | red: 0x0000, 276 | green: 0x0000, 277 | blue: 0x4443, 278 | alpha: 0xcccc, 279 | } 280 | ); 281 | } 282 | 283 | #[test] 284 | fn fails_for_invalid_rgb_color() { 285 | assert!(Color::parse(b"rgb:").is_err()); // Empty 286 | assert!(Color::parse(b"rgb:f/f").is_err()); // Not enough channels 287 | assert!(Color::parse(b"rgb:f/f/f/f").is_err()); // Too many channels 288 | assert!(Color::parse(b"rgb:f//f").is_err()); // Empty channel 289 | assert!(Color::parse(b"rgb:ffff/ffff/fffff").is_err()); // Too many digits for one channel 290 | } 291 | 292 | // Tests adapted from alacritty/vte: 293 | // https://github.com/alacritty/vte/blob/ed51aa19b7ad060f62a75ec55ebb802ced850b1a/src/ansi.rs#L2142 294 | #[test] 295 | fn parses_valid_sharp_color() { 296 | assert_eq!( 297 | Color::parse(b"#1af").unwrap(), 298 | Color { 299 | red: 0x1000, 300 | green: 0xa000, 301 | blue: 0xf000, 302 | alpha: u16::MAX, 303 | } 304 | ); 305 | assert_eq!( 306 | Color::parse(b"#1AF").unwrap(), 307 | Color { 308 | red: 0x1000, 309 | green: 0xa000, 310 | blue: 0xf000, 311 | alpha: u16::MAX, 312 | } 313 | ); 314 | assert_eq!( 315 | Color::parse(b"#11aaff").unwrap(), 316 | Color { 317 | red: 0x1100, 318 | green: 0xaa00, 319 | blue: 0xff00, 320 | alpha: u16::MAX, 321 | } 322 | ); 323 | assert_eq!( 324 | Color::parse(b"#110aa0ff0").unwrap(), 325 | Color { 326 | red: 0x1100, 327 | green: 0xaa00, 328 | blue: 0xff00, 329 | alpha: u16::MAX, 330 | } 331 | ); 332 | assert_eq!( 333 | Color::parse(b"#1100aa00ff00").unwrap(), 334 | Color { 335 | red: 0x1100, 336 | green: 0xaa00, 337 | blue: 0xff00, 338 | alpha: u16::MAX, 339 | } 340 | ); 341 | assert_eq!( 342 | Color::parse(b"#123456789ABC").unwrap(), 343 | Color { 344 | red: 0x1234, 345 | green: 0x5678, 346 | blue: 0x9ABC, 347 | alpha: u16::MAX, 348 | } 349 | ); 350 | } 351 | 352 | #[test] 353 | fn fails_for_invalid_sharp_color() { 354 | assert!(Color::parse(b"#").is_err()); // Empty 355 | assert!(Color::parse(b"#1234").is_err()); // Not divisible by three 356 | assert!(Color::parse(b"#123456789ABCDEF").is_err()); // Too many components 357 | } 358 | 359 | #[test] 360 | fn black_has_perceived_lightness_zero() { 361 | let black = Color::rgb(0, 0, 0); 362 | assert_eq!(0.0, black.perceived_lightness()) 363 | } 364 | 365 | #[test] 366 | fn white_has_perceived_lightness_one() { 367 | let white = Color::rgb(u16::MAX, u16::MAX, u16::MAX); 368 | assert_eq!(1.0, white.perceived_lightness()) 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | [graph] 2 | all-features = true 3 | exclude = ["benchmark"] 4 | 5 | [output] 6 | feature-depth = 1 7 | 8 | [licenses] 9 | allow = [ 10 | "MIT", 11 | "Apache-2.0", 12 | "Apache-2.0 WITH LLVM-exception", 13 | "Unicode-3.0", 14 | ] 15 | confidence-threshold = 1.0 16 | 17 | [bans] 18 | multiple-versions = "deny" 19 | wildcards = "deny" 20 | skip = [ 21 | { crate = "windows-sys", reason = "an old version is used by mio, see https://github.com/tokio-rs/mio/pull/1820 for updating it" }, 22 | { crate = "heck@0.4", reason = "depended on by clap. updated in a newer clap version, but we can't update because that would require an MSRV bump)" } 23 | ] 24 | 25 | [sources] 26 | unknown-registry = "deny" 27 | unknown-git = "deny" 28 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 29 | -------------------------------------------------------------------------------- /doc/feature-detection.md: -------------------------------------------------------------------------------- 1 | How does colorsaurus detect if a terminal supports querying its colors? 2 | 3 | Colorsaurus sends two escape sequences: `OSC 10` (or `OSC 11`) followed by `DA1`. 4 | `DA1` is supported by almost every terminal. 5 | 6 | Terminals process incoming escape sequences in order. 7 | Therefore if the response to `DA1` is seen first, then the terminal does not support `OSC 10` (or `OSC 11`). 8 | 9 | Colorsaurus thus doesn't need to rely on a timeout to detect if a terminal supports `OSC 10` (or `OSC 11`). 10 | 11 | However, there might still be a lot of latency (e.g. when connected via SSH) or the terminal might not support `DA1`. 12 | To prevent waiting forever in those cases, colorsaurus uses a 1 second timeout by default. 13 | -------------------------------------------------------------------------------- /doc/latency-rustdoc.md: -------------------------------------------------------------------------------- 1 | What kind of latency do I have to expect? 2 | 3 | 4 | *A picture is worth a thousand words.* 5 | 6 | ## Fast Terminals 7 | 8 | 9 | 10 | 11 | 12 | 13 | ## Slow Terminals 14 | 15 | 16 | 17 | 18 | 19 | 20 | > **ℹ️ Note:** 21 | > The macOS terminals were not tested on the same machine as the Linux terminals. 22 | -------------------------------------------------------------------------------- /doc/latency.md: -------------------------------------------------------------------------------- 1 | # Latency Measurements 2 | Measurements generated using [examples/benchmark](../examples/benchmark/src/main.rs): 3 | ```shell 4 | cargo run --release -p benchmark '' 5 | ``` 6 | 7 | ## Fast Terminals 8 | 9 | 10 | 11 | 12 | 13 | ## Slow Terminals 14 | 15 | 16 | 17 | 18 | 19 | > **ℹ️ Note:** 20 | > The macOS terminals were not tested on the same machine as the Linux terminals. 21 | -------------------------------------------------------------------------------- /doc/terminal-survey.md: -------------------------------------------------------------------------------- 1 | The following terminals have known support or non-support for 2 | querying for the background/foreground colors and have been tested 3 | with `terminal-colorsaurus`: 4 | 5 | ## Supported 6 | * Alacritty 7 | * Contour 8 | * foot 9 | * [Ghostty] 10 | * GNOME Terminal, (GNOME) Console, MATE Terminal, XFCE Terminal, (elementary) Terminal, LXTerminal 11 | * Hyper 12 | * The builtin terminal of JetBrains IDEs (i.e. IntelliJ IDEA, …) 13 | * iTerm2 14 | * kitty 15 | * Konsole 16 | * macOS Terminal 17 | * neovim's built-in [terminal][nvim-terminal] 18 | * Rio 19 | * st 20 | * Terminology 21 | * Termux 22 | * tmux (next-3.4) 23 | * urxvt (rxvt-unicode) 24 | * VSCode (xterm.js) 25 | * Warp 26 | * WezTerm 27 | * Windows Terminal (>= v1.22) 28 | * xterm 29 | * [Zed](https://zed.dev) 30 | 31 | ## Unsupported 32 | * linux 33 | * Jetbrains Fleet 34 | * iSH 35 | * GNU Screen 36 | 37 | ## Details 38 | 39 | A list of terminals that were tested for support of `OSC 10` / `OSC 11` and `DA1` (= `CSI c`). 40 | 41 | | Terminal | `OSC 10` and `OSC 11` | `DA1` | Version Tested | 42 | |----------------------------|-----------------------|-------|------------------------------------| 43 | | [Alacritty] | yes | yes | Version 0.13.1 (1) (macOS) | 44 | | [Contour] | yes | yes | 0.4.1.6292 (macOS) | 45 | | [foot] | yes | yes | 1.16.1 | 46 | | [Ghostty] | yes | yes | 1.0.0 (macOS) | 47 | | [Hyper] | yes | yes | 3.4.1 (macOS) | 48 | | [iTerm2] | yes | yes | Build 3.5.0beta18 | 49 | | [kitty] | yes | yes | 0.31.0 | 50 | | (GNOME) [Console] [^1] | yes | yes | 3.50.1 | 51 | | [Konsole] | yes | yes | 23.08.4 | 52 | | [mintty] | yes | yes | 3.6.1 | 53 | | macOS Terminal | yes [^3] | yes | Version 2.13 (447) | 54 | | [neovim][nvim-terminal] | yes | yes | v0.10.2 | 55 | | [mlterm] | yes | yes | [`f3474e1`][mlterm-commit] | 56 | | [Rio] | yes | yes | 0.0.36 (wayland) | 57 | | [rxvt-unicode] | yes [^2] | yes | 9.31 | 58 | | [st] | yes [^3] | yes | 0.9 | 59 | | [Terminology] | yes [^4] | yes | 1.13.0 | 60 | | [Termux] | yes | yes | 0.118.0 | 61 | | [Therm] | yes | yes | 0.6.4 | 62 | | Warp | yes | yes | v0.2024.12.18.08.02.stable\_04 | 63 | | [wayst] | yes | yes | [`51773da`][wayst-commit] | 64 | | [WezTerm] | yes | yes | 20240203-110809-5046fc22 (flatpak) | 65 | | [xst] (fork of st) | yes | yes | 0.9.0 | 66 | | [xterm] | yes | yes | 385 | 67 | | [Yakuake] | yes | yes | 24.12.0 | 68 | | [zed] | yes | yes | 0.167.1 (flatpak) | 69 | | [zutty] | yes | yes | `050bf2b` | 70 | | IntelliJ IDEA ([JediTerm]) | yes | yes | PyCharm 2023.3.2 (macOS) | 71 | | VSCode ([xterm.js]) | yes | yes | 1.85.1 (macOS) | 72 | | Windows Terminal (conhost) | yes | yes | [`b3f4162`][conhost-commit] | 73 | | anyterm | no | *no* | 1.2.3 | 74 | | ConEmu / Cmder | no | yes | 230724 stable | 75 | | cool-retro-term | no | yes | 1.2.0 | 76 | | Eterm | no | *no* | 0.9.6 | 77 | | [Extraterm] | no | yes | 0.80.0 | 78 | | [iSH] (hterm) | no | yes | 1.3.2 (Build 494) (iOS) | 79 | | Jetbrains Fleet | no | yes | build 1.40.87 (macOS) | 80 | | [Lapce] | no | yes | 0.4.2 (macOS) | 81 | | [La Terminal] | no | yes | 1.9.1 | 82 | | Linux console | no | yes | - | 83 | | MobaXterm | no | yes | v24.2 | 84 | | mrxvt | no | yes | 0.5.3 | 85 | | [PuTTY] | no | yes | 0.80 | 86 | | shellinabox | no | *no* | 2.20 | 87 | | QMLKonsole | no | yes | 23.08.5 | 88 | | [QTerminal] | no | yes | 1.3.0 | 89 | | [mosh] | no | yes | 1.4.0 | 90 | | [pangoterm] | no | yes | [revision 634][pangoterm-rev] | 91 | 92 |
93 | 94 | [^1]: Some Linux terminals are omitted since they all use the `vte` library behind the scenes. \ 95 | Here's a non-exhaustive list: GNOME Terminal, (GNOME) Console, MATE Terminal, XFCE Terminal, (GNOME) Builder, (elementary) Terminal, LXTerminal, and Guake. 96 | [^2]: The currently released version has a bug where it terminates the response with `ESC` instead of `ST`. Fixed by revision [1.600](http://cvs.schmorp.de/rxvt-unicode/src/command.C?revision=1.600&view=markup) 97 | [^3]: Response is always terminated with `BEL` even when the query is terminated by `ST`. 98 | [^4]: Response to `OSC 10` is always terminated with `BEL` even when the query is terminated by `ST`. 99 | 100 | The following shell commands can be used to test a terminal: 101 | ```shell 102 | printf '\e[c' && cat -v # Tests for DA1. Example output: ^[[?65;1;9c 103 | printf '\e]10;?\e\\' && cat -v # Tests for foreground color support. Example output: ^[]10;rgb:0000/0000/0000^[\ 104 | printf '\e]11;?\e\\' && cat -v # Tests for background color support. Example output: ^[]11;rgb:ffff/ffff/ffff^[\ 105 | ``` 106 | 107 | [Alacritty]: https://alacritty.org/ 108 | [anyterm]: https://anyterm.org/ 109 | [conhost-commit]: https://github.com/microsoft/terminal/commit/b3f41626b4d212da8ca7c08077b12c289f918c86 110 | [Console]: https://apps.gnome.org/en-GB/Console/ 111 | [Contour]: https://contour-terminal.org/ 112 | [cool-retro-term]: https://github.com/Swordfish90/cool-retro-term 113 | [Ghostty]: https://ghostty.org 114 | [Extraterm]: https://extraterm.org 115 | [foot]: https://codeberg.org/dnkl/foot 116 | [Hyper]: https://hyper.is/ 117 | [iSH]: https://ish.app/ 118 | [iTerm2]: https://iterm2.com/ 119 | [JediTerm]: https://github.com/JetBrains/jediterm 120 | [kitty]: https://sw.kovidgoyal.net/kitty/ 121 | [Konsole]: https://konsole.kde.org/ 122 | [Lapce]: https://lapce.dev/ 123 | [La Terminal]: https://la-terminal.net/ 124 | [mintty]: https://mintty.github.io/ 125 | [nvim-terminal]: http://neovim.io/doc/user/terminal.html 126 | [mlterm-commit]: https://github.com/arakiken/mlterm/commit/f3474e1eb6a97239b38869f0fba78ce3e6a8ad87 127 | [mlterm]: https://mlterm.sourceforge.net/ 128 | [mosh]: https://mosh.org 129 | [pangoterm-rev]: https://bazaar.launchpad.net/~leonerd/pangoterm/trunk/revision/634 130 | [pangoterm]: http://www.leonerd.org.uk/code/pangoterm/ 131 | [PuTTY]: https://www.chiark.greenend.org.uk/~sgtatham/putty/ 132 | [QTerminal]: https://github.com/lxqt/qterminal 133 | [Rio Terminal]: https://raphamorim.io/rio/ 134 | [Rio]: https://raphamorim.io/rio/ 135 | [rxvt-unicode]: http://software.schmorp.de/pkg/rxvt-unicode.html 136 | [shellinabox]: https://github.com/shellinabox/shellinabox 137 | [st]: https://st.suckless.org/ 138 | [Terminology]: http://www.enlightenment.org/ 139 | [Termux]: https://termux.dev/en/ 140 | [Therm]: https://github.com/trufae/Therm 141 | [wayst]: https://github.com/91861/wayst 142 | [wayst-commit]: https://github.com/91861/wayst/commit/51773da1817abb14f2b90635daf30aac0f1536b6 143 | [WezTerm]: https://wezfurlong.org/wezterm/ 144 | [xst]: https://github.com/gnotclub/xst 145 | [xterm.js]: https://xtermjs.org/ 146 | [xterm]: https://invisible-island.net/xterm/ 147 | [Yakuake]: https://apps.kde.org/en-gb/yakuake/ 148 | [zed]: https://zed.dev/ 149 | [zutty]: https://tomscii.sig7.se/zutty/ 150 | -------------------------------------------------------------------------------- /license-apache.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /license-mit.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tau Gärtli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # terminal-colorsaurus 🦕 2 | 3 | [![Docs](https://img.shields.io/docsrs/terminal-colorsaurus/latest)](https://docs.rs/terminal-colorsaurus) 4 | [![Crate Version](https://img.shields.io/crates/v/terminal-colorsaurus)](https://crates.io/crates/terminal-colorsaurus) 5 | 6 | A cross-platform library for determining the terminal's background and foreground color. \ 7 | It answers the question *«Is this terminal dark or light?»*. 8 | 9 | Works in all major terminals including Windows Terminal (starting with v1.22). 10 | 11 | ## Example 12 | ```rust,no_run 13 | use terminal_colorsaurus::{color_scheme, QueryOptions, ColorScheme}; 14 | 15 | match color_scheme(QueryOptions::default()).unwrap() { 16 | ColorScheme::Dark => { /* ... */ }, 17 | ColorScheme::Light => { /* ... */ }, 18 | } 19 | ``` 20 | 21 | ## [Docs](https://docs.rs/terminal-colorsaurus) 22 | 23 | ## Inspiration 24 | This crate borrows ideas from many other projects. This list is by no means exhaustive. 25 | 26 | * [xterm-query]: Use `mio` to wait for the terminal's response with a timeout. 27 | * [termbg]: Lists a lot of terminals which served as a good starting point for me to test terminals as well. 28 | * [macOS doesn't like polling /dev/tty][macos-dev-tty] by Nathan Craddock 29 | * [This excellent answer on Stack Overflow][perceived-lightness] for determining the perceived lightness of a color. 30 | * [This comment in the Terminal WG](https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/8#note_151381) for the `DA1` trick 31 | to easily detect terminals that don't support querying the colors with `OSC 10` / `OSC 11`. 32 | 33 | ## License 34 | Licensed under either of 35 | 36 | * Apache License, Version 2.0 37 | ([license-apache.txt](license-apache.txt) or ) 38 | * MIT license 39 | ([license-mit.txt](license-mit.txt) or ) 40 | 41 | at your option. 42 | 43 | ## Contribution 44 | Unless you explicitly state otherwise, any contribution intentionally submitted 45 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall be 46 | dual licensed as above, without any additional terms or conditions. 47 | 48 | [xterm-query]: https://github.com/Canop/xterm-query 49 | [termbg]: https://github.com/dalance/termbg 50 | [macos-dev-tty]: https://nathancraddock.com/blog/macos-dev-tty-polling/ 51 | [perceived-lightness]: https://stackoverflow.com/a/56678483 52 | -------------------------------------------------------------------------------- /typos.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | extend-exclude = ["*.svg"] 3 | --------------------------------------------------------------------------------