diff --git a/.all-contributorsrc b/.all-contributorsrc index ddb03fede..e13777b0a 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -117,15 +117,6 @@ "code" ] }, - { - "login": "vlakreeh", - "name": "Zeb Piasecki", - "avatar_url": "https://avatars.githubusercontent.com/u/14242997?v=4", - "profile": "https://zebulon.dev/", - "contributions": [ - "code" - ] - }, { "login": "briandipalma", "name": "Brian Di Palma", @@ -383,7 +374,7 @@ }, { "login": "aragonnetje6", - "name": "Twan Stok", + "name": "Grace Stok", "avatar_url": "https://avatars.githubusercontent.com/u/69118097?v=4", "profile": "https://github.com/aragonnetje6", "contributions": [ @@ -490,6 +481,152 @@ "contributions": [ "code" ] + }, + { + "login": "woodsb02", + "name": "Ben Woods", + "avatar_url": "https://avatars.githubusercontent.com/u/7113557?v=4", + "profile": "https://www.woods.am/", + "contributions": [ + "doc" + ] + }, + { + "login": "stephen-huan", + "name": "Stephen Huan", + "avatar_url": "https://avatars.githubusercontent.com/u/20411956?v=4", + "profile": "http://cgdct.moe/", + "contributions": [ + "code" + ] + }, + { + "login": "jasongwartz", + "name": "Jason Gwartz", + "avatar_url": "https://avatars.githubusercontent.com/u/10981911?v=4", + "profile": "https://github.com/jasongwartz", + "contributions": [ + "doc" + ] + }, + { + "login": "llc0930", + "name": "llc0930", + "avatar_url": "https://avatars.githubusercontent.com/u/14966910?v=4", + "profile": "https://github.com/llc0930", + "contributions": [ + "code" + ] + }, + { + "login": "yretenai", + "name": "Ada Ahmed", + "avatar_url": "https://avatars.githubusercontent.com/u/614231?v=4", + "profile": "https://chronovore.dev/", + "contributions": [ + "code" + ] + }, + { + "login": "Wateir", + "name": "Wateir", + "avatar_url": "https://avatars.githubusercontent.com/u/78731687?v=4", + "profile": "https://github.com/Wateir", + "contributions": [ + "doc" + ] + }, + { + "login": "al42and", + "name": "Andrey Alekseenko", + "avatar_url": "https://avatars.githubusercontent.com/u/933873?v=4", + "profile": "https://github.com/al42and", + "contributions": [ + "code" + ] + }, + { + "login": "fgimian", + "name": "Fotis Gimian", + "avatar_url": "https://avatars.githubusercontent.com/u/1811813?v=4", + "profile": "http://fgimian.github.io/", + "contributions": [ + "code", + "doc" + ] + }, + { + "login": "SigmaSquadron", + "name": "Fernando Rodrigues", + "avatar_url": "https://avatars.githubusercontent.com/u/174749595?v=4", + "profile": "https://sigmasquadron.net/", + "contributions": [ + "doc" + ] + }, + { + "login": "mtoohey31", + "name": "Matthew Toohey", + "avatar_url": "https://avatars.githubusercontent.com/u/36740602?v=4", + "profile": "https://mtoohey.com/", + "contributions": [ + "code" + ] + }, + { + "login": "win8linux", + "name": "Julius Enriquez", + "avatar_url": "https://avatars.githubusercontent.com/u/11584387?v=4", + "profile": "https://meander.site/", + "contributions": [ + "doc" + ] + }, + { + "login": "benjamb", + "name": "Ben Brown", + "avatar_url": "https://avatars.githubusercontent.com/u/8291297?v=4", + "profile": "https://github.com/benjamb", + "contributions": [ + "code" + ] + }, + { + "login": "nyurik", + "name": "Yuri Astrakhan", + "avatar_url": "https://avatars.githubusercontent.com/u/1641515?v=4", + "profile": "https://github.com/nyurik", + "contributions": [ + "code", + "doc" + ] + }, + { + "login": "kachick", + "name": "Kenichi Kamiya", + "avatar_url": "https://avatars.githubusercontent.com/u/1180335?v=4", + "profile": "https://kachick.github.io/", + "contributions": [ + "code" + ] + }, + { + "login": "yahlia", + "name": "yahlia", + "avatar_url": "https://avatars.githubusercontent.com/u/40295453?v=4", + "profile": "https://github.com/yahlia", + "contributions": [ + "code" + ] + }, + { + "login": "Bucket-Bucket-Bucket", + "name": "Bucket-Bucket-Bucket", + "avatar_url": "https://avatars.githubusercontent.com/u/107044719?v=4", + "profile": "https://github.com/Bucket-Bucket-Bucket", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/.cirrus.yml b/.cirrus.yml index 060a94eeb..5a60ed9e6 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -39,35 +39,6 @@ env: CARGO_PROFILE_DEV_DEBUG: "0" CARGO_HUSKY_DONT_INSTALL_HOOKS: "true" -test_task: - auto_cancellation: "false" # We set this to false to prevent nightly builds from affecting this - only_if: $CIRRUS_BUILD_SOURCE != "api" && ($CIRRUS_BRANCH == "main" || $CIRRUS_PR != "") - timeout_in: "15m" - skip: "!changesInclude('.cargo/**', 'sample_configs/**', 'scripts/cirrus/**', 'src/**', 'tests/**', '.cirrus.yml', 'build.rs', 'Cargo.lock', 'Cargo.toml', 'clippy.toml', 'rustfmt.toml')" - matrix: - - name: "FreeBSD 14 Test" - freebsd_instance: - image_family: freebsd-14-0 - - - name: "FreeBSD 13 Test" - freebsd_instance: - image_family: freebsd-13-3 - <<: *SETUP_TEMPLATE - <<: *CACHE_TEMPLATE - test_no_feature_script: - - . $HOME/.cargo/env - - cargo fmt --all -- --check - - cargo test --no-run --locked --no-default-features - - cargo test --no-fail-fast --no-default-features -- --nocapture --quiet - - cargo clippy --all-targets --workspace --no-default-features -- -D warnings - test_all_feature_script: - - . $HOME/.cargo/env - - cargo fmt --all -- --check - - cargo test --no-run --locked --all-features - - cargo test --no-fail-fast --all-features -- --nocapture --quiet - - cargo clippy --all-targets --workspace --all-features -- -D warnings - <<: *CLEANUP_TEMPLATE - release_task: auto_cancellation: "false" only_if: $CIRRUS_BUILD_SOURCE == "api" && $BTM_BUILD_RELEASE_CALLER == "ci" @@ -78,22 +49,6 @@ release_task: MANPAGE_DIR: "target/tmp/bottom/manpage/" # -PLACEHOLDER FOR CI- matrix: - - name: "FreeBSD 14 Build" - alias: "freebsd_14_0_build" - freebsd_instance: - image_family: freebsd-14-0 - env: - TARGET: "x86_64-unknown-freebsd" - NAME: "x86_64-unknown-freebsd-14-0" - - - name: "FreeBSD 13 Build" - alias: "freebsd_13_3_build" - freebsd_instance: - image_family: freebsd-13-3 - env: - TARGET: "x86_64-unknown-freebsd" - NAME: "x86_64-unknown-freebsd-13-3" - - name: "Legacy Linux (2.17)" alias: "linux_2_17_build" container: diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f399475d2..6348b565b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -51,8 +51,10 @@ body: - type: dropdown id: filesystem + validations: + required: false attributes: - label: What filesystem(s) are you using? + label: (Optional) What filesystem(s) are you using? description: > If you know, please select what filesystem(s) you are using on the system that is experiencing the problem. This can be especially helpful if the issue is related to either the disk or memory widgets. @@ -79,7 +81,7 @@ body: It would also be helpful if you are not running [the latest version](https://github.com/ClementTsang/bottom/releases/latest) to try that as well to see if the issue has already been resolved. - placeholder: 0.10.2 + placeholder: 0.11.0 - type: textarea id: install diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml deleted file mode 100644 index f811945d9..000000000 --- a/.github/workflows/audit.yml +++ /dev/null @@ -1,35 +0,0 @@ -# A routine check to see if there are any Rust-specific security vulnerabilities in the repo we should be aware of. - -name: audit -on: - workflow_dispatch: - schedule: - - cron: "0 0 * * 1" -jobs: - audit: - timeout-minutes: 18 - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - - name: Set up Rust toolchain - uses: dtolnay/rust-toolchain@21dc36fb71dd22e3317045c0c31a3f4249868b17 - with: - toolchain: stable - - - name: Enable Rust cache - uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # 2.7.3 - with: - cache-targets: false - cache-all-crates: true - cache-on-failure: true - - - name: Install cargo-audit - run: | - cargo install cargo-audit --locked - rm -rf ~/.cargo/registry || echo "no registry to delete" - - - uses: rustsec/audit-check@dd51754d4e59da7395a4cd9b593f0ff2d61a9b95 # v1.4.1 - with: - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build_releases.yml b/.github/workflows/build_releases.yml index f202fe7c5..23d65e273 100644 --- a/.github/workflows/build_releases.yml +++ b/.github/workflows/build_releases.yml @@ -1,11 +1,10 @@ # Builds the following releases: -# - Binary releases +# - Binaries +# - Binaries via VMs +# - Cirrus binaries (currently just Linux 2.17) +# - MSI installer for Windows (.msi) # - .deb releases # - .rpm releases -# - MSI installer for Windows (.msi) -# - Cirrus CI binaries -# - FreeBSD (x86_64) -# - macOS (aarch64) name: "build releases" @@ -38,58 +37,60 @@ jobs: name: "Build binaries" runs-on: ${{ matrix.info.os }} container: ${{ matrix.info.container }} - timeout-minutes: 30 + timeout-minutes: 12 strategy: fail-fast: false matrix: info: # ======= Supported targets ======= # Linux (x86-64, x86, aarch64) + # + # TODO: In the future, when ARM runners are available on github, switch ARM targets off of cross. - { - os: "ubuntu-20.04", + os: "ubuntu-22.04", target: "x86_64-unknown-linux-gnu", cross: false, generate-other-artifacts: true, } - { - os: "ubuntu-20.04", + os: "ubuntu-22.04", target: "i686-unknown-linux-gnu", cross: true, } - { - os: "ubuntu-20.04", + os: "ubuntu-22.04", target: "x86_64-unknown-linux-musl", cross: true, } - { - os: "ubuntu-20.04", + os: "ubuntu-22.04", target: "i686-unknown-linux-musl", cross: true, } - { - os: "ubuntu-20.04", + os: "ubuntu-22.04", target: "aarch64-unknown-linux-gnu", cross: true, } - { - os: "ubuntu-20.04", + os: "ubuntu-22.04", target: "aarch64-unknown-linux-musl", cross: true, } # macOS (x86-64 and aarch64) - - { os: "macos-12", target: "x86_64-apple-darwin", cross: false } + - { os: "macos-13", target: "x86_64-apple-darwin", cross: false } - { os: "macos-14", target: "aarch64-apple-darwin", cross: false } # Windows (x86-64, x86) - { - os: "windows-2019", + os: "windows-2022", target: "x86_64-pc-windows-msvc", cross: false, } - - { os: "windows-2019", target: "i686-pc-windows-msvc", cross: false } + - { os: "windows-2022", target: "i686-pc-windows-msvc", cross: false } - { - os: "windows-2019", + os: "windows-2022", target: "x86_64-pc-windows-gnu", cross: false, } @@ -97,39 +98,37 @@ jobs: # ======= Unsupported targets ======= # armv7 - { - os: "ubuntu-20.04", + os: "ubuntu-22.04", target: "armv7-unknown-linux-gnueabihf", cross: true, } - { - os: "ubuntu-20.04", + os: "ubuntu-22.04", target: "armv7-unknown-linux-musleabihf", cross: true, } # PowerPC 64 LE - { - os: "ubuntu-20.04", + os: "ubuntu-22.04", target: "powerpc64le-unknown-linux-gnu", cross: true, } # Risc-V 64gc - { - os: "ubuntu-20.04", + os: "ubuntu-22.04", target: "riscv64gc-unknown-linux-gnu", cross: true, } + + # Seems like cross' FreeBSD image is a bit broken? I + # get build errors, may be related to this issue: + # https://github.com/cross-rs/cross/issues/1291 steps: - name: Checkout repository if: matrix.info.container == '' - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - with: - fetch-depth: 1 - - - name: Checkout repository (non-GitHub container) - if: matrix.info.container != '' - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 1 @@ -166,7 +165,7 @@ jobs: mv "$MANPAGE_DIR" manpage - name: Bundle release and completion (Windows) - if: matrix.info.os == 'windows-2019' + if: matrix.info.os == 'windows-2022' shell: bash run: | cp target/${{ matrix.info.target }}/release/btm.exe btm.exe @@ -175,15 +174,15 @@ jobs: echo "ASSET=bottom_${{ matrix.info.target }}.zip" >> $GITHUB_ENV - name: Bundle release and completion (Linux and macOS) - if: matrix.info.os != 'windows-2019' + if: matrix.info.os != 'windows-2022' shell: bash run: | cp target/${{ matrix.info.target }}/release/btm ./btm - tar -czvf bottom_${{ matrix.info.target }}${{ matrix.info.suffix }}.tar.gz btm completion - echo "ASSET=bottom_${{ matrix.info.target }}${{ matrix.info.suffix }}.tar.gz" >> $GITHUB_ENV + tar -czvf bottom_${{ matrix.info.target }}.tar.gz btm completion + echo "ASSET=bottom_${{ matrix.info.target }}.tar.gz" >> $GITHUB_ENV - name: Generate artifact attestation for file - uses: actions/attest-build-provenance@v1 + uses: actions/attest-build-provenance@6149ea5740be74af77f260b9db67e633f6b0a9a1 # v1.4.2 with: subject-path: ${{ env.ASSET }} @@ -218,16 +217,98 @@ jobs: uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 with: retention-days: 3 - name: "release-${{ matrix.info.target }}${{ matrix.info.suffix }}" + name: "release-${{ matrix.info.target }}" + path: release + + build-vm: + name: "Build binaries via VMs" + runs-on: "ubuntu-latest" + timeout-minutes: 12 + strategy: + fail-fast: false + matrix: + info: + # Seems like cross's FreeBSD image is a bit broken? I get build errors, + # may be related to this issue: https://github.com/cross-rs/cross/issues/1291 + + # Alas, that's why we do it with VMs. + + # Disabled as there's a weird issue with installing curl on FreeBSD 15 at the moment. + # - { + # type: "freebsd", + # os_release: "15.0", + # target: "x86_64-unknown-freebsd", + # } + - { + type: "freebsd", + os_release: "14.3", + target: "x86_64-unknown-freebsd", + } + - { + type: "freebsd", + os_release: "13.5", + target: "x86_64-unknown-freebsd", + } + steps: + - name: Checkout repository + if: matrix.info.container == '' + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + fetch-depth: 1 + + - name: Build (FreeBSD) + if: ${{ matrix.info.type == 'freebsd' }} + uses: vmactions/freebsd-vm@966989c456d41351f095a421f60e71342d3bce41 # v1.2.1 + with: + release: "${{ matrix.info.os_release }}" + envs: "RUST_BACKTRACE CARGO_INCREMENTAL CARGO_PROFILE_DEV_DEBUG CARGO_HUSKY_DONT_INSTALL_HOOKS COMPLETION_DIR MANPAGE_DIR" + usesh: true + prepare: | + pkg install -y curl bash + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs --output rustup.sh + sh rustup.sh --default-toolchain stable -y + run: | + . "$HOME/.cargo/env" + BTM_GENERATE=true BTM_BUILD_RELEASE_CALLER=${{ inputs.caller }} cargo build --release --verbose --locked --target=${{ matrix.info.target }} --features deploy + + - name: Move automatically generated completion/manpage + shell: bash + run: | + mv "$COMPLETION_DIR" completion + mv "$MANPAGE_DIR" manpage + + - name: Bundle release and completion + shell: bash + run: | + cp target/${{ matrix.info.target }}/release/btm ./btm + tar -czvf bottom_${{ matrix.info.target }}-${{ matrix.info.os_release }}.tar.gz btm completion + echo "ASSET=bottom_${{ matrix.info.target }}-${{ matrix.info.os_release }}.tar.gz" >> $GITHUB_ENV + + - name: Generate artifact attestation for file + uses: actions/attest-build-provenance@6149ea5740be74af77f260b9db67e633f6b0a9a1 # v1.4.2 + with: + subject-path: ${{ env.ASSET }} + + - name: Create release directory for artifact, move file + shell: bash + run: | + mkdir release + mv ${{ env.ASSET }} release/ + + - name: Save release as artifact + uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 + with: + retention-days: 3 + name: "release-${{ matrix.info.target }}-${{ matrix.info.os_release }}" path: release build-msi: - name: "Build MSI installer" - runs-on: "windows-2019" - timeout-minutes: 30 + name: "Build MSI (WiX) installer" + runs-on: "windows-2022" + timeout-minutes: 12 steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 1 @@ -246,16 +327,20 @@ jobs: toolchain: stable target: x86_64-pc-windows-msvc + - name: Install cargo-wix + shell: powershell + run: | + cargo install cargo-wix --version 0.3.8 --locked + - name: Build MSI file shell: powershell env: - BTM_GENERATE: "" + BTM_GENERATE: true run: | - cargo install cargo-wix --version 0.3.8 --locked - cargo wix + cargo wix --nocapture - name: Generate artifact attestation for file - uses: actions/attest-build-provenance@v1 + uses: actions/attest-build-provenance@6149ea5740be74af77f260b9db67e633f6b0a9a1 # v1.4.2 with: subject-path: "bottom_x86_64_installer.msi" @@ -275,10 +360,10 @@ jobs: build-cirrus: name: "Build using Cirrus CI" runs-on: "ubuntu-latest" - timeout-minutes: 30 + timeout-minutes: 12 steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 0 @@ -299,7 +384,7 @@ jobs: python ./scripts/cirrus/release.py "$BRANCH" "release/" "${{ inputs.caller }}" - name: Generate artifact attestation for file - uses: actions/attest-build-provenance@v1 + uses: actions/attest-build-provenance@6149ea5740be74af77f260b9db67e633f6b0a9a1 # v1.4.2 with: subject-path: "release/**/*.tar.gz" @@ -312,11 +397,12 @@ jobs: build-deb: name: "Build .deb software packages" - runs-on: "ubuntu-20.04" - timeout-minutes: 30 + runs-on: "ubuntu-22.04" + timeout-minutes: 12 strategy: fail-fast: false matrix: + # TODO: In the future, when ARM runners are available on github, switch ARM targets off of cross. info: - { target: "x86_64-unknown-linux-gnu", dpkg: amd64 } - { target: "x86_64-unknown-linux-musl", cross: true, dpkg: amd64 } @@ -346,7 +432,7 @@ jobs: } steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 1 @@ -420,7 +506,7 @@ jobs: rm -r ./target/${{ matrix.info.target }}/debian/ - name: Generate artifact attestation for file - uses: actions/attest-build-provenance@v1 + uses: actions/attest-build-provenance@6149ea5740be74af77f260b9db67e633f6b0a9a1 # v1.4.2 with: subject-path: ${{ steps.verify.outputs.DEB_FILE }} @@ -441,7 +527,7 @@ jobs: name: "Build .rpm software packages" runs-on: ubuntu-latest container: ghcr.io/clementtsang/almalinux-8 - timeout-minutes: 30 + timeout-minutes: 12 strategy: fail-fast: false matrix: @@ -450,7 +536,7 @@ jobs: - { target: "x86_64-unknown-linux-musl", cross: true } steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 1 @@ -510,7 +596,7 @@ jobs: rm -r ./target/${{ matrix.info.target }}/generate-rpm/ - name: Generate artifact attestation for file - uses: actions/attest-build-provenance@v1 + uses: actions/attest-build-provenance@6149ea5740be74af77f260b9db67e633f6b0a9a1 # v1.4.2 with: subject-path: ${{ steps.verify.outputs.RPM_FILE }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d5cb94cf..182b53076 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ -# Main CI workflow to validate PRs and branches are correctly formatted -# and pass tests. +# Main CI workflow to validate that files are formatted correctly, pass tests, +# and pass lints. # # CI workflow was based on a lot of work from other people: # - https://github.com/heim-rs/heim/blob/master/.github/workflows/ci.yml @@ -8,16 +8,12 @@ # - https://matklad.github.io/2021/09/04/fast-rust-builds.html # # Supported platforms run the following tasks: -# - cargo fmt -# - cargo test (built/test in separate steps) -# - cargo clippy (apparently faster to do it after the build/test) +# - Format +# - Test (built/test in separate steps) +# - Clippy (apparently faster to do it after the build/test) # # Unsupported platforms run the following tasks: -# - cargo build -# -# Note that not all platforms are tested using this CI action! There are some -# tested by Cirrus CI due to (free) platform limitations on GitHub. Currently, -# this is just macOS M1 and FreeBSD. +# - Clippy name: ci @@ -57,12 +53,12 @@ jobs: # Runs rustfmt + tests + clippy on the main supported platforms. # - # Note that m1 macOS is tested via CirrusCI. + # TODO: In the future, when ARM runners are available on github, switch ARM targets off of cross. supported: needs: pre-job if: ${{ needs.pre-job.outputs.should_skip != 'true' }} runs-on: ${{ matrix.info.os }} - timeout-minutes: 18 + timeout-minutes: 12 strategy: fail-fast: false matrix: @@ -77,17 +73,17 @@ jobs: target: "aarch64-unknown-linux-gnu", cross: true, } - - { os: "macos-12", target: "x86_64-apple-darwin", cross: false } + - { os: "macos-13", target: "x86_64-apple-darwin", cross: false } - { os: "macos-14", target: "aarch64-apple-darwin", cross: false } - { - os: "windows-2019", + os: "windows-2022", target: "x86_64-pc-windows-msvc", cross: false, } features: ["--all-features", "--no-default-features"] steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@21dc36fb71dd22e3317045c0c31a3f4249868b17 @@ -97,7 +93,7 @@ jobs: target: ${{ matrix.info.target }} - name: Enable Rust cache - uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # 2.7.3 + uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # 2.7.8 if: ${{ github.event_name != 'pull_request' || ! github.event.pull_request.head.repo.fork }} # If it is a PR, only if not a fork with: key: ${{ matrix.info.target }} @@ -106,6 +102,7 @@ jobs: - name: Check cargo fmt run: cargo fmt --all -- --check + # TODO: add junit output using nextest for codecov (https://docs.codecov.com/docs/test-analytics) - name: Build tests uses: ClementTsang/cargo-action@v0.0.5 with: @@ -137,13 +134,14 @@ jobs: RUST_BACKTRACE: full # Try running cargo build on all other platforms. + # # TODO: Maybe some of these should be allowed to fail? If so, I guess we can add back the "unofficial" MSRV, # I would also put android there. other-check: needs: pre-job runs-on: ${{ matrix.info.os }} if: ${{ needs.pre-job.outputs.should_skip != 'true' }} - timeout-minutes: 20 + timeout-minutes: 12 strategy: fail-fast: false matrix: @@ -165,9 +163,9 @@ jobs: cross: true, } - - { os: "windows-2019", target: "i686-pc-windows-msvc", cross: false } + - { os: "windows-2022", target: "i686-pc-windows-msvc", cross: false } - { - os: "windows-2019", + os: "windows-2022", target: "x86_64-pc-windows-gnu", cross: false, } @@ -180,13 +178,13 @@ jobs: rust: "beta", } - { - os: "macos-12", - target: "x86_64-apple-darwin", + os: "macos-14", + target: "aarch64-apple-darwin", cross: false, rust: "beta", } - { - os: "windows-2019", + os: "windows-2022", target: "x86_64-pc-windows-msvc", cross: false, rust: "beta", @@ -214,6 +212,7 @@ jobs: } # Risc-V 64gc + # Note: seems like this breaks with tests? - { os: "ubuntu-latest", target: "riscv64gc-unknown-linux-gnu", @@ -225,58 +224,125 @@ jobs: os: "ubuntu-latest", target: "aarch64-linux-android", cross: true, - cross-version: "git:cabfc3b02d1edec03869fabdabf6a7f8b0519160", + cross-version: "git:df3309709a4a26b3dc3b1567239c3f38b9da0425", # latest version that I've found works so far no-default-features: true, + no-clippy: true, } steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@21dc36fb71dd22e3317045c0c31a3f4249868b17 with: toolchain: ${{ matrix.info.rust || 'stable' }} target: ${{ matrix.info.target }} + components: "clippy" - name: Enable Rust cache - uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # 2.7.3 + uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # 2.7.8 if: ${{ github.event_name != 'pull_request' || ! github.event.pull_request.head.repo.fork }} # If it is a PR, only if not a fork with: key: ${{ matrix.info.target }} cache-all-crates: true - - name: Try building with only default features enabled + - name: Clippy (default features) uses: ClementTsang/cargo-action@v0.0.5 if: ${{ matrix.info.no-default-features != true }} with: - command: build - args: --all-targets --verbose --target=${{ matrix.info.target }} --locked + command: clippy + args: --all-targets --workspace --target=${{ matrix.info.target }} --locked use-cross: ${{ matrix.info.cross }} cross-version: ${{ matrix.info.cross-version || '0.2.5' }} - - name: Try building with no features enabled + - name: Clippy (no features enabled) uses: ClementTsang/cargo-action@v0.0.5 if: ${{ matrix.info.no-default-features == true }} with: - command: build - args: --all-targets --verbose --target=${{ matrix.info.target }} --locked --no-default-features + command: clippy + args: --all-targets --workspace --target=${{ matrix.info.target }} --locked --no-default-features use-cross: ${{ matrix.info.cross }} cross-version: ${{ matrix.info.cross-version || '0.2.5' }} + vm-check: + name: "Test using VMs" + needs: pre-job + if: ${{ needs.pre-job.outputs.should_skip != 'true' }} + runs-on: "ubuntu-latest" + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + info: + # Seems like cross's FreeBSD image is a bit broken? I get build errors, + # may be related to this issue: https://github.com/cross-rs/cross/issues/1291 + # + # Alas, that's why we do it with VMs. + + # Disabled as there's a weird issue with installing curl on FreeBSD 15 at the moment. + # - { + # type: "freebsd", + # os_release: "15.0", + # target: "x86_64-unknown-freebsd", + # } + - { + type: "freebsd", + os_release: "14.3", + target: "x86_64-unknown-freebsd", + } + - { + type: "freebsd", + os_release: "13.5", + target: "x86_64-unknown-freebsd", + } + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + fetch-depth: 1 + + - name: Enable Rust cache + uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # 2.7.8 + if: ${{ github.event_name != 'pull_request' || ! github.event.pull_request.head.repo.fork }} # If it is a PR, only if not a fork + with: + key: ${{ matrix.info.target }}-${{ matrix.info.os_release }} + cache-all-crates: true + + - name: Clippy (FreeBSD) + if: ${{ matrix.info.type == 'freebsd' }} + uses: vmactions/freebsd-vm@966989c456d41351f095a421f60e71342d3bce41 # v1.2.1 + with: + release: "${{ matrix.info.os_release }}" + envs: "RUST_BACKTRACE CARGO_INCREMENTAL CARGO_PROFILE_DEV_DEBUG CARGO_HUSKY_DONT_INSTALL_HOOKS" + usesh: true + prepare: | + pkg install -y curl bash + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs --output rustup.sh + sh rustup.sh --default-toolchain stable -y + run: | + . "$HOME/.cargo/env" + cargo clippy --all-targets --workspace -- -D warnings + completion: name: "CI Pass Check" - needs: [supported, other-check] - if: ${{ success() || failure() }} + needs: [supported, other-check, vm-check] + if: ${{ needs.supported.result != 'skipped' || needs.other-check.result != 'skipped' || needs.vm-check.result != 'skipped' }} runs-on: "ubuntu-latest" steps: - name: CI Passed - if: ${{ (needs.supported.result == 'success' && needs.other-check.result == 'success') || (needs.supported.result == 'skipped' && needs.other-check.result == 'skipped') }} + if: ${{ (needs.supported.result == 'success' || needs.supported.result == 'skipped') && (needs.other-check.result == 'success' || needs.other-check.result == 'skipped') && (needs.vm-check.result == 'success' || needs.vm-check.result == 'skipped') }} run: | echo "CI workflow completed successfully."; - name: CI Failed - if: ${{ needs.supported.result == 'failure' && needs.other-check.result == 'failure' }} + if: ${{ needs.supported.result == 'failure' || needs.other-check.result == 'failure' || needs.vm-check.result == 'failure' }} + run: | + echo "CI workflow failed."; + exit 1; + + - name: CI Cancelled + if: ${{ needs.supported.result == 'cancelled' || needs.other-check.result == 'cancelled' || needs.vm-check.result == 'cancelled' }} run: | - echo "CI workflow failed at some point."; + echo "CI workflow was cancelled."; exit 1; diff --git a/.github/workflows/clear_workflow_cache.yml b/.github/workflows/clear_workflow_cache.yml index 6d6ca6c9d..c9895961b 100644 --- a/.github/workflows/clear_workflow_cache.yml +++ b/.github/workflows/clear_workflow_cache.yml @@ -22,7 +22,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 1 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e5f5e962e..8322060dd 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -37,17 +37,17 @@ jobs: needs: pre-job if: ${{ needs.pre-job.outputs.should_skip != 'true' }} runs-on: ${{ matrix.info.os }} - timeout-minutes: 18 + timeout-minutes: 12 strategy: fail-fast: false matrix: info: - { os: "ubuntu-latest", target: "x86_64-unknown-linux-gnu" } - - { os: "macos-12", target: "x86_64-apple-darwin" } - - { os: "windows-2019", target: "x86_64-pc-windows-msvc" } + - { os: "macos-14", target: "aarch64-apple-darwin", cross: false } + - { os: "windows-2022", target: "x86_64-pc-windows-msvc" } steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@21dc36fb71dd22e3317045c0c31a3f4249868b17 @@ -55,7 +55,7 @@ jobs: toolchain: stable - name: Enable Rust cache - uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # 2.7.3 + uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # 2.7.8 if: ${{ github.event_name != 'pull_request' || ! github.event.pull_request.head.repo.fork }} # If it is a PR, only if not a fork with: key: ${{ matrix.info.target }} @@ -71,14 +71,36 @@ jobs: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info --locked --target=${{ matrix.info.target }} # The token is generally not needed, but sometimes the default shared token hits limits. - - name: Upload to codecov.io - uses: Wandalen/wretry.action@6feedb7dedadeb826de0f45ff482b53b379a7844 # v3.5.0 + # Yes this is ugly as hell. Why this is not a built-in feature of GHA, I have no idea. + + - name: Upload to codecov.io (Attempt 1) + id: upload_attempt_1 + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 + with: + files: lcov.info + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.info.os }} + continue-on-error: true + + - name: Upload to codecov.io (Attempt 2) + id: upload_attempt_2 + if: steps.upload_attempt_1.outcome == 'failure' + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 + with: + files: lcov.info + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.info.os }} + continue-on-error: true + + - name: Upload to codecov.io (Attempt 3) + id: upload_attempt_3 + if: steps.upload_attempt_2.outcome == 'failure' + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 with: - action: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 - with: | - files: lcov.info - fail_ci_if_error: true - token: ${{ secrets.CODECOV_TOKEN }} - flags: ${{ matrix.info.os }} - attempt_limit: 5 - attempt_delay: 1500 + files: lcov.info + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.info.os }} + continue-on-error: true diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 6c31c8566..e2b872e15 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -54,7 +54,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 1 @@ -68,7 +68,7 @@ jobs: echo "Release version: ${{ env.RELEASE_VERSION }}" - name: Get release artifacts - uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: pattern: release-* path: release @@ -106,7 +106,7 @@ jobs: echo "Release version: ${{ env.RELEASE_VERSION }}" - name: Get release artifacts - uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: pattern: release-* path: release @@ -118,7 +118,7 @@ jobs: du -h -d 0 ./release/* - name: Create release and add release files - uses: softprops/action-gh-release@20e085ccc73308c2c8e43ab8da4f8d7ecbb94d4e # 2.0.1 + uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 # 2.0.8 with: token: ${{ secrets.GITHUB_TOKEN }} prerelease: false diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7e85af2db..2499e5c06 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,6 +1,7 @@ # Workflow to deploy mkdocs documentation. name: docs + on: workflow_dispatch: push: @@ -21,13 +22,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 0 - - uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - python-version: 3.11 + python-version: 3.12 - name: Install Python dependencies run: pip install -r docs/requirements.txt diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 335bd1248..c83a8e409 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -9,9 +9,10 @@ on: workflow_dispatch: inputs: isMock: - description: "Replace with any word other than 'mock' to trigger a non-mock run." - default: "mock" + description: "Mock run" + default: true required: false + type: boolean env: CARGO_INCREMENTAL: 0 @@ -19,8 +20,9 @@ env: CARGO_HUSKY_DONT_INSTALL_HOOKS: true jobs: - # Check if things should be skipped. - pre-job: + # Check if things should be skipped, or if this is a mock job. + initialize-job: + name: initialize-job runs-on: ubuntu-latest outputs: should_skip: ${{ steps.skip_check.outputs.should_skip }} @@ -32,18 +34,11 @@ jobs: skip_after_successful_duplicate: "true" do_not_skip: '["workflow_dispatch"]' - initialize-job: - name: initialize-job - needs: pre-job - if: ${{ needs.pre-job.outputs.should_skip != 'true' }} - runs-on: ubuntu-latest - steps: - name: Check if mock run: | - echo "${{ github.event.inputs.isMock }}"; if [[ -z "${{ github.event.inputs.isMock }}" ]]; then echo "This is a scheduled nightly run." - elif [[ "${{ github.event.inputs.isMock }}" == "mock" ]]; then + elif [[ "${{ github.event.inputs.isMock }}" == "true" ]]; then echo "This is a mock run." else echo "This is NOT a mock run. Watch for the generated files!" @@ -51,6 +46,7 @@ jobs: build-release: needs: initialize-job + if: ${{ needs.initialize-job.outputs.should_skip != 'true' }} uses: ./.github/workflows/build_releases.yml with: caller: "nightly" @@ -62,12 +58,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 1 - name: Get release artifacts - uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: pattern: release-* path: release @@ -79,18 +75,18 @@ jobs: du -h -d 0 ./release/* - name: Delete tag and release if not mock - if: github.event.inputs.isMock != 'mock' + if: github.event.inputs.isMock != 'true' run: gh release delete nightly --cleanup-tag env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Sleep for a few seconds to prevent timing issues between the deletion and creation of the release run: sleep 10 - if: github.event.inputs.isMock != 'mock' + if: github.event.inputs.isMock != 'true' - name: Add all release files and create nightly release if not mock - uses: softprops/action-gh-release@20e085ccc73308c2c8e43ab8da4f8d7ecbb94d4e # 2.0.1 - if: github.event.inputs.isMock != 'mock' + uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 # 2.0.8 + if: github.event.inputs.isMock != 'true' with: token: ${{ secrets.GITHUB_TOKEN }} prerelease: true diff --git a/.github/workflows/post_release.yml b/.github/workflows/post_release.yml index 31887e179..0ce281c33 100644 --- a/.github/workflows/post_release.yml +++ b/.github/workflows/post_release.yml @@ -27,22 +27,18 @@ jobs: version: ${{ env.VERSION }} steps: - name: Get the release version from the tag - if: env.VERSION == '' run: | if [[ -n "${{ github.event.inputs.tag }}" ]]; then echo "Manual run against a tag; overriding actual tag in the environment..." - echo "VERSION=${{ github.event.inputs.tag }}" >> $GITHUB_ENV + echo "VERSION=${{ github.event.inputs.tag }}" >> "$GITHUB_ENV" else - echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV + echo "VERSION=${{ github.event.release.tag_name }}" >> "$GITHUB_ENV" fi - - name: Test env - run: | - echo ${{ env.VERSION }} - - name: Make sure you're not on master/main/nightly run: | - if [[ ${{ env.VERSION }} == "master" || ${{ env.VERSION }} == "main" || ${{ env.VERSION }} == "nightly" ]]; then + echo ${{ env.VERSION }} + if [[ ${{ env.VERSION }} == "master" || ${{ env.VERSION }} == "main" || ${{ env.VERSION }} == "nightly" ]]; then exit 1 fi @@ -60,13 +56,13 @@ jobs: echo "Release version: ${{ env.RELEASE_VERSION }}" - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 0 - - uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - python-version: 3.11 + python-version: 3.12 - name: Install Python dependencies run: pip install -r docs/requirements.txt @@ -120,7 +116,7 @@ jobs: echo "Release version: ${{ env.RELEASE_VERSION }}" - name: Automatically create PR for winget repos - uses: vedantmgoyal2009/winget-releaser@0db4f0a478166abd0fa438c631849f0b8dcfb99f + uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e with: identifier: Clement.bottom installers-regex: '^bottom_x86_64_installer\.msi$' diff --git a/.github/workflows/test_docs.yml b/.github/workflows/test_docs.yml index c91b63892..60991c8e6 100644 --- a/.github/workflows/test_docs.yml +++ b/.github/workflows/test_docs.yml @@ -1,6 +1,7 @@ # Small CI workflow to test if mkdocs documentation can be successfully built. name: test docs + on: workflow_dispatch: pull_request: @@ -29,13 +30,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 0 - - uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - python-version: 3.11 + python-version: 3.12 - name: Install Python dependencies run: pip install -r docs/requirements.txt diff --git a/.github/workflows/validate_schema.yml b/.github/workflows/validate_schema.yml index 34e9f1973..cf136b6c7 100644 --- a/.github/workflows/validate_schema.yml +++ b/.github/workflows/validate_schema.yml @@ -9,7 +9,10 @@ on: - main paths: - "schema/**" + - "scripts/schema/**" - ".github/workflows/validate_schema.yml" + - "src/bin/schema.rs" + - "Cargo.toml" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -25,7 +28,7 @@ jobs: uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5.3.1 with: skip_after_successful_duplicate: "true" - paths: '["schema/**", ".github/workflows/validate_schema.yml"]' + paths: '["schema/**", "scripts/schema/**", ".github/workflows/validate_schema.yml", "src/bin/schema.rs", "Cargo.toml"]' do_not_skip: '["workflow_dispatch"]' test-build-documentation: @@ -35,13 +38,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 0 - - uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - python-version: 3.11 + python-version: 3.12 - name: Install Python dependencies run: pip install -r scripts/schema/requirements.txt @@ -51,7 +54,6 @@ jobs: python3 scripts/schema/validator.py -s ./schema/nightly/bottom.json -f ./sample_configs/default_config.toml python3 scripts/schema/validator.py --uncomment -s ./schema/nightly/bottom.json -f ./sample_configs/default_config.toml python3 scripts/schema/validator.py -s ./schema/nightly/bottom.json -f ./sample_configs/demo_config.toml - - name: Test nightly catches on a bad sample config run: | diff --git a/.gitignore b/.gitignore index 94e6b7eb3..c26efedc6 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ supply-chain/ # samply profiling profile.json +profile.json.gz **/venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a3358450c..7b36dc806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,63 @@ # Changelog -All notable changes to this project will be documented in this file. +All notable changes to this project will be documented in this file. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Versioning for this project is based on [Semantic Versioning](https://semver.org/spec/v2.0.0.html). More specifically: + +**Pre 1.0.0 (current)**: + +- Patch versions should aim to only contain bug fixes or non-breaking features/changes. +- Minor versions may break things. + +**Post 1.0.0**: + +- Patch versions should only contain bug fixes. +- Minor versions should only contain forward-compatible features/changes. +- Major versions may break things. + +That said, these are more guidelines rather than hardset rules, though the project will generally try to follow them. + +--- + +## [0.11.0] - 2025-08-05 + +### Features + +- [#1625](https://github.com/ClementTsang/bottom/pull/1625): Add the ability to configure the disk widget's table columns. +- [#1641](https://github.com/ClementTsang/bottom/pull/1641): Support AMD GPU data collection on Linux. +- [#1642](https://github.com/ClementTsang/bottom/pull/1642): Support changing the widget borders. +- [#1717](https://github.com/ClementTsang/bottom/pull/1717): Support delete key (fn + delete on macOS) to kill processes. +- [#1306](https://github.com/ClementTsang/bottom/pull/1306): Support using left/right key to collapse/expand process trees respectively. +- [#1767](https://github.com/ClementTsang/bottom/pull/1767): Add a virtual memory column for processes. +- [#1770](https://github.com/ClementTsang/bottom/pull/1770) (originally [#1627](https://github.com/ClementTsang/bottom/pull/1627)): Add option to have process tree entries be collapsed by default. + +### Bug Fixes + +- [#1551](https://github.com/ClementTsang/bottom/pull/1551): Fix missing parent section names in default config. +- [#1552](https://github.com/ClementTsang/bottom/pull/1552): Fix typo in default config. +- [#1578](https://github.com/ClementTsang/bottom/pull/1578): Fix missing selected text background colour in `default-light` theme. +- [#1593](https://github.com/ClementTsang/bottom/pull/1593): Fix using `"none"` for chart legend position in configs. +- [#1594](https://github.com/ClementTsang/bottom/pull/1594): Fix incorrect default config definitions for chart legends. +- [#1596](https://github.com/ClementTsang/bottom/pull/1596): Fix support for nilfs2 file system. +- [#1660](https://github.com/ClementTsang/bottom/pull/1660): Fix properly cleaning up the terminal if the program is terminated due to an `Err` bubbling to the top. +- [#1663](https://github.com/ClementTsang/bottom/pull/1663): Fix network graphs using log scaling having broken lines when a point was 0. +- [#1683](https://github.com/ClementTsang/bottom/pull/1683): Fix graph lines potentially showing up behind legends. +- [#1701](https://github.com/ClementTsang/bottom/pull/1701): Fix process kill dialog occasionally causing panics. +- [#1755](https://github.com/ClementTsang/bottom/pull/1755): Fix missing stats/incorrect mount name for certain entries in the disk widget. +- [#1759](https://github.com/ClementTsang/bottom/pull/1759): Fix increment for data tables if the change is greater than the number of entries left. + +### Changes + +- [#1559](https://github.com/ClementTsang/bottom/pull/1559): Rename `--enable_gpu` to `--disable_gpu`, and make GPU features enabled by default. +- [#1570](https://github.com/ClementTsang/bottom/pull/1570): Consider `$XDG_CONFIG_HOME` on macOS when looking for a default config path in a backwards-compatible fashion. +- [#1686](https://github.com/ClementTsang/bottom/pull/1686): Allow hyphenated arguments to work as well (e.g. `--autohide-time`). +- [#1701](https://github.com/ClementTsang/bottom/pull/1701): Redesign process kill dialog. +- [#1769](https://github.com/ClementTsang/bottom/pull/1769): Change how we calculate swap usage in Windows. + +### Other + +- [#1663](https://github.com/ClementTsang/bottom/pull/1663): Rework how data is stored internally, reducing memory usage a bit. ## [0.10.2] - 2024-08-05 @@ -36,6 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changes +- [#1276](https://github.com/ClementTsang/bottom/pull/1276): NVIDIA GPU functionality is now tied behind the `--enable_gpu` flag. This will likely be changed in the future. - [#1344](https://github.com/ClementTsang/bottom/pull/1344): Change the `group` command line-argument to `group_processes` for consistency with the config file option. - [#1376](https://github.com/ClementTsang/bottom/pull/1376): Group together related command-line arguments in `-h` and `--help`. - [#1411](https://github.com/ClementTsang/bottom/pull/1411): Add `time` as a default column. @@ -60,6 +115,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `--colors` is now `--theme` - [#1513](https://github.com/ClementTsang/bottom/pull/1513): Table headers are now bold by default. - [#1515](https://github.com/ClementTsang/bottom/pull/1515): Show the config path in the error message if unable to read/create a config. +- [#1682](https://github.com/ClementTsang/bottom/pull/1682): On Linux, temperature sensor labels now always have their first letter capitalized (e.g. "k10temp: tctl" -> "k10temp: Tctl"). ### Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index 888ff1e92..8d494c5bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,30 +4,18 @@ version = 3 [[package]] name = "addr2line" -version = "0.22.0" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] [[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - -[[package]] -name = "ahash" -version = "0.8.11" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" @@ -40,15 +28,15 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.18" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anstream" -version = "0.6.15" +version = "0.6.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" dependencies = [ "anstyle", "anstyle-parse", @@ -61,53 +49,55 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.8" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "anstyle-parse" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.1" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.4" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" dependencies = [ "anstyle", - "windows-sys 0.52.0", + "once_cell_polyfill", + "windows-sys 0.59.0", ] [[package]] name = "anyhow" -version = "1.0.86" +version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" [[package]] name = "assert_cmd" -version = "2.0.15" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc65048dd435533bb1baf2ed9956b9a278fbfdcf90301b39ee117f06c0199d37" +checksum = "2bd389a4b2970a01282ee455294913c0a43724daedcd1a24c3eb0ec1c1320b66" dependencies = [ "anstyle", "bstr", "doc-comment", + "libc", "predicates", "predicates-core", "predicates-tree", @@ -116,23 +106,23 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "backtrace" -version = "0.3.73" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ "addr2line", - "cc", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", + "windows-targets 0.52.6", ] [[package]] @@ -149,13 +139,13 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" [[package]] name = "bottom" -version = "0.10.2" +version = "0.11.0" dependencies = [ "anyhow", "assert_cmd", @@ -169,7 +159,7 @@ dependencies = [ "clap_mangen", "concat-string", "core-foundation", - "crossterm", + "crossterm 0.29.0", "ctrlc", "dirs", "fern", @@ -178,7 +168,7 @@ dependencies = [ "humantime", "indexmap", "indoc", - "itertools", + "itertools 0.14.0", "libc", "log", "mach2", @@ -187,27 +177,29 @@ dependencies = [ "predicates", "ratatui", "regex", - "rustix", + "rustix 1.0.7", "schemars", "serde", "serde_json", "starship-battery", - "strum", + "strum 0.27.1", "sysctl", "sysinfo", + "tempfile", "time", + "timeless", "toml_edit", "unicode-ellipsis", "unicode-segmentation", - "unicode-width", - "windows 0.58.0", + "unicode-width 0.2.0", + "windows", ] [[package]] name = "bstr" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ "memchr", "regex-automata", @@ -241,17 +233,11 @@ dependencies = [ "rustversion", ] -[[package]] -name = "cc" -version = "1.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26a5c3fd7bfa1ce3897a3a3501d362b2d87b7f2583ebcb4a949ec25911025cbc" - [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] name = "cfg_aliases" @@ -267,9 +253,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.13" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbb260a053428790f3de475e304ff84cdbc4face759ea7a3e64c1edd938a7fc" +checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" dependencies = [ "clap_builder", "clap_derive", @@ -277,9 +263,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.13" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64b17d7ea74e9f833c7dbf2cbe4fb12ff26783eda4782a8975b72f895c9b4d99" +checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" dependencies = [ "anstream", "anstyle", @@ -290,9 +276,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.12" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8670053e87c316345e384ca1f3eba3006fc6355ed8b8a1140d104e109e3df34" +checksum = "aad5b1b4de04fead402672b48897030eec1f3bfe1550776322f59f6d6e6a5677" dependencies = [ "clap", ] @@ -309,9 +295,9 @@ dependencies = [ [[package]] name = "clap_complete_nushell" -version = "4.5.3" +version = "4.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fe32110e006bccf720f8c9af3fee1ba7db290c724eab61544e1d3295be3a40e" +checksum = "cdb8335b398d197fb3176efe9400c6c053a41733c26794316c73423d212b2f3d" dependencies = [ "clap", "clap_complete", @@ -319,11 +305,11 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.13" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" +checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn", @@ -331,15 +317,15 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.2" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "clap_mangen" -version = "0.2.23" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17415fd4dfbea46e3274fcd8d368284519b358654772afb700dc2e8d2b24eeb" +checksum = "fc33c849748320656a90832f54a5eeecaa598e92557fb5dedebc3355746d31e4" dependencies = [ "clap", "roff", @@ -347,19 +333,20 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "compact_str" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ "castaway", "cfg-if", "itoa", + "rustversion", "ryu", "static_assertions", ] @@ -370,11 +357,20 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7439becb5fafc780b6f4de382b1a7a3e70234afe783854a4702ee8adbb838609" +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" -version = "0.9.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -382,46 +378,39 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "crossbeam-deque" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" +name = "crossterm" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "crossbeam-utils", + "bitflags 2.9.1", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" - [[package]] name = "crossterm" -version = "0.27.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "crossterm_winapi", - "libc", + "derive_more", + "document-features", "mio", "parking_lot", + "rustix 1.0.7", "signal-hook", "signal-hook-mio", "winapi", @@ -438,19 +427,19 @@ dependencies = [ [[package]] name = "ctrlc" -version = "3.4.4" +version = "3.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "672465ae37dc1bc6380a6547a8883d5dd397b0f1faaad4f265726cc7042a5345" +checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73" dependencies = [ - "nix 0.28.0", - "windows-sys 0.52.0", + "nix 0.30.1", + "windows-sys 0.59.0", ] [[package]] name = "darling" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ "darling_core", "darling_macro", @@ -458,9 +447,9 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", @@ -472,9 +461,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", @@ -483,13 +472,34 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ "powerfmt", ] +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "difflib" version = "0.4.0" @@ -498,23 +508,23 @@ checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" [[package]] name = "dirs" -version = "5.0.1" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.48.0", + "windows-sys 0.60.2", ] [[package]] @@ -523,6 +533,15 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -531,23 +550,23 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "dyn-clone" -version = "1.0.17" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" [[package]] name = "either" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "enum-as-inner" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ffccbb6966c05b32ef8fbac435df276c4ae4d3dc55a8cd0eb9745e6c12f546a" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck 0.4.1", + "heck", "proc-macro2", "quote", "syn", @@ -555,45 +574,51 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fern" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" dependencies = [ "log", ] [[package]] name = "filedescriptor" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" dependencies = [ "libc", - "thiserror", + "thiserror 1.0.69", "winapi", ] [[package]] name = "float-cmp" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" dependencies = [ "num-traits", ] @@ -604,11 +629,17 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", @@ -617,26 +648,21 @@ dependencies = [ [[package]] name = "gimli" -version = "0.29.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" dependencies = [ - "ahash", "allocator-api2", + "equivalent", + "foldhash", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -645,9 +671,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "humantime" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" [[package]] name = "ident_case" @@ -657,9 +683,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "indexmap" -version = "2.3.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3fc2e30ba82dd1b3911c8de1ffc143c74a914a14e99514d7637e3099df5ea0" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", "hashbrown", @@ -667,17 +693,21 @@ dependencies = [ [[package]] name = "indoc" -version = "2.0.5" +version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" [[package]] -name = "ioctl-rs" -version = "0.1.6" +name = "instability" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" dependencies = [ - "libc", + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -695,11 +725,20 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "lazy_static" @@ -715,41 +754,53 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" [[package]] name = "libloading" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.53.2", ] [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "libc", ] [[package]] name = "linux-raw-sys" -version = "0.4.14" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ "autocfg", "scopeguard", @@ -757,76 +808,53 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "lru" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37ee39891760e7d94734f6f63fedc29a2e4a152f836120753a72503f09fcf904" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ "hashbrown", ] [[package]] name = "mach2" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ "libc", ] [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" - -[[package]] -name = "memoffset" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" -dependencies = [ - "autocfg", -] +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "miniz_oxide" -version = "0.7.4" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "adler", + "adler2", ] [[package]] name = "mio" -version = "0.8.11" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", "log", "wasi", - "windows-sys 0.48.0", -] - -[[package]] -name = "nix" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" -dependencies = [ - "autocfg", - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset", - "pin-utils", + "windows-sys 0.59.0", ] [[package]] @@ -835,7 +863,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -843,11 +871,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.29.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -894,41 +922,66 @@ dependencies = [ [[package]] name = "nvml-wrapper" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9bff0aa1d48904a1385ea2a8b97576fbdcbc9a3cfccd0d31fe978e1c4038c5" +checksum = "0d5c6c0ef9702176a570f06ad94f3198bc29c524c8b498f1b9346e1b1bdcbb3a" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "libloading", "nvml-wrapper-sys", "static_assertions", - "thiserror", + "thiserror 1.0.69", "wrapcenum-derive", ] [[package]] name = "nvml-wrapper-sys" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "698d45156f28781a4e79652b6ebe2eaa0589057d588d3aec1333f6466f13fcb5" +checksum = "dd23dbe2eb8d8335d2bce0299e0a07d6a63c089243d626ca75b770a962ff49e6" dependencies = [ "libloading", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "object" -version = "0.36.2" +version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f203fa8daa7bb185f760ae12bd8e097f63d17041dcdcaf675ac54cdf863170e" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" [[package]] name = "option-ext" @@ -938,9 +991,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", "parking_lot_core", @@ -948,9 +1001,9 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", @@ -965,17 +1018,11 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "plist" -version = "1.7.0" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016" +checksum = "546b279bf0638ee811d9e47de2ca5b66575a543035d79fdf83959dd2f5c3b4c3" dependencies = [ "base64", "indexmap", @@ -986,9 +1033,9 @@ dependencies = [ [[package]] name = "portable-pty" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" dependencies = [ "anyhow", "bitflags 1.3.2", @@ -997,8 +1044,8 @@ dependencies = [ "lazy_static", "libc", "log", - "nix 0.25.1", - "serial", + "nix 0.28.0", + "serial2", "shared_library", "shell-words", "winapi", @@ -1013,9 +1060,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "predicates" -version = "3.1.2" +version = "3.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e9086cc7640c29a356d1a29fd134380bee9d8f79a17410aa76e7ad295f42c97" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" dependencies = [ "anstyle", "difflib", @@ -1027,15 +1074,15 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae8177bee8e75d6846599c6b9ff679ed51e882816914eec639944d7c9aa11931" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" [[package]] name = "predicates-tree" -version = "1.0.11" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b740d195ed3166cd147c8047ec98db0e22ec019eb8eeb76d343b795304fb13" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" dependencies = [ "predicates-core", "termtree", @@ -1043,97 +1090,97 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] [[package]] name = "quick-xml" -version = "0.32.0" +version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] [[package]] name = "ratatui" -version = "0.27.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16546c5b5962abf8ce6e2881e722b4e0ae3b6f1a08a26ae3573c55853ca68d3" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "cassowary", "compact_str", - "crossterm", - "itertools", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", "lru", "paste", - "stability", - "strum", - "strum_macros", + "strum 0.26.3", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] -name = "rayon" -version = "1.10.0" +name = "redox_syscall" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" dependencies = [ - "either", - "rayon-core", + "bitflags 2.9.1", ] [[package]] -name = "rayon-core" -version = "1.12.1" +name = "redox_users" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ - "crossbeam-deque", - "crossbeam-utils", + "getrandom", + "libredox", + "thiserror 2.0.12", ] [[package]] -name = "redox_syscall" -version = "0.5.3" +name = "ref-cast" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" dependencies = [ - "bitflags 2.6.0", + "ref-cast-impl", ] [[package]] -name = "redox_users" -version = "0.4.5" +name = "ref-cast-impl" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ - "getrandom", - "libredox", - "thiserror", + "proc-macro2", + "quote", + "syn", ] [[package]] name = "regex" -version = "1.10.5" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -1143,9 +1190,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -1154,9 +1201,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "roff" @@ -1166,34 +1213,47 @@ checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" [[package]] name = "rustix" -version = "0.38.34" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "errno", "libc", - "linux-raw-sys", - "windows-sys 0.52.0", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys 0.9.4", + "windows-sys 0.59.0", ] [[package]] name = "rustversion" -version = "1.0.17" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "same-file" @@ -1206,11 +1266,12 @@ dependencies = [ [[package]] name = "schemars" -version = "0.8.21" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" dependencies = [ "dyn-clone", + "ref-cast", "schemars_derive", "serde", "serde_json", @@ -1218,9 +1279,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.21" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" +checksum = "5016d94c77c6d32f0b8e08b781f7dc8a90c2007d4e77472cc2807bc10a8438fe" dependencies = [ "proc-macro2", "quote", @@ -1236,18 +1297,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.204" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.204" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -1267,9 +1328,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.121" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ab380d7d9f22ef3f21ad3e6c1ebe8e4fc7a2000ccba2e4d71fc96f15b2cb609" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", "memchr", @@ -1279,53 +1340,22 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.7" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] [[package]] -name = "serial" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" -dependencies = [ - "serial-core", - "serial-unix", - "serial-windows", -] - -[[package]] -name = "serial-core" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" -dependencies = [ - "libc", -] - -[[package]] -name = "serial-unix" -version = "0.4.0" +name = "serial2" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" -dependencies = [ - "ioctl-rs", - "libc", - "serial-core", - "termios", -] - -[[package]] -name = "serial-windows" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +checksum = "c7d1d08630509d69f90eff4afcd02c3bd974d979225cbd815ff5942351b14375" dependencies = [ + "cfg-if", "libc", - "serial-core", + "winapi", ] [[package]] @@ -1346,9 +1376,9 @@ checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" [[package]] name = "signal-hook" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" dependencies = [ "libc", "signal-hook-registry", @@ -1367,45 +1397,35 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" dependencies = [ "libc", ] [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" - -[[package]] -name = "stability" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" -dependencies = [ - "quote", - "syn", -] +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "starship-battery" -version = "0.9.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7915746794358b8f649d3032c8ce150f55b7a0cd41951f170162e82e6cf43f" +checksum = "5b781e3db6801873ce18c19041da668920c46223cdc7059ea20e2f8fe1ba85a2" dependencies = [ "cfg-if", "core-foundation", "lazycell", "libc", "mach2", - "nix 0.29.0", + "nix 0.30.1", "num-traits", "plist", "uom", - "winapi", + "windows-sys 0.60.2", ] [[package]] @@ -1426,7 +1446,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +dependencies = [ + "strum_macros 0.27.1", ] [[package]] @@ -1435,7 +1464,20 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.5.0", + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "strum_macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +dependencies = [ + "heck", "proc-macro2", "quote", "rustversion", @@ -1444,9 +1486,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.72" +version = "2.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" dependencies = [ "proc-macro2", "quote", @@ -1455,72 +1497,94 @@ dependencies = [ [[package]] name = "sysctl" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7dddc5f0fee506baf8b9fdb989e242f17e4b11c61dfbb0635b705217199eea" +checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "byteorder", "enum-as-inner", "libc", - "thiserror", + "thiserror 1.0.69", "walkdir", ] [[package]] name = "sysinfo" -version = "0.30.13" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" +checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" dependencies = [ - "cfg-if", - "core-foundation-sys", "libc", + "memchr", "ntapi", - "once_cell", - "rayon", - "windows 0.52.0", + "objc2-core-foundation", + "objc2-io-kit", + "windows", ] [[package]] -name = "terminal_size" -version = "0.3.0" +name = "tempfile" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ - "rustix", - "windows-sys 0.48.0", + "fastrand", + "once_cell", + "rustix 1.0.7", + "windows-sys 0.59.0", ] [[package]] -name = "termios" -version = "0.2.2" +name = "terminal_size" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" dependencies = [ - "libc", + "rustix 1.0.7", + "windows-sys 0.59.0", ] [[package]] name = "termtree" -version = "0.4.1" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] [[package]] name = "thiserror" -version = "1.0.63" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.12", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", @@ -1529,9 +1593,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" dependencies = [ "deranged", "itoa", @@ -1546,69 +1610,82 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" dependencies = [ "num-conv", "time-core", ] +[[package]] +name = "timeless" +version = "0.0.14-alpha" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04121e3f47427f2604066a4c4af25102e6c5794b167f6dee85958898ebf7f131" + [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.20" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", "serde_spanned", "toml_datetime", + "toml_write", "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "typenum" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "unicode-ellipsis" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ab5b8c3fed9966b8cde2dc7169146331cba3dacba97cbd0e8866e7cfd4dff" +checksum = "34ed7a61d66ae6471dc2fa895bc9c30c3351760c95e8c7afeb978acab3ccf04b" dependencies = [ "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-segmentation" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" @@ -1616,22 +1693,28 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] name = "unicode-width" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "uom" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd36e5350a65d112584053ee91843955826bf9e56ec0d1351214e01f6d7cd9c" +checksum = "cd5cfe7d84f6774726717f358a37f5bca8fca273bed4de40604ad129d1107b49" dependencies = [ "num-traits", "typenum", @@ -1643,17 +1726,11 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wait-timeout" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" dependencies = [ "libc", ] @@ -1670,9 +1747,9 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "winapi" @@ -1692,11 +1769,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1707,51 +1784,55 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.52.0" +version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-core 0.52.0", - "windows-targets 0.52.6", + "windows-collections", + "windows-core", + "windows-future", + "windows-link", + "windows-numerics", ] [[package]] -name = "windows" -version = "0.58.0" +name = "windows-collections" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", + "windows-core", ] [[package]] name = "windows-core" -version = "0.52.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-targets 0.52.6", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "windows-core" -version = "0.58.0" +name = "windows-future" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-implement", - "windows-interface", - "windows-result", - "windows-strings", - "windows-targets 0.52.6", + "windows-core", + "windows-link", + "windows-threading", ] [[package]] name = "windows-implement" -version = "0.58.0" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", @@ -1760,9 +1841,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.58.0" +version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", @@ -1770,55 +1851,55 @@ dependencies = [ ] [[package]] -name = "windows-result" +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-numerics" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-targets 0.52.6", + "windows-core", + "windows-link", ] [[package]] -name = "windows-strings" -version = "0.1.0" +name = "windows-result" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-result", - "windows-targets 0.52.6", + "windows-link", ] [[package]] -name = "windows-sys" -version = "0.48.0" +name = "windows-strings" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-targets 0.48.5", + "windows-link", ] [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] [[package]] -name = "windows-targets" -version = "0.48.5" +name = "windows-sys" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "windows-targets 0.53.2", ] [[package]] @@ -1830,7 +1911,7 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", @@ -1838,10 +1919,29 @@ dependencies = [ ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" +name = "windows-targets" +version = "0.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link", +] [[package]] name = "windows_aarch64_gnullvm" @@ -1850,10 +1950,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" +name = "windows_aarch64_gnullvm" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" [[package]] name = "windows_aarch64_msvc" @@ -1862,10 +1962,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "windows_i686_gnu" -version = "0.48.5" +name = "windows_aarch64_msvc" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" [[package]] name = "windows_i686_gnu" @@ -1873,6 +1973,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" @@ -1880,10 +1986,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "windows_i686_msvc" -version = "0.48.5" +name = "windows_i686_gnullvm" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" [[package]] name = "windows_i686_msvc" @@ -1892,10 +1998,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" +name = "windows_i686_msvc" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" [[package]] name = "windows_x86_64_gnu" @@ -1904,10 +2010,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" +name = "windows_x86_64_gnu" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" [[package]] name = "windows_x86_64_gnullvm" @@ -1916,10 +2022,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" +name = "windows_x86_64_gnullvm" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" [[package]] name = "windows_x86_64_msvc" @@ -1927,11 +2033,17 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "winnow" -version = "0.6.18" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" +checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" dependencies = [ "memchr", ] @@ -1956,23 +2068,3 @@ dependencies = [ "quote", "syn", ] - -[[package]] -name = "zerocopy" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/Cargo.toml b/Cargo.toml index d25744440..1e6e19530 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,20 @@ [package] name = "bottom" -version = "0.10.2" -authors = ["Clement Tsang "] -edition = "2021" +version = "0.11.0" repository = "https://github.com/ClementTsang/bottom" -keywords = ["cross-platform", "monitoring", "cli", "top", "tui"] license = "MIT" -categories = ["command-line-utilities", "visualization"] description = "A customizable cross-platform graphical process/system monitor for the terminal. Supports Linux, macOS, and Windows." documentation = "https://clementtsang.github.io/bottom/stable" readme = "README.md" default-run = "btm" build = "build.rs" +authors = ["Clement Tsang "] +keywords = ["cross-platform", "monitoring", "cli", "top", "tui"] +categories = ["command-line-utilities", "visualization"] exclude = [ ".cargo-husky/", ".github/", - ".idea", + ".idea/", ".vscode/", "assets/", "desktop/", @@ -37,37 +36,30 @@ exclude = [ "rust-toolchain.toml", "rustfmt.toml", ] +edition = "2021" # The oldest version I've tested that should still build - note this is not an official MSRV! -rust-version = "1.74.0" +rust-version = "1.81" -[[bin]] -name = "btm" -path = "src/main.rs" +[lib] test = true doctest = true doc = true -[profile.dev.package."*"] -# Compile dependencies with optimizations on even in debug mode. -opt-level = 3 - -[profile.no-opt] -inherits = "dev" -opt-level = 0 - -[profile.release] -debug = 0 -strip = "symbols" -lto = true -opt-level = 3 -codegen-units = 1 +[[bin]] +name = "btm" +path = "src/bin/main.rs" +doc = false -[profile.profiling] -inherits = "release" -debug = true -strip = false +[[bin]] +name = "schema" +path = "src/bin/schema.rs" +test = false +doctest = false +doc = false +required-features = ["generate_schema"] [features] +# Used for general builds. battery = ["starship-battery"] nvidia = ["nvml-wrapper"] gpu = ["nvidia"] @@ -75,56 +67,71 @@ zfs = [] deploy = ["battery", "gpu", "zfs"] default = ["deploy"] +# Should not be included in builds. logging = ["fern", "log", "time"] generate_schema = ["schemars", "serde_json", "strum"] [dependencies] -anyhow = "1.0.86" -backtrace = "0.3.73" -cfg-if = "1.0.0" -clap = { version = "4.5.13", features = ["default", "cargo", "wrap_help", "derive"] } +anyhow = "1.0.98" +backtrace = "0.3.75" +cfg-if = "1.0.1" +clap = { version = "4.5.40", features = [ + "default", + "cargo", + "wrap_help", + "derive", +] } concat-string = "1.0.1" -crossterm = "0.27.0" -ctrlc = { version = "3.4.4", features = ["termination"] } -dirs = "5.0.1" -hashbrown = "0.14.5" -humantime = "2.1.0" -indexmap = "2.2.6" -indoc = "2.0.5" -itertools = "0.13.0" -nvml-wrapper = { version = "0.10.0", optional = true, features = ["legacy-functions"] } -regex = "1.10.5" -serde = { version = "1.0.204", features = ["derive"] } -starship-battery = { version = "0.9.1", optional = true } -sysinfo = "=0.30.13" -toml_edit = { version = "0.22.17", features = ["serde"] } -tui = { version = "0.27.0", package = "ratatui" } -unicode-ellipsis = "0.2.0" -unicode-segmentation = "1.11.0" -unicode-width = "0.1.13" - -# Used for logging. -fern = { version = "0.6.2", optional = true } -log = { version = "0.4.22", optional = true } -time = { version = "0.3.36", features = ["local-offset", "formatting", "macros"], optional = true } +crossterm = "0.29.0" +ctrlc = { version = "3.4.7", features = ["termination"] } +dirs = "6.0.0" +hashbrown = "0.15.4" +humantime = "2.2.0" +indexmap = "2.10.0" +indoc = "2.0.6" +itertools = "0.14.0" +nvml-wrapper = { version = "0.11.0", optional = true, features = [ + "legacy-functions", +] } +regex = "1.11.1" +serde = { version = "1.0.219", features = ["derive"] } +starship-battery = { version = "0.10.2", optional = true } +sysinfo = "=0.36.1" +timeless = "0.0.14-alpha" +toml_edit = { version = "0.22.27", features = ["serde"] } +tui = { version = "0.29.0", package = "ratatui", features = [ + "unstable-rendered-line-info", +] } +unicode-ellipsis = "0.3.0" +unicode-segmentation = "1.12.0" +unicode-width = "0.2.0" + +# Used for logging. Mostly a debugging tool. +fern = { version = "0.7.1", optional = true } +log = { version = "0.4.27", optional = true } +time = { version = "0.3.41", features = [ + "local-offset", + "formatting", + "macros", +], optional = true } # These are just used for JSON schema generation. -schemars = { version = "0.8.21", optional = true } -serde_json = { version = "1.0.120", optional = true } -strum = { version = "0.26.3", features = ["derive"], optional = true } +schemars = { version = "0.9.0", optional = true } +serde_json = { version = "1.0.140", optional = true } +strum = { version = "0.27.1", features = ["derive"], optional = true } [target.'cfg(unix)'.dependencies] -libc = "0.2.155" +libc = "0.2.174" [target.'cfg(target_os = "linux")'.dependencies] -rustix = { version = "0.38.34", features = ["fs", "param"] } +rustix = { version = "1.0.7", features = ["fs", "param"] } [target.'cfg(target_os = "macos")'.dependencies] -core-foundation = "0.9.4" -mach2 = "0.4.2" +core-foundation = "0.10.1" +mach2 = "0.4.3" [target.'cfg(target_os = "windows")'.dependencies] -windows = { version = "0.58.0", features = [ +windows = { version = "0.61.3", features = [ "Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", @@ -135,25 +142,49 @@ windows = { version = "0.58.0", features = [ ] } [target.'cfg(target_os = "freebsd")'.dependencies] -serde_json = { version = "1.0.120" } -sysctl = { version = "0.5.5" } -filedescriptor = "0.8.2" +serde_json = { version = "1.0.140" } +sysctl = { version = "0.6.0" } +filedescriptor = "0.8.3" [dev-dependencies] -assert_cmd = "2.0.15" -cargo-husky = { version = "1.5.0", default-features = false, features = ["user-hooks"] } -predicates = "3.1.0" +assert_cmd = "2.0.17" +cargo-husky = { version = "1.5.0", default-features = false, features = [ + "user-hooks", +] } +predicates = "3.1.3" +tempfile = { version = "3.20.0", default-features = false } [target.'cfg(all(target_arch = "x86_64", target_os = "linux"))'.dev-dependencies] -portable-pty = "0.8.1" +portable-pty = "0.9.0" [build-dependencies] -clap = { version = "4.5.13", features = ["default", "cargo", "wrap_help", "derive"] } -clap_complete = "4.5.12" -clap_complete_nushell = "4.5.3" +clap = { version = "4.5.40", features = [ + "default", + "cargo", + "wrap_help", + "derive", +] } +clap_complete = "4.5.54" +clap_complete_nushell = "4.5.7" clap_complete_fig = "4.5.2" -clap_mangen = "0.2.23" -indoc = "2.0.5" +clap_mangen = "0.2.27" +indoc = "2.0.6" + +# Compile dependencies with optimizations enabled, even in debug mode. +[profile.dev.package."*"] +opt-level = 3 + +[profile.release] +debug = 0 +strip = "symbols" +lto = true +opt-level = 3 +codegen-units = 1 + +[profile.profiling] +inherits = "release" +debug = true +strip = false [package.metadata.deb] section = "utility" diff --git a/README.md b/README.md index 4f61831b9..8ab595dea 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@

bottom (btm)

- A customizable cross-platform graphical process/system monitor for the terminal.
Supports Linux, macOS, and Windows. Inspired by gtop, gotop, and htop. + A customizable cross-platform graphical process/system monitor for the terminal.
Supports Linux, macOS, and Windows. Inspired by gtop, gotop, and htop.

-[CI status](https://github.com/ClementTsang/bottom/actions?query=branch%main) +[CI status](https://github.com/ClementTsang/bottom/actions?query=branch%3Amain) [crates.io link](https://crates.io/crates/bottom) [Stable documentation](https://clementtsang.github.io/bottom/stable) [Nightly documentation](https://clementtsang.github.io/bottom/nightly) @@ -16,7 +16,7 @@ Quick demo recording showing off bottom's searching, expanding, and process killing.

- Demo using the Gruvbox theme (--color gruvbox), along with IBM Plex Mono and Kitty + Demo using the Gruvbox theme (--theme gruvbox), along with IBM Plex Mono and Kitty

@@ -29,12 +29,14 @@ - [Unofficial](#unofficial) - [Installation](#installation) - [Cargo](#cargo) + - [Alpine](#alpine) - [Arch Linux](#arch-linux) - [Debian / Ubuntu](#debian--ubuntu) - [Exherbo Linux](#exherbo-linux) - [Fedora / CentOS / AlmaLinux / Rocky Linux](#fedora--centos--almalinux--rocky-linux) - [Gentoo](#gentoo) - [Nix](#nix) + - [openSUSE](#opensuse) - [Snap](#snap) - [Solus](#solus) - [Void](#void) @@ -44,6 +46,7 @@ - [Scoop](#scoop) - [winget](#winget) - [Windows installer](#windows-installer) + - [Conda](#conda) - [Pre-built binaries](#pre-built-binaries) - [Auto-completion](#auto-completion) - [Usage](#usage) @@ -85,7 +88,7 @@ As (yet another) process/system visualization and management application, bottom - Changing the layout of widgets - Filtering out entries in some widgets -- Some other nice stuff, like: +- And more: - [An htop-inspired basic mode](https://clementtsang.github.io/bottom/nightly/usage/basic-mode/) - [Expansion, which focuses on just one widget](https://clementtsang.github.io/bottom/nightly/usage/general-usage/#expansion) @@ -119,7 +122,7 @@ bottom may work on a number of platforms that aren't officially supported. Note Note that some unsupported platforms may eventually be officially supported (e.g., FreeBSD). -A non-comprehensive list of some currently unofficially supported platforms that may compile/work include: +A non-comprehensive list of some currently unofficially-supported platforms that may compile/work include: - FreeBSD (`x86_64`) - Linux (`armv6`, `armv7`, `powerpc64le`, `riscv64gc`) @@ -151,7 +154,7 @@ cargo +stable install bottom --locked cargo install bottom ``` -Alternatively, if you can use `cargo install` using the repo as the source. +Alternatively, you can use `cargo install` using the repo as the source. ```bash # You might need to update the stable version of Rust first. @@ -159,8 +162,8 @@ Alternatively, if you can use `cargo install` using the repo as the source. rustup update stable # Option 1 - Download an archive from releases and install -curl -LO https://github.com/ClementTsang/bottom/archive/0.10.2.tar.gz -tar -xzvf 0.10.2.tar.gz +curl -LO https://github.com/ClementTsang/bottom/archive/0.11.0.tar.gz +tar -xzvf 0.11.0.tar.gz cargo install --path . --locked # Option 2 - Manually clone the repo and install @@ -171,11 +174,21 @@ cargo install --path . --locked # Option 3 - Install using cargo with the repo as the source cargo install --git https://github.com/ClementTsang/bottom --locked -# You can also pass in the target-cpu=native flag for -# better CPU-specific optimizations. For example: +# You can also pass in the target-cpu=native flag to try to +# use better CPU-specific optimizations. For example: RUSTFLAGS="-C target-cpu=native" cargo install --path . --locked ``` +### Alpine + +bottom is available as a [package](https://pkgs.alpinelinux.org/packages?name=bottom&branch=edge&repo=&arch=&origin=&flagged=&maintainer=) for Alpine Linux via `apk`: + +```bash +apk add bottom +``` + +Packages for documentation ([`bottom-doc`](https://pkgs.alpinelinux.org/packages?name=bottom-doc&branch=edge&repo=&arch=&origin=&flagged=&maintainer=)) and completions for Bash ([`bottom-bash-completion`](https://pkgs.alpinelinux.org/packages?name=bottom-bash-completion&branch=edge&repo=&arch=&origin=&flagged=&maintainer=)), Fish ([`bottom-fish-completion`](https://pkgs.alpinelinux.org/packages?name=bottom-fish-completion&branch=edge&repo=&arch=&origin=&flagged=&maintainer=)), and Zsh ([`bottom-zsh-completion`](https://pkgs.alpinelinux.org/packages?name=bottom-zsh-completion&branch=edge&repo=&arch=&origin=&flagged=&maintainer=)) are also available. + ### Arch Linux bottom is available as an [official package](https://archlinux.org/packages/extra/x86_64/bottom/) that can be installed with `pacman`: @@ -184,31 +197,38 @@ bottom is available as an [official package](https://archlinux.org/packages/extr sudo pacman -S bottom ``` -If you want the latest changes that are not yet stable, you can also install `bottom-git` [from the AUR](https://aur.archlinux.org/packages/bottom-git). -For example, to install with `paru`: +If you want the latest changes that are not yet stable, you can also install `bottom-git` [from the AUR](https://aur.archlinux.org/packages/bottom-git): ```bash -sudo paru -S bottom-git +# Using paru +paru -S bottom-git + +# Using yay +yay -S bottom-git ``` ### Debian / Ubuntu A `.deb` file is provided on each [stable release](https://github.com/ClementTsang/bottom/releases/latest) and -[nightly builds](https://github.com/ClementTsang/bottom/releases/tag/nightly) for x86, aarch64, and armv7 -(note stable ARM builds are only available for 0.6.8 and later). An example of installing this way: +[nightly builds](https://github.com/ClementTsang/bottom/releases/tag/nightly) for x86, aarch64, and armv7. +Some examples of installing it this way: ```bash # x86-64 -curl -LO https://github.com/ClementTsang/bottom/releases/download/0.10.2/bottom_0.10.2_amd64.deb -sudo dpkg -i bottom_0.10.2_amd64.deb +curl -LO https://github.com/ClementTsang/bottom/releases/download/0.11.0/bottom_0.11.0-1_amd64.deb +sudo dpkg -i bottom_0.11.0-1_amd64.deb # ARM64 -curl -LO https://github.com/ClementTsang/bottom/releases/download/0.10.2/bottom_0.10.2_arm64.deb -sudo dpkg -i bottom_0.10.2_arm64.deb +curl -LO https://github.com/ClementTsang/bottom/releases/download/0.11.0/bottom_0.11.0-1_arm64.deb +sudo dpkg -i bottom_0.11.0-1_arm64.deb # ARM -curl -LO https://github.com/ClementTsang/bottom/releases/download/0.10.2/bottom_0.10.2_armhf.deb -sudo dpkg -i bottom_0.10.2_armhf.deb +curl -LO https://github.com/ClementTsang/bottom/releases/download/0.11.0/bottom_0.11.0-1_armhf.deb +sudo dpkg -i bottom_0.11.0-1_armhf.deb + +# musl-based +curl -LO https://github.com/ClementTsang/bottom/releases/download/0.11.0/bottom-musl_0.11.0-1_amd64.deb +sudo dpkg -i bottom-musl_0.11.0-1_amd64.deb ``` ### Exherbo Linux @@ -222,19 +242,26 @@ cave resolve -x bottom ### Fedora / CentOS / AlmaLinux / Rocky Linux -bottom is available in [COPR](https://copr.fedorainfracloud.org/coprs/atim/bottom/): +bottom is available on [COPR](https://copr.fedorainfracloud.org/coprs/atim/bottom/): ```bash sudo dnf copr enable atim/bottom -y sudo dnf install bottom ``` +bottom is also available via [Terra](https://terra.fyralabs.com/): + +```bash +sudo dnf install --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' --setopt='terra.gpgkey=https://repos.fyralabs.com/terra$releasever/key.asc' terra-release +sudo dnf install bottom +``` + `.rpm` files are also generated for x86 in the [releases](https://github.com/ClementTsang/bottom/releases) page. For example: ```bash -curl -LO https://github.com/ClementTsang/bottom/releases/download/0.10.2/bottom-0.10.2-1.x86_64.rpm -sudo rpm -i bottom-0.10.2-1.x86_64.rpm +curl -LO https://github.com/ClementTsang/bottom/releases/download/0.11.0/bottom-0.11.0-1.x86_64.rpm +sudo rpm -i bottom-0.11.0-1.x86_64.rpm ``` ### Gentoo @@ -247,10 +274,26 @@ sudo emerge --ask sys-process/bottom ### Nix -Available [in the nix-community repo](https://github.com/nix-community/home-manager/blob/master/modules/programs/bottom.nix): +Available [in Nixpkgs](https://search.nixos.org/packages?channel=unstable&show=bottom&from=0&size=1&sort=relevance&type=packages) as `bottom`: + +```bash +nix profile install nixpkgs#bottom +``` + +`bottom` can also be installed and configured through the [home-manager](https://nix-community.github.io/home-manager) module: + +```nix +{ + programs.bottom.enable = true; +} +``` + +### openSUSE + +Available in openSUSE Tumbleweed: ```bash -nix-env -i bottom +zypper in bottom ``` ### Snap @@ -334,6 +377,19 @@ You can uninstall via Control Panel, Options, or `winget --uninstall bottom`. You can also manually install bottom as a Windows program by going to the [latest release](https://github.com/ClementTsang/bottom/releases/latest) and installing via the `.msi` file. +### Conda + +You can install bottom using `conda` with [this conda-smithy repository](https://github.com/conda-forge/bottom-feedstock): + +```bash +# Add the channel +conda config --add channels conda-forge +conda config --set channel_priority strict + +# Install +conda install bottom +``` + ### Pre-built binaries You can also use the pre-built release binaries: @@ -351,37 +407,40 @@ or by installing to your system following the procedures for installing binaries #### Auto-completion -The release binaries are packaged with shell auto-completion files for bash, fish, zsh, and Powershell. To install them: +The release binaries in [the releases page](https://github.com/ClementTsang/bottom/releases) are packaged with +shell auto-completion files for Bash, Zsh, fish, Powershell, Elvish, Fig, and Nushell. To install them: -- For bash, move `btm.bash` to `$XDG_CONFIG_HOME/bash_completion or /etc/bash_completion.d/`. +- For Bash, move `btm.bash` to `$XDG_CONFIG_HOME/bash_completion or /etc/bash_completion.d/`. +- For Zsh, move `_btm` to one of your `$fpath` directories. - For fish, move `btm.fish` to `$HOME/.config/fish/completions/`. -- For zsh, move `_btm` to one of your `$fpath` directories. -- For PowerShell, add `_btm.ps1` to your PowerShell - [profile](). +- For PowerShell, add `_btm.ps1` to your PowerShell [profile](). +- For Elvish, the completion file is `btm.elv`. +- For Fig, the completion file is `btm.ts`. +- For Nushell, source `btm.nu`. -The individual auto-completion files are also included in the stable/nightly releases as `completion.tar.gz`. +The individual auto-completion files are also included in the stable/nightly releases as `completion.tar.gz` if needed. ## Usage You can run bottom using `btm`. - For help on flags, use `btm -h` for a quick overview or `btm --help` for more details. -- For info on key and mouse bindings, press `?` inside bottom or refer to the [documentation](https://clementtsang.github.io/bottom/nightly/). +- For info on key and mouse bindings, press `?` inside bottom or refer to the [documentation page](https://clementtsang.github.io/bottom/nightly/). You can find more information on usage in the [documentation](https://clementtsang.github.io/bottom/nightly/). ## Configuration -bottom accepts a number of command-line arguments to change the behaviour of the application as desired. Additionally, bottom will automatically -generate a configuration file on the first launch, which one can change as appropriate. +bottom accepts a number of command-line arguments to change the behaviour of the application as desired. +Additionally, bottom will automatically generate a configuration file on the first launch, which can be changed. More details on configuration can be found [in the documentation](https://clementtsang.github.io/bottom/nightly/configuration/config-file/). ## Troubleshooting -If some things aren't working, give the [troubleshooting page](https://clementtsang.github.io/bottom/nightly/troubleshooting) a look. -If things still aren't working, then consider opening [a question](https://github.com/ClementTsang/bottom/discussions) -or filing a [bug report](https://github.com/ClementTsang/bottom/issues/new/choose). +If some things aren't working, give the [troubleshooting page](https://clementtsang.github.io/bottom/nightly/troubleshooting) +a look. If things still aren't working, then consider asking [a question](https://github.com/ClementTsang/bottom/discussions) +or filing a [bug report](https://github.com/ClementTsang/bottom/issues/new/choose) if you think it's a bug. ## Contribution @@ -413,59 +472,78 @@ Thanks to all contributors: Erlend Hamberg
Erlend Hamberg

💻 Frederick Zhang
Frederick Zhang

💻 pvanheus
pvanheus

💻 - Zeb Piasecki
Zeb Piasecki

💻 Brian Di Palma
Brian Di Palma

📖 + Lasha Kanteladze
Lasha Kanteladze

📖 - Lasha Kanteladze
Lasha Kanteladze

📖 Herby Gillot
Herby Gillot

📖 Greg Brown
Greg Brown

💻 TotalCaesar659
TotalCaesar659

📖 George Rawlinson
George Rawlinson

📖 📦 adiabatic
adiabatic

📖 Randy Barlow
Randy Barlow

💻 + Patrick Jackson
Patrick Jackson

🤔 📖 - Patrick Jackson
Patrick Jackson

🤔 📖 Mateusz Mikuła
Mateusz Mikuła

💻 Guillaume Gomez
Guillaume Gomez

💻 shura
shura

💻 Wesley Moore
Wesley Moore

💻 xgdgsc
xgdgsc

📖 ViridiCanis
ViridiCanis

💻 + Justin Martin
Justin Martin

💻 📖 - Justin Martin
Justin Martin

💻 📖 Diana
Diana

💻 Hervy Qurrotul Ainur Rozi
Hervy Qurrotul Ainur Rozi

📖 Mike Rivnak
Mike Rivnak

📖 lroobrou
lroobrou

💻 database64128
database64128

💻 Chon Sou
Chon Sou

💻 + DrSheppard
DrSheppard

📖 - DrSheppard
DrSheppard

📖 Rareș Constantin
Rareș Constantin

💻 felipesuri
felipesuri

📖 spital
spital

💻 Michael Bikovitsky
Michael Bikovitsky

💻 Dmitry Valter
Dmitry Valter

💻 - Twan Stok
Twan Stok

💻 + Grace Stok
Grace Stok

💻 + Yuxuan Shui
Yuxuan Shui

💻 - Yuxuan Shui
Yuxuan Shui

💻 Wenqing Zong
Wenqing Zong

💻 Gabriele Belluardo
Gabriele Belluardo

💻 Zeb Piasecki
Zeb Piasecki

💻 wzy
wzy

💻 📖 john-s-lin
john-s-lin

📖 Lee Wonjoon
Lee Wonjoon

💻 📖 + David Legrand
David Legrand

📖 - David Legrand
David Legrand

📖 Michal Bryxí
Michal Bryxí

📖 Raphael Erik Hviding
Raphael Erik Hviding

💻 CosmicHorror
CosmicHorror

💻 + Ben Woods
Ben Woods

📖 + Stephen Huan
Stephen Huan

💻 + Jason Gwartz
Jason Gwartz

📖 + llc0930
llc0930

💻 + + + Ada Ahmed
Ada Ahmed

💻 + Wateir
Wateir

📖 + Andrey Alekseenko
Andrey Alekseenko

💻 + Fotis Gimian
Fotis Gimian

💻 📖 + Fernando Rodrigues
Fernando Rodrigues

📖 + Matthew Toohey
Matthew Toohey

💻 + Julius Enriquez
Julius Enriquez

📖 + + + Ben Brown
Ben Brown

💻 + Yuri Astrakhan
Yuri Astrakhan

💻 📖 + Kenichi Kamiya
Kenichi Kamiya

💻 + yahlia
yahlia

💻 + Bucket-Bucket-Bucket
Bucket-Bucket-Bucket

💻 @@ -480,8 +558,21 @@ Thanks to all contributors: - This project is very much inspired by [gotop](https://github.com/xxxserxxx/gotop), [gtop](https://github.com/aksakalli/gtop), and [htop](https://github.com/htop-dev/htop/). -- This application was written with many, _many_ libraries, and built on the - work of many talented people. This application would be impossible without their - work. I used to thank them all individually but the list got too large... +- This application was written with [many](https://github.com/ClementTsang/bottom/blob/main/Cargo.toml), + [_many_ libraries](https://github.com/ClementTsang/bottom/blob/main/Cargo.lock), as well as many services and + programs, all built on top of the work of many talented people. bottom would not exist without all of this. + +- And of course, thank you again to all contributors and package maintainers! + +- I also really appreciate anyone who has used bottom, and those + who go out of their way to report bugs or suggest ways to improve things. I hope + it's been a useful tool for others. + +- To those who support my work financially via donations, thank you so much. + +- Also thanks to JetBrains for providing access to tools that I use to develop bottom + as part of their [open source support program](https://jb.gg/OpenSourceSupport). -- And of course, another round of thanks to all the contributors and package maintainers! + + JetBrains logo + diff --git a/build.rs b/build.rs index df8203967..efae0a0e4 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,6 @@ -#[allow(dead_code)] +//! General build script used by bottom to generate completion files and set binary version. + +#[expect(dead_code)] #[path = "src/options/args.rs"] mod args; @@ -8,22 +10,20 @@ use std::{ }; use clap::{Command, CommandFactory}; -use clap_complete::{generate_to, shells::Shell, Generator}; +use clap_complete::{Generator, generate_to, shells::Shell}; use clap_complete_fig::Fig; use clap_complete_nushell::Nushell; use crate::args::BottomArgs; fn create_dir(dir: &Path) -> io::Result<()> { - let res = fs::create_dir_all(dir); - match &res { - Ok(()) => {} - Err(err) => { - eprintln!("Failed to create a directory at location {dir:?}, encountered error {err:?}. Aborting...",); - } - } - - res + fs::create_dir_all(dir).inspect_err(|err| { + eprintln!( + "Couldn't create a directory at {} ({:?}). Aborting.", + dir.display(), + err + ) + }) } fn generate_completions(to_generate: G, cmd: &mut Command, out_dir: &Path) -> io::Result @@ -38,11 +38,12 @@ fn btm_generate() -> io::Result<()> { match env::var_os(ENV_KEY) { Some(var) if !var.is_empty() => { - const COMPLETION_DIR: &str = "./target/tmp/bottom/completion/"; - const MANPAGE_DIR: &str = "./target/tmp/bottom/manpage/"; + let completion_dir = + option_env!("COMPLETION_DIR").unwrap_or("./target/tmp/bottom/completion/"); + let manpage_dir = option_env!("MANPAGE_DIR").unwrap_or("./target/tmp/bottom/manpage/"); - let completion_out_dir = PathBuf::from(COMPLETION_DIR); - let manpage_out_dir = PathBuf::from(MANPAGE_DIR); + let completion_out_dir = PathBuf::from(completion_dir); + let manpage_out_dir = PathBuf::from(manpage_dir); create_dir(&completion_out_dir)?; create_dir(&manpage_out_dir)?; diff --git a/desktop/bottom.desktop b/desktop/bottom.desktop index b9ff46745..dbc3b9f7a 100644 --- a/desktop/bottom.desktop +++ b/desktop/bottom.desktop @@ -1,10 +1,10 @@ [Desktop Entry] Name=bottom -Version=0.10.2 +Version=1.5 GenericName=System Monitor Comment=A customizable cross-platform graphical process/system monitor for the terminal. Exec=btm Terminal=true Type=Application -Categories=Utility;System;ConsoleOnly;Monitor; +Categories=System;ConsoleOnly;Monitor; StartupNotify=false diff --git a/docs/README.md b/docs/README.md index 66092dd07..591c799a6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,8 @@ Documentation is currently built using Python 3.11, though it should work fine w ## Running locally -One way is to just run `serve.sh`. Alternatively, the manual steps are: +One way is to just run `serve.sh`. Alternatively, the manual steps are, assuming your current working directory +is the bottom repo: ```bash # Change directories to the documentation. @@ -26,16 +27,17 @@ venv/bin/mkdocs serve ## Deploying -Deploying is done via [mike](https://github.com/jimporter/mike). +Deploying is done via [mike](https://github.com/jimporter/mike) in order to get versioning. Typically, +this is done through CI, but can be done manually if needed. -### Nightly +### Nightly docs ```bash cd docs mike deploy nightly --push ``` -### Stable +### Stable docs ```bash cd docs diff --git a/docs/content/configuration/command-line-options.md b/docs/content/configuration/command-line-options.md index 7f7923153..d65a584b2 100644 --- a/docs/content/configuration/command-line-options.md +++ b/docs/content/configuration/command-line-options.md @@ -25,18 +25,19 @@ see information on these options by running `btm -h`, or run `btm --help` to dis ## Process Options -| Option | Behaviour | -| --------------------------- | -------------------------------------------------------------------------------------- | -| `-S, --case_sensitive` | Enables case sensitivity by default. | -| `-u, --current_usage` | Calculates process CPU usage as a percentage of current usage rather than total usage. | -| `--disable_advanced_kill` | Hides additional stopping options Unix-like systems. | -| `-g, --group_processes` | Groups processes with the same name by default. | -| `--process_memory_as_value` | Defaults to showing process memory usage by value. | -| `--process_command` | Shows the full command name instead of the process name by default. | -| `-R, --regex` | Enables regex by default while searching. | -| `-T, --tree` | Makes the process widget use tree mode by default. | -| `-n, --unnormalized_cpu` | Show process CPU% usage without averaging over the number of CPU cores. | -| `-W, --whole_word` | Enables whole-word matching by default while searching. | +| Option | Behaviour | +| --------------------------- | --------------------------------------------------------------------------------------------- | +| `-S, --case_sensitive` | Enables case sensitivity by default. | +| `-u, --current_usage` | Calculates process CPU usage as a percentage of current usage rather than total usage. | +| `--disable_advanced_kill` | Disable being able to send signals to processes. Only available on Linux, macOS, and FreeBSD. | +| `-g, --group_processes` | Groups processes with the same name by default. No effect if `--tree` is set. | +| `--process_memory_as_value` | Defaults to showing process memory usage by value. | +| `--process_command` | Shows the full command name instead of the process name by default. | +| `-R, --regex` | Enables regex by default while searching. | +| `-T, --tree` | Makes the process widget use tree mode by default. | +| `-n, --unnormalized_cpu` | Show process CPU% usage without averaging over the number of CPU cores. | +| `-W, --whole_word` | Enables whole-word matching by default while searching. | +| `--tree_collapse` | Collapse process tree by default. | ## Temperature Options @@ -79,9 +80,9 @@ see information on these options by running `btm -h`, or run `btm --help` to dis ## GPU Options -| Option | Behaviour | -| -------------- | ------------------------------------------- | -| `--enable_gpu` | Enable collecting and displaying GPU usage. | +| Option | Behaviour | +| --------------- | ----------------------------------------------------------------- | +| `--disable_gpu` | Disable collecting and displaying NVIDIA and AMD GPU information. | ## Style Options diff --git a/docs/content/configuration/config-file/flags.md b/docs/content/configuration/config-file/flags.md index 18d3ee2c9..ec0bedb96 100644 --- a/docs/content/configuration/config-file/flags.md +++ b/docs/content/configuration/config-file/flags.md @@ -14,40 +14,41 @@ hide_avg_cpu = true Most of the [command line flags](../command-line-options.md) have config file equivalents to avoid having to type them out each time: -| Field | Type | Functionality | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | -| `hide_avg_cpu` | Boolean | Hides the average CPU usage. | -| `dot_marker` | Boolean | Uses a dot marker for graphs. | -| `cpu_left_legend` | Boolean | Puts the CPU chart legend to the left side. | -| `current_usage` | Boolean | Sets process CPU% to be based on current CPU%. | -| `group_processes` | Boolean | Groups processes with the same name by default. | -| `case_sensitive` | Boolean | Enables case sensitivity by default. | -| `whole_word` | Boolean | Enables whole-word matching by default. | -| `regex` | Boolean | Enables regex by default. | -| `basic` | Boolean | Hides graphs and uses a more basic look. | -| `use_old_network_legend` | Boolean | DEPRECATED - uses the older network legend. | -| `battery` | Boolean | Shows the battery widget. | -| `rate` | Unsigned Int (represents milliseconds) or String (represents human time) | Sets a refresh rate in ms. | -| `default_time_value` | Unsigned Int (represents milliseconds) or String (represents human time) | Default time value for graphs in ms. | -| `time_delta` | Unsigned Int (represents milliseconds) or String (represents human time) | The amount in ms changed upon zooming. | -| `hide_time` | Boolean | Hides the time scale. | -| `temperature_type` | String (one of ["k", "f", "c", "kelvin", "fahrenheit", "celsius"]) | Sets the temperature unit type. | -| `default_widget_type` | String (one of ["cpu", "proc", "net", "temp", "mem", "disk"], same as layout options) | Sets the default widget type, use --help for more info. | -| `default_widget_count` | Unsigned Int (represents which `default_widget_type`) | Sets the n'th selected widget type as the default. | -| `disable_click` | Boolean | Disables mouse clicks. | -| `enable_cache_memory` | Boolean | Enable cache and buffer memory stats (not available on Windows). | -| `process_memory_as_value` | Boolean | Defaults to showing process memory usage by value. | -| `tree` | Boolean | Defaults to showing the process widget in tree mode. | -| `show_table_scroll_position` | Boolean | Shows the scroll position tracker in table widgets. | -| `process_command` | Boolean | Show processes as their commands by default. | -| `disable_advanced_kill` | Boolean | Hides advanced options to stop a process on Unix-like systems. | -| `network_use_binary_prefix` | Boolean | Displays the network widget with binary prefixes. | -| `network_use_bytes` | Boolean | Displays the network widget using bytes. | -| `network_use_log` | Boolean | Displays the network widget with a log scale. | -| `enable_gpu` | Boolean | Shows the GPU widgets. | -| `retention` | String (human readable time, such as "10m", "1h", etc.) | How much data is stored at once in terms of time. | -| `unnormalized_cpu` | Boolean | Show process CPU% without normalizing over the number of cores. | -| `expanded` | Boolean | Expand the default widget upon starting the app. | -| `memory_legend` | String (one of ["none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right"]) | Where to place the legend for the memory widget. | -| `network_legend` | String (one of ["none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right"]) | Where to place the legend for the network widget. | -| `average_cpu_row` | Boolean | Moves the average CPU usage entry to its own row when using basic mode. | +| Field | Type | Functionality | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `hide_avg_cpu` | Boolean | Hides the average CPU usage. | +| `dot_marker` | Boolean | Uses a dot marker for graphs. | +| `cpu_left_legend` | Boolean | Puts the CPU chart legend to the left side. | +| `current_usage` | Boolean | Sets process CPU% to be based on current CPU%. | +| `group_processes` | Boolean | Groups processes with the same name by default. | +| `case_sensitive` | Boolean | Enables case sensitivity by default. | +| `whole_word` | Boolean | Enables whole-word matching by default. | +| `regex` | Boolean | Enables regex by default. | +| `basic` | Boolean | Hides graphs and uses a more basic look. | +| `use_old_network_legend` | Boolean | DEPRECATED - uses the older network legend. | +| `battery` | Boolean | Shows the battery widget. | +| `rate` | Unsigned Int (represents milliseconds) or String (represents human time) | Sets a refresh rate in ms. | +| `default_time_value` | Unsigned Int (represents milliseconds) or String (represents human time) | Default time value for graphs in ms. | +| `time_delta` | Unsigned Int (represents milliseconds) or String (represents human time) | The amount in ms changed upon zooming. | +| `hide_time` | Boolean | Hides the time scale. | +| `temperature_type` | String (one of ["k", "f", "c", "kelvin", "fahrenheit", "celsius"]) | Sets the temperature unit type. | +| `default_widget_type` | String (one of ["cpu", "proc", "net", "temp", "mem", "disk"], same as layout options) | Sets the default widget type, use --help for more info. | +| `default_widget_count` | Unsigned Int (represents which `default_widget_type`) | Sets the n'th selected widget type as the default. | +| `disable_click` | Boolean | Disables mouse clicks. | +| `enable_cache_memory` | Boolean | Enable cache and buffer memory stats (not available on Windows). | +| `process_memory_as_value` | Boolean | Defaults to showing process memory usage by value. | +| `tree` | Boolean | Defaults to showing the process widget in tree mode. | +| `show_table_scroll_position` | Boolean | Shows the scroll position tracker in table widgets. | +| `process_command` | Boolean | Show processes as their commands by default. | +| `disable_advanced_kill` | Boolean | Disable being able to send signals to processes on supported Unix-like systems. Only available on Linux, macOS, and FreeBSD. | +| `network_use_binary_prefix` | Boolean | Displays the network widget with binary prefixes. | +| `network_use_bytes` | Boolean | Displays the network widget using bytes. | +| `network_use_log` | Boolean | Displays the network widget with a log scale. | +| `disable_gpu` | Boolean | Disable NVIDIA and AMD GPU data collection. | +| `retention` | String (human readable time, such as "10m", "1h", etc.) | How much data is stored at once in terms of time. | +| `unnormalized_cpu` | Boolean | Show process CPU% without normalizing over the number of cores. | +| `expanded` | Boolean | Expand the default widget upon starting the app. | +| `memory_legend` | String (one of ["none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right"]) | Where to place the legend for the memory widget. | +| `network_legend` | String (one of ["none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right"]) | Where to place the legend for the network widget. | +| `average_cpu_row` | Boolean | Moves the average CPU usage entry to its own row when using basic mode. | +| `tree_collapse` | Boolean | Collapse process tree by default. | diff --git a/docs/content/configuration/config-file/index.md b/docs/content/configuration/config-file/index.md index 433d4f3ca..17fda3368 100644 --- a/docs/content/configuration/config-file/index.md +++ b/docs/content/configuration/config-file/index.md @@ -6,11 +6,11 @@ For persistent configuration, and for certain configuration options, bottom supp If no config file argument is given, it will automatically look for a config file at these locations: -| OS | Default Config Location | -| ------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| macOS | `$HOME/Library/Application Support/bottom/bottom.toml`
`~/.config/bottom/bottom.toml`
`$XDG_CONFIG_HOME/bottom/bottom.toml` | -| Linux | `~/.config/bottom/bottom.toml`
`$XDG_CONFIG_HOME/bottom/bottom.toml` | -| Windows | `C:\Users\\AppData\Roaming\bottom\bottom.toml` | +| OS | Default Config Location | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| macOS | `$HOME/Library/Application Support/bottom/bottom.toml`
`$HOME/.config/bottom/bottom.toml`
`$XDG_CONFIG_HOME/bottom/bottom.toml` | +| Linux | `$HOME/.config/bottom/bottom.toml`
`$XDG_CONFIG_HOME/bottom/bottom.toml` | +| Windows | `C:\Users\\AppData\Roaming\bottom\bottom.toml` | If the config file doesn't exist at the path, bottom will automatically try to create a new config file at the location with default values. diff --git a/docs/content/contribution/documentation.md b/docs/content/contribution/documentation.md index ac09e865d..fc600dbc7 100644 --- a/docs/content/contribution/documentation.md +++ b/docs/content/contribution/documentation.md @@ -12,7 +12,7 @@ There are a few areas where documentation changes are often needed: - The [`README.md`](https://github.com/ClementTsang/bottom/blob/main/README.md) - The help menu inside of the application (located [here](https://github.com/ClementTsang/bottom/blob/main/src/constants.rs)) -- The [extended documentation](https://clementtsang.github.io/bottom/nightly/) (here) +- The [extended documentation](../index.md) (what you're reading right now) - The [`CHANGELOG.md`](https://github.com/ClementTsang/bottom/blob/main/CHANGELOG.md) ## How should I add/update documentation? diff --git a/docs/content/contribution/packaging-and-distribution.md b/docs/content/contribution/packaging-and-distribution.md index a41384c31..0107465ba 100644 --- a/docs/content/contribution/packaging-and-distribution.md +++ b/docs/content/contribution/packaging-and-distribution.md @@ -54,6 +54,9 @@ This will automatically generate completion and manpage files in `target/tmp/bot files, modify/delete either these files or set `BTM_GENERATE` to some other non-empty value to retrigger the build script. +You may override the default diretories used to generate both completion and manpage files by specifying the +`COMPLETION_DIR` and `MANPAGE_DIR` environment variables respectively. + For more information, you may want to look at either the [`build.rs`](https://github.com/ClementTsang/bottom/blob/main/build.rs) file or the [binary build CI workflow](https://github.com/ClementTsang/bottom/blob/main/.github/workflows/build_releases.yml). diff --git a/docs/content/index.md b/docs/content/index.md index 1939cfadf..6d2763dd2 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -33,7 +33,7 @@ The command to run bottom is `btm`. You can refer to the [usage](usage/general-usage.md) pages for more details on using bottom (e.g. keybinds, some features, a general overview of what each widget does). -To configure bottom (e.g. how it behaves, how it looks, etc.) refer to the [command-line options page](configuration/command-line-options.md) for temporary settings, or [the config file page](configuration/config-file) for more permanent settings. +To configure bottom (e.g. how it behaves, how it looks, etc.) refer to the [command-line options page](configuration/command-line-options.md) for temporary settings, or [the config file page](configuration/config-file/index.md) for more permanent settings. ## Contribution diff --git a/docs/content/troubleshooting.md b/docs/content/troubleshooting.md index 3a56fcec1..4e178ca54 100644 --- a/docs/content/troubleshooting.md +++ b/docs/content/troubleshooting.md @@ -2,15 +2,19 @@ ## The graph points look broken/strange -It's possible that your graphs won't look great out of the box due to the reliance on braille fonts to draw them. One -example of this is seeing a bunch of missing font characters, caused when the terminal isn't configured properly to -render braille fonts. +It's possible that your graphs don't look great out of the box due to the reliance on +[braille characters](https://en.wikipedia.org/wiki/Braille_Patterns) to draw them. This could cause problems if +your terminal's font does not support them, or your terminal is not configured properly to draw them.
Example of a terminal with no braille font.
An example of missing braille fonts in Powershell
+Some possible solutions are included below. + +### Use dot markers instead + One alternative is to use the `--dot_marker` option to render graph charts using dots instead of the braille characters, which generally seems better supported out of the box, at the expense of looking less intricate: @@ -19,22 +23,29 @@ which generally seems better supported out of the box, at the expense of looking
Example using btm --dot_marker
-Another (better) alternative is to install a font that supports braille fonts, and configure your terminal emulator to use it. -For example, installing something like [UBraille](https://yudit.org/download/fonts/UBraille/) or [Iosevka](https://github.com/be5invis/Iosevka) -and ensuring your terminal uses it should work. +### Use a font that supports braille fonts -### Braille font issues on Linux/macOS/Unix-like +Another (better) alternative is to install a font that supports braille fonts, and configure your terminal emulator to +use it. For example, installing something like [UBraille](https://yudit.org/download/fonts/UBraille/) or +[Iosevka](https://github.com/be5invis/Iosevka) and ensuring your terminal uses it should work. -Generally, the problem comes down to you either not having a font that supports the braille markers, or your terminal -emulator is not using the correct font for the braille markers. +#### Linux/macOS/Unix -See [here](https://github.com/cjbassi/gotop/issues/18) for possible fixes if you're having font issues on Linux, which -may also be helpful for macOS or other Unix-like systems. +Solutions mostly depend on what terminal emulator you are using, so unfortunately, I can't give specific instructions. +Here are some possible solutions: -If you're still having issues, feel free to open a [discussion](https://github.com/ClementTsang/bottom/discussions/new/) -question about it. +- Uninstalling `gnu-free-fonts` if installed, as that is known to cause problems with braille markers +- Installing a font like `ttf-symbola` or `ttf-ubraille` for your terminal emulator to try and automatically fall back to +- Configuring your terminal emulator to use specific fonts for the `U+2800` to `U+28FF` range. + - For example for kitty, do `symbol_map U+2800-U+28FF Symbola`. -### Installing fonts for Windows Command Prompt/PowerShell +For some more possible solutions: + +- Check out [this issue](https://github.com/cjbassi/gotop/issues/18) from gotop about the same issue. +- See ratatui's [FAQ](https://ratatui.rs/faq/#some-characters-appear-to-be-missing--look-weird) (ratatui is the underlying + library bottom uses to draw things). + +#### Windows and Powershell **Note: I would advise backing up your registry beforehand if you aren't sure what you are doing!** @@ -50,16 +61,16 @@ Let's say you're installing [Iosevka](https://github.com/be5invis/Iosevka). The 4. Here, add a new `String value`, and set the `Name` to a bunch of 0's (e.g. `000` - make sure the name isn't already used), then set the `Data` to the font name (e.g. `Iosevka`). -
- Regedit menu showing how to add a new font for Command Prompt/PowerShell -
The last entry is the new entry for Iosevka
-
+
+ Regedit menu showing how to add a new font for Command Prompt/PowerShell +
The last entry is the new entry for Iosevka
+
5. Then, open the Command Prompt/PowerShell, and right-click on the top bar, and open "Properties": -
- Opening the properties menu in Command Prompt/PowerShell -
+
+ Opening the properties menu in Command Prompt/PowerShell +
6. From here, go to "Font", and set the font to your new font (so in this example, Iosevka): @@ -67,6 +78,11 @@ Let's say you're installing [Iosevka](https://github.com/be5invis/Iosevka). The Setting a new font in Command Prompt/PowerShell +### Still having issues? + +If you're still having issues, feel free to open a [discussion](https://github.com/ClementTsang/bottom/discussions/new/) +question about it, and I (or others) can try to help. + ## Why can't I see all my temperature sensors on Windows? This is a [known limitation](./support/official.md#windows), some sensors may require admin privileges to get sensor data. diff --git a/docs/content/usage/autocomplete.md b/docs/content/usage/autocomplete.md new file mode 100644 index 000000000..bd593467f --- /dev/null +++ b/docs/content/usage/autocomplete.md @@ -0,0 +1,14 @@ +# Auto-Complete + +The release binaries in [the releases page](https://github.com/ClementTsang/bottom/releases) are packaged with +shell auto-completion files for Bash, Zsh, fish, Powershell, Elvish, Fig, and Nushell. To install them: + +- For Bash, move `btm.bash` to `$XDG_CONFIG_HOME/bash_completion or /etc/bash_completion.d/`. +- For Zsh, move `_btm` to one of your `$fpath` directories. +- For fish, move `btm.fish` to `$HOME/.config/fish/completions/`. +- For PowerShell, add `_btm.ps1` to your PowerShell [profile](). +- For Elvish, the completion file is `btm.elv`. +- For Fig, the completion file is `btm.ts`. +- For Nushell, source `btm.nu`. + +The individual auto-completion files are also included in the stable/nightly releases as `completion.tar.gz` if needed. diff --git a/docs/content/usage/widgets/memory.md b/docs/content/usage/widgets/memory.md index 4304a3f7b..b52c7d164 100644 --- a/docs/content/usage/widgets/memory.md +++ b/docs/content/usage/widgets/memory.md @@ -13,7 +13,7 @@ If the total RAM or swap available is 0, then it is automatically hidden from th One can also adjust the displayed time range through either the keyboard or mouse, with a range of 30s to 600s. -This widget can also be configured to display Nvidia GPU memory usage (`--enable_gpu` on Linux/Windows) or cache memory usage (`--enable_cache_memory`). +This widget can also be configured to display Nvidia and AMD GPU memory usage (`--disable_gpu` on Linux/Windows to disable) or cache memory usage (`--enable_cache_memory`). ## Key bindings @@ -31,7 +31,9 @@ Note that key bindings are generally case-sensitive. | ------------ | -------------------------------------------------------------- | | ++"Scroll"++ | Scrolling up or down zooms in or out of the graph respectively | -## Calculations +## How are memory values determined? + +### Linux Memory usage is calculated using the following formula based on values from `/proc/meminfo` (based on [htop's implementation](https://github.com/htop-dev/htop/blob/976c6123f41492aaf613b9d172eef1842fb7b0a3/linux/LinuxProcessList.c#L1584)): @@ -40,3 +42,10 @@ MemTotal - MemFree - Buffers - (Cached + SReclaimable - Shmem) ``` You can find more info on `/proc/meminfo` and its fields [here](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/6/html/deployment_guide/s2-proc-meminfo). + +### Windows + +In Windows, we calculate swap by querying `Get-Counter "\Paging File(*)\% Usage"`. This +is also what some libraries like [psutil](https://github.com/giampaolo/psutil/blob/master/psutil/arch/windows/mem.c) use. However, note there are also a few other valid methods of +representing "swap" in Windows (e.g. using `GetPerformanceInfo`), which all slightly don't +match. diff --git a/docs/content/usage/widgets/process.md b/docs/content/usage/widgets/process.md index 0c16dc02a..9b5f4ec99 100644 --- a/docs/content/usage/widgets/process.md +++ b/docs/content/usage/widgets/process.md @@ -36,7 +36,7 @@ By default, the main process table displays the following information for each p [here](https://docs.rs/sysinfo/latest/sysinfo/struct.Process.html#method.disk_usage) for more details. -With the feature flag (`--enable_gpu` on Linux/Windows) and gpu process columns enabled in the configuration: +With the feature flag (`--disable_gpu` on Linux/Windows to disable) and gpu process columns enabled in the configuration: - GPU memory use percentage - GPU core utilization percentage @@ -64,7 +64,14 @@ is added together when displayed. A picture of grouped mode in a process widget. -Note that the process state and user columns are disabled in this mode. +!!! info + + Note that the process state and user columns are disabled in this mode. + +!!! info + + Note that if tree mode is also active, processes cannot be grouped together due to the behaviour of the two modes + somewhat clashing. This also reflects with default modes like `group_processes`. ### Process termination @@ -76,8 +83,8 @@ operating systems, you are also able to control which specific signals to send (
The process termination menu on Linux
-If you're on Windows, or if the `disable_advanced_kill` flag is set in the options or command-line, then a simpler termination -screen will be shown to confirm whether you want to kill that process/process group. +If you're on Windows, or if the `disable_advanced_kill` flag is set in the options or command-line (only available on +Linux, macOS, and FreeBSD), then a simpler termination screen with just yes or no options will be shown.
A picture of the process kill menu on Windows. @@ -92,9 +99,14 @@ Pressing ++t++ or ++f5++ in the table toggles tree mode in the process widget, d A picture of tree mode in a process widget.
-A process in tree mode can also be "collapsed", hiding its children and any descendants, using either the ++minus++ or ++plus++ keys, or double-clicking on an entry. +A process in tree mode can also be "collapsed", hiding its children and any descendants, using the either the ++minus++, +++plus++, or ++left++ keys, or clicking on an entry. It can be expanded by using the ++minus++, ++plus++, or ++right++ +keys, or by clicking on the entry again. + +!!! info -Lastly, note that in tree mode, processes cannot be grouped together due to the behaviour of the two modes somewhat clashing. + Note that if tree mode is active, processes cannot be grouped together due to the behaviour of the two modes + somewhat clashing. This also reflects with default modes like `group_processes`. ### Full command @@ -201,26 +213,26 @@ Note that key bindings are generally case-sensitive. ### Process table -| Binding | Action | -| ---------------------- | ---------------------------------------------------------------- | -| ++up++ , ++k++ | Move up within a widget | -| ++down++ , ++j++ | Move down within a widget | -| ++g+g++ , ++home++ | Jump to the first entry in the table | -| ++G++ , ++end++ | Jump to the last entry in the table | -| ++d+d++ , ++f9++ | Send a kill signal to the selected process | -| ++c++ | Sort by CPU usage, press again to reverse sorting order | -| ++m++ | Sort by memory usage, press again to reverse sorting order | -| ++p++ | Sort by PID name, press again to reverse sorting order | -| ++n++ | Sort by process name, press again to reverse sorting order | -| ++tab++ | Toggle grouping processes with the same name | -| ++P++ | Toggle between showing the full command or just the process name | -| ++ctrl+f++ , ++slash++ | Toggle showing the search sub-widget | -| ++s++ , ++f6++ | Toggle showing the sort sub-widget | -| ++I++ | Invert the current sort | -| ++"%"++ | Toggle between values and percentages for memory usage | -| ++t++ , ++f5++ | Toggle tree mode | -| ++M++ | Sort by gpu memory usage, press again to reverse sorting order | -| ++C++ | Sort by gpu usage, press again to reverse sorting order | +| Binding | Action | +| --------------------------------------------------- | ---------------------------------------------------------------- | +| ++up++ , ++k++ | Move up within a widget | +| ++down++ , ++j++ | Move down within a widget | +| ++g+g++ , ++home++ | Jump to the first entry in the table | +| ++G++ , ++end++ | Jump to the last entry in the table | +| ++d+d++ , ++f9++ | Send a kill signal to the selected process | +| ++c++ | Sort by CPU usage, press again to reverse sorting order | +| ++m++ | Sort by memory usage, press again to reverse sorting order | +| ++p++ | Sort by PID name, press again to reverse sorting order | +| ++n++ | Sort by process name, press again to reverse sorting order | +| ++tab++ | Toggle grouping processes with the same name | +| ++P++ | Toggle between showing the full command or just the process name | +| ++ctrl+f++ , ++slash++ | Toggle showing the search sub-widget | +| ++s++ , ++f6++, ++delete++ (++fn+delete++ on macOS) | Toggle showing the sort sub-widget | +| ++I++ | Invert the current sort | +| ++"%"++ | Toggle between values and percentages for memory usage | +| ++t++ , ++f5++ | Toggle tree mode | +| ++M++ | Sort by gpu memory usage, press again to reverse sorting order | +| ++C++ | Sort by gpu usage, press again to reverse sorting order | ### Sort sub-widget @@ -246,7 +258,7 @@ Note that key bindings are generally case-sensitive. | ++ctrl+w++ | Delete a word behind the cursor | | ++ctrl+h++ | Delete the character behind the cursor | | ++backspace++ | Delete the character behind the cursor | -| ++delete++ | Delete the character at the cursor | +| ++delete++ (++fn+delete++ on macOS) | Delete the character at the cursor | | ++alt+c++ , ++f1++ | Toggle matching case | | ++alt+w++ , ++f2++ | Toggle matching the entire word | | ++alt+r++ , ++f3++ | Toggle using regex | diff --git a/docs/content/usage/widgets/temperature.md b/docs/content/usage/widgets/temperature.md index 3cadc9a89..8ed87b10b 100644 --- a/docs/content/usage/widgets/temperature.md +++ b/docs/content/usage/widgets/temperature.md @@ -10,7 +10,7 @@ The temperature widget provides a table of temperature sensors and their current The temperature widget provides the sensor name as well as its current temperature. -This widget can also be configured to display Nvidia GPU temperatures (`--enable_gpu` on Linux/Windows). +This widget can also be configured to display Nvidia and AMD GPU temperatures (`--disable_gpu` on Linux/Windows to disable). ## Key bindings diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index df31da81c..992f4e638 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -161,15 +161,17 @@ nav: - "Disk Widget": usage/widgets/disk.md - "Temperature Widget": usage/widgets/temperature.md - "Battery Widget": usage/widgets/battery.md + - "Auto-Complete": usage/autocomplete.md - "Configuration": - "Command-line Options": configuration/command-line-options.md - "Config File": - configuration/config-file/index.md + - "CPU Widget": configuration/config-file/cpu.md + - "Data Filtering": configuration/config-file/data-filtering.md - "Flags": configuration/config-file/flags.md - - "Styling": configuration/config-file/styling.md - "Layout": configuration/config-file/layout.md - - "Data Filtering": configuration/config-file/data-filtering.md - - "Processes": configuration/config-file/processes.md + - "Processes Widget": configuration/config-file/processes.md + - "Styling": configuration/config-file/styling.md - "Contribution": - "Issues, Pull Requests, and Discussions": contribution/issues-and-pull-requests.md - "Documentation": contribution/documentation.md diff --git a/docs/requirements.txt b/docs/requirements.txt index 6f18402c7..a3c2cd3b6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,6 @@ -mkdocs == 1.6.0 -mkdocs-material == 9.5.31 +mkdocs == 1.6.1 +mkdocs-material == 9.6.9 mdx_truly_sane_lists == 1.3 -mike == 2.1.2 -mkdocs-git-revision-date-localized-plugin == 1.2.4 +mike == 2.1.3 +mkdocs-git-revision-date-localized-plugin == 1.4.5 + diff --git a/rustfmt.toml b/rustfmt.toml index c4a8b5842..8308ec60b 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -5,6 +5,7 @@ fn_params_layout = "Compressed" use_field_init_shorthand = true tab_spaces = 4 max_width = 100 +style_edition = "2024" # Unstable options, disabled by default. # imports_granularity = "Crate" diff --git a/sample_configs/default_config.toml b/sample_configs/default_config.toml index af98fccdf..1cb6614c2 100644 --- a/sample_configs/default_config.toml +++ b/sample_configs/default_config.toml @@ -1,32 +1,42 @@ -# This is a default config file for bottom. All of the settings are commented +# This is a default config file for bottom. All of the settings are commented # out by default; if you wish to change them uncomment and modify as you see # fit. -# This group of options represents a command-line option. Flags explicitly +# This group of options represents a command-line option. Flags explicitly # added when running (ie: btm -a) will override this config file if an option # is also set here. - [flags] # Whether to hide the average cpu entry. #hide_avg_cpu = false + # Whether to use dot markers rather than braille. #dot_marker = false + # The update rate of the application. #rate = "1s" + # Whether to put the CPU legend to the left. #cpu_left_legend = false + # Whether to set CPU% on a process to be based on the total CPU or just current usage. #current_usage = false + # Whether to set CPU% on a process to be based on the total CPU or per-core CPU% (not divided by the number of cpus). #unnormalized_cpu = false -# Whether to group processes with the same name together by default. + +# Whether to group processes with the same name together by default. Doesn't do anything +# if tree is set to true or --tree is set. #group_processes = false + # Whether to make process searching case sensitive by default. #case_sensitive = false + # Whether to make process searching look for matching the entire word by default. #whole_word = false + # Whether to make process searching use regex by default. #regex = false + # The temperature unit. One of the following, defaults to "c" for Celsius: #temperature_type = "c" ##temperature_type = "k" @@ -34,101 +44,170 @@ ##temperature_type = "kelvin" ##temperature_type = "fahrenheit" ##temperature_type = "celsius" + # The default time interval (in milliseconds). #default_time_value = "60s" + # The time delta on each zoom in/out action (in milliseconds). #time_delta = 15000 + # Hides the time scale. #hide_time = false + # Override layout default widget #default_widget_type = "proc" #default_widget_count = 1 + # Expand selected widget upon starting the app #expanded = true + # Use basic mode #basic = false + # Use the old network legend style #use_old_network_legend = false + # Remove space in tables #hide_table_gap = false + # Show the battery widgets #battery = false + # Disable mouse clicks #disable_click = false -# Built-in themes. Valid values are "default", "default-light", "gruvbox", "gruvbox-light", "nord", "nord-light" -#color = "default" + # Show memory values in the processes widget as values by default #process_memory_as_value = false + # Show tree mode by default in the processes widget. #tree = false + # Shows an indicator in table widgets tracking where in the list you are. #show_table_scroll_position = false + # Show processes as their commands by default in the process widget. #process_command = false + # Displays the network widget with binary prefixes. #network_use_binary_prefix = false + # Displays the network widget using bytes. #network_use_bytes = false + # Displays the network widget with a log scale. #network_use_log = false -# Hides advanced options to stop a process on Unix-like systems. + +# Hides advanced options to stop a process on Unix-like systems. Only available on Linux, macOS, and FreeBSD #disable_advanced_kill = false -# Shows GPU(s) memory -#enable_gpu = false + +# Hide GPU(s) information +#disable_gpu = false + # Shows cache and buffer memory #enable_cache_memory = false + # How much data is stored at once in terms of time. #retention = "10m" +# Where to place the legend for the memory widget. One of "none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right". +#memory_legend = "top-right" + +# Where to place the legend for the network widget. One of "none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right". +#network_legend = "top-right" + # Processes widget configuration #[processes] -# The columns shown by the process widget. The following columns are supported: +# The columns shown by the process widget. The following columns are supported (the GPU columns are only available if the GPU feature is enabled when built): # PID, Name, CPU%, Mem%, R/s, W/s, T.Read, T.Write, User, State, Time, GMem%, GPU% #columns = ["PID", "Name", "CPU%", "Mem%", "R/s", "W/s", "T.Read", "T.Write", "User", "State", "GMem%", "GPU%"] # CPU widget configuration #[cpu] # One of "all" (default), "average"/"avg" -# default = "average" +#default = "average" # Disk widget configuration #[disk] -#[name_filter] +# The columns shown by the process widget. The following columns are supported: +# Disk, Mount, Used, Free, Total, Used%, Free%, R/s, W/s +#columns = ["Disk", "Mount", "Used", "Free", "Total", "Used%", "R/s", "W/s"] + +# By default, there are no disk name filters enabled. These can be turned on to filter out specific data entries if you +# don't want to see them. An example use case is provided below. +#[disk.name_filter] +# Whether to ignore any matches. Defaults to true. #is_list_ignored = true + +# A list of filters to try and match. #list = ["/dev/sda\\d+", "/dev/nvme0n1p2"] + +# Whether to use regex. Defaults to false. #regex = true + +# Whether to be case-sensitive. Defaults to false. #case_sensitive = false + +# Whether to be require matching the whole word. Defaults to false. #whole_word = false -#[mount_filter] +# By default, there are no mount name filters enabled. An example use case is provided below. +#[disk.mount_filter] +# Whether to ignore any matches. Defaults to true. #is_list_ignored = true + +# A list of filters to try and match. #list = ["/mnt/.*", "/boot"] + +# Whether to use regex. Defaults to false. #regex = true + +# Whether to be case-sensitive. Defaults to false. #case_sensitive = false + +# Whether to be require matching the whole word. Defaults to false. #whole_word = false # Temperature widget configuration #[temperature] -#[sensor_filter] +# By default, there are no temperature sensor filters enabled. An example use case is provided below. +#[temperature.sensor_filter] +# Whether to ignore any matches. Defaults to true. #is_list_ignored = true + +# A list of filters to try and match. #list = ["cpu", "wifi"] + +# Whether to use regex. Defaults to false. #regex = false + +# Whether to be case-sensitive. Defaults to false. #case_sensitive = false + +# Whether to be require matching the whole word. Defaults to false. #whole_word = false # Network widget configuration #[network] -#[interface_filter] +# By default, there are no network interface filters enabled. An example use case is provided below. +#[network.interface_filter] +# Whether to ignore any matches. Defaults to true. #is_list_ignored = true + +# A list of filters to try and match. #list = ["virbr0.*"] + +# Whether to use regex. Defaults to false. #regex = true + +# Whether to be case-sensitive. Defaults to false. #case_sensitive = false + +# Whether to be require matching the whole word. Defaults to false. #whole_word = false # These are all the components that support custom theming. Note that colour support # will depend on terminal support. #[styles] # Uncomment if you want to use custom styling - # Built-in themes. Valid values are: # - "default" # - "default-light" diff --git a/sample_configs/demo_config.toml b/sample_configs/demo_config.toml index 4a4db938e..c774cb05f 100644 --- a/sample_configs/demo_config.toml +++ b/sample_configs/demo_config.toml @@ -16,3 +16,6 @@ default_widget_count = 1 [styles] theme = "gruvbox" + +[processes] +columns = ["PID", "Name", "CPU%", "Mem%", "Rps", "Wps", "TRead", "Twrite", "State", "Time", "Virt"] diff --git a/schema/README.md b/schema/README.md index b365e4f09..140cd0958 100644 --- a/schema/README.md +++ b/schema/README.md @@ -9,6 +9,8 @@ behind a feature flag to avoid building unnecessary code for release builds, and cargo run --features="generate_schema" -- --generate_schema > schema/nightly/bottom.json ``` +Alternatively, run the script in `scripts/schema/generate.sh`, which does this for you. + ## Publication To publish these schemas, cut a new version by copying `nightly` to a new folder with a version number matching bottom's diff --git a/schema/nightly/bottom.json b/schema/nightly/bottom.json index 781a524a0..1b77d18f2 100644 --- a/schema/nightly/bottom.json +++ b/schema/nightly/bottom.json @@ -1,14 +1,14 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://github.com/ClementTsang/bottom/blob/main/schema/nightly/bottom.json", - "title": "Schema for bottom's configs (nightly)", - "description": "https://clementtsang.github.io/bottom/nightly/configuration/config-file", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Schema for bottom's config file (nightly)", + "description": "https://bottom.pages.dev/nightly/configuration/config-file/", "type": "object", "properties": { "cpu": { "anyOf": [ { - "$ref": "#/definitions/CpuConfig" + "$ref": "#/$defs/CpuConfig" }, { "type": "null" @@ -18,7 +18,7 @@ "disk": { "anyOf": [ { - "$ref": "#/definitions/DiskConfig" + "$ref": "#/$defs/DiskConfig" }, { "type": "null" @@ -28,7 +28,7 @@ "flags": { "anyOf": [ { - "$ref": "#/definitions/FlagConfig" + "$ref": "#/$defs/FlagConfig" }, { "type": "null" @@ -38,7 +38,7 @@ "network": { "anyOf": [ { - "$ref": "#/definitions/NetworkConfig" + "$ref": "#/$defs/NetworkConfig" }, { "type": "null" @@ -48,7 +48,7 @@ "processes": { "anyOf": [ { - "$ref": "#/definitions/ProcessesConfig" + "$ref": "#/$defs/ProcessesConfig" }, { "type": "null" @@ -61,13 +61,13 @@ "null" ], "items": { - "$ref": "#/definitions/row" + "$ref": "#/$defs/row" } }, "styles": { "anyOf": [ { - "$ref": "#/definitions/StyleConfig" + "$ref": "#/$defs/StyleConfig" }, { "type": "null" @@ -77,7 +77,7 @@ "temperature": { "anyOf": [ { - "$ref": "#/definitions/TempConfig" + "$ref": "#/$defs/TempConfig" }, { "type": "null" @@ -85,7 +85,7 @@ ] } }, - "definitions": { + "$defs": { "BatteryStyle": { "description": "Styling specific to the battery widget.", "type": "object", @@ -94,7 +94,7 @@ "description": "The colour of the battery widget bar when the battery is over 50%.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -105,7 +105,7 @@ "description": "The colour of the battery widget bar when the battery is under 10%.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -116,7 +116,7 @@ "description": "The colour of the battery widget bar when the battery between 10% to 50%.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -133,12 +133,12 @@ "type": "object", "properties": { "default": { - "$ref": "#/definitions/CpuDefault" + "$ref": "#/$defs/CpuDefault" } } }, "CpuDefault": { - "description": "The default selection of the CPU widget. If the given selection is invalid, we will fall back to all.", + "description": "The default selection of the CPU widget. If the given selection is invalid,\n we will fall back to all.", "type": "string", "enum": [ "all", @@ -153,7 +153,7 @@ "description": "The colour of the \"All\" CPU label.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -164,7 +164,7 @@ "description": "The colour of the average CPU label and graph line.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -178,20 +178,45 @@ "null" ], "items": { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" } } } }, + "DiskColumn": { + "type": "string", + "enum": [ + "Disk", + "Free", + "Free%", + "Mount", + "R/s", + "Read", + "Rps", + "Total", + "Used", + "Used%", + "W/s", + "Wps", + "Write" + ] + }, "DiskConfig": { "description": "Disk configuration.", "type": "object", "properties": { + "columns": { + "description": "A list of disk widget columns.", + "type": "array", + "items": { + "$ref": "#/$defs/DiskColumn" + } + }, "mount_filter": { "description": "A filter over the mount names.", "anyOf": [ { - "$ref": "#/definitions/IgnoreList" + "$ref": "#/$defs/IgnoreList" }, { "type": "null" @@ -202,7 +227,7 @@ "description": "A filter over the disk names.", "anyOf": [ { - "$ref": "#/definitions/IgnoreList" + "$ref": "#/$defs/IgnoreList" }, { "type": "null" @@ -214,10 +239,10 @@ "FinalWidget": { "description": "Represents a widget.", "type": "object", - "required": [ - "type" - ], "properties": { + "type": { + "type": "string" + }, "default": { "type": [ "boolean", @@ -230,12 +255,12 @@ "null" ], "format": "uint32", - "minimum": 0.0 - }, - "type": { - "type": "string" + "minimum": 0 } - } + }, + "required": [ + "type" + ] }, "FlagConfig": { "type": "object", @@ -285,7 +310,7 @@ "default_time_value": { "anyOf": [ { - "$ref": "#/definitions/StringOrNum" + "$ref": "#/$defs/StringOrNum" }, { "type": "null" @@ -298,7 +323,7 @@ "null" ], "format": "uint64", - "minimum": 0.0 + "minimum": 0 }, "default_widget_type": { "type": [ @@ -318,19 +343,19 @@ "null" ] }, - "dot_marker": { + "disable_gpu": { "type": [ "boolean", "null" ] }, - "enable_cache_memory": { + "dot_marker": { "type": [ "boolean", "null" ] }, - "enable_gpu": { + "enable_cache_memory": { "type": [ "boolean", "null" @@ -417,7 +442,7 @@ "rate": { "anyOf": [ { - "$ref": "#/definitions/StringOrNum" + "$ref": "#/$defs/StringOrNum" }, { "type": "null" @@ -433,7 +458,7 @@ "retention": { "anyOf": [ { - "$ref": "#/definitions/StringOrNum" + "$ref": "#/$defs/StringOrNum" }, { "type": "null" @@ -455,7 +480,7 @@ "time_delta": { "anyOf": [ { - "$ref": "#/definitions/StringOrNum" + "$ref": "#/$defs/StringOrNum" }, { "type": "null" @@ -468,6 +493,12 @@ "null" ] }, + "tree_collapse": { + "type": [ + "boolean", + "null" + ] + }, "unnormalized_cpu": { "type": [ "boolean", @@ -496,7 +527,7 @@ "description": "The general colour of the parts of the graph.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -507,7 +538,7 @@ "description": "Text styling for graph's legend text.", "anyOf": [ { - "$ref": "#/definitions/TextStyleConfig" + "$ref": "#/$defs/TextStyleConfig" }, { "type": "null" @@ -518,17 +549,14 @@ }, "IgnoreList": { "type": "object", - "required": [ - "list" - ], "properties": { "case_sensitive": { - "default": false, - "type": "boolean" + "type": "boolean", + "default": false }, "is_list_ignored": { - "default": true, - "type": "boolean" + "type": "boolean", + "default": true }, "list": { "type": "array", @@ -537,14 +565,17 @@ } }, "regex": { - "default": false, - "type": "boolean" + "type": "boolean", + "default": false }, "whole_word": { - "default": false, - "type": "boolean" + "type": "boolean", + "default": false } - } + }, + "required": [ + "list" + ] }, "MemoryStyle": { "description": "Styling specific to the memory widget.", @@ -554,7 +585,7 @@ "description": "The colour of the ARC label and graph line.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -565,7 +596,7 @@ "description": "The colour of the cache label and graph line. Does not do anything on Windows.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -579,14 +610,14 @@ "null" ], "items": { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" } }, "ram_color": { "description": "The colour of the RAM label and graph line.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -597,7 +628,7 @@ "description": "The colour of the swap label and graph line.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -614,7 +645,7 @@ "description": "A filter over the network interface names.", "anyOf": [ { - "$ref": "#/definitions/IgnoreList" + "$ref": "#/$defs/IgnoreList" }, { "type": "null" @@ -631,7 +662,7 @@ "description": "The colour of the RX (download) label and graph line.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -642,7 +673,7 @@ "description": "he colour of the total RX (download) label in basic mode.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -653,7 +684,7 @@ "description": "The colour of the TX (upload) label and graph line.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -664,7 +695,7 @@ "description": "The colour of the total TX (upload) label in basic mode.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -677,29 +708,37 @@ "description": "A column in the process widget.", "type": "string", "enum": [ - "PID", - "Count", - "Name", - "Command", "CPU%", + "Command", + "Count", + "GMem", + "GMem%", + "GPU%", "Mem", "Mem%", + "Memory", + "Memory%", + "Name", + "PID", "R/s", "Read", "Rps", - "W/s", - "Write", - "Wps", + "State", "T.Read", - "TWrite", "T.Write", "TRead", - "State", - "User", + "TWrite", "Time", - "GMem", - "GMem%", - "GPU%" + "Total Read", + "Total Write", + "User", + "Virt", + "VirtMem", + "Virtual", + "Virtual Memory", + "W/s", + "Wps", + "Write" ] }, "ProcessesConfig": { @@ -710,27 +749,24 @@ "description": "A list of process widget columns.", "type": "array", "items": { - "$ref": "#/definitions/ProcColumn" + "$ref": "#/$defs/ProcColumn" } } } }, "RowChildren": { - "description": "Represents a child of a Row - either a Col (column) or a FinalWidget.\n\nA Col can also have an optional length and children. We only allow columns to have FinalWidgets as children, lest we get some amount of mutual recursion between Row and Col.", + "description": "Represents a child of a Row - either a Col (column) or a FinalWidget.\n\n A Col can also have an optional length and children. We only allow columns\n to have FinalWidgets as children, lest we get some amount of mutual\n recursion between Row and Col.", "anyOf": [ { - "$ref": "#/definitions/FinalWidget" + "$ref": "#/$defs/FinalWidget" }, { "type": "object", - "required": [ - "child" - ], "properties": { "child": { "type": "array", "items": { - "$ref": "#/definitions/FinalWidget" + "$ref": "#/$defs/FinalWidget" } }, "ratio": { @@ -739,9 +775,12 @@ "null" ], "format": "uint32", - "minimum": 0.0 + "minimum": 0 } - } + }, + "required": [ + "child" + ] } ] }, @@ -753,7 +792,7 @@ { "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 0 } ] }, @@ -765,7 +804,7 @@ "description": "Styling for the battery widget.", "anyOf": [ { - "$ref": "#/definitions/BatteryStyle" + "$ref": "#/$defs/BatteryStyle" }, { "type": "null" @@ -776,7 +815,7 @@ "description": "Styling for the CPU widget.", "anyOf": [ { - "$ref": "#/definitions/CpuStyle" + "$ref": "#/$defs/CpuStyle" }, { "type": "null" @@ -787,7 +826,7 @@ "description": "Styling for graph widgets.", "anyOf": [ { - "$ref": "#/definitions/GraphStyle" + "$ref": "#/$defs/GraphStyle" }, { "type": "null" @@ -798,7 +837,7 @@ "description": "Styling for the memory widget.", "anyOf": [ { - "$ref": "#/definitions/MemoryStyle" + "$ref": "#/$defs/MemoryStyle" }, { "type": "null" @@ -809,7 +848,7 @@ "description": "Styling for the network widget.", "anyOf": [ { - "$ref": "#/definitions/NetworkStyle" + "$ref": "#/$defs/NetworkStyle" }, { "type": "null" @@ -820,7 +859,7 @@ "description": "Styling for table widgets.", "anyOf": [ { - "$ref": "#/definitions/TableStyle" + "$ref": "#/$defs/TableStyle" }, { "type": "null" @@ -828,7 +867,7 @@ ] }, "theme": { - "description": "A built-in theme.\n\nIf this is and a custom colour are both set, in the config file, the custom colour scheme will be prioritized first. If a theme is set in the command-line args, however, it will always be prioritized first.", + "description": "A built-in theme.\n\n If this is and a custom colour are both set, in the config file,\n the custom colour scheme will be prioritized first. If a theme\n is set in the command-line args, however, it will always be\n prioritized first.", "type": [ "string", "null" @@ -838,7 +877,7 @@ "description": "Styling for general widgets.", "anyOf": [ { - "$ref": "#/definitions/WidgetStyle" + "$ref": "#/$defs/WidgetStyle" }, { "type": "null" @@ -855,7 +894,7 @@ "description": "Text styling for table headers.", "anyOf": [ { - "$ref": "#/definitions/TextStyleConfig" + "$ref": "#/$defs/TextStyleConfig" }, { "type": "null" @@ -872,7 +911,7 @@ "description": "A filter over the sensor names.", "anyOf": [ { - "$ref": "#/definitions/IgnoreList" + "$ref": "#/$defs/IgnoreList" }, { "type": "null" @@ -885,7 +924,7 @@ "description": "A style for text.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "object", @@ -894,7 +933,7 @@ "description": "A built-in ANSI colour, RGB hex, or RGB colour code.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -902,7 +941,7 @@ ] }, "bold": { - "description": "Whether to make this text bolded or not. If not set, will default to built-in defaults.", + "description": "Whether to make this text bolded or not. If not set,\n will default to built-in defaults.", "type": [ "boolean", "null" @@ -912,7 +951,7 @@ "description": "A built-in ANSI colour, RGB hex, or RGB colour code.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -920,7 +959,7 @@ ] }, "italics": { - "description": "Whether to make this text italicized or not. If not set, will default to built-in defaults.", + "description": "Whether to make this text italicized or not. If not set,\n will default to built-in defaults.", "type": [ "boolean", "null" @@ -930,6 +969,15 @@ } ] }, + "WidgetBorderType": { + "type": "string", + "enum": [ + "Default", + "Rounded", + "Double", + "Thick" + ] + }, "WidgetStyle": { "description": "General styling for generic widgets.", "type": "object", @@ -938,7 +986,7 @@ "description": "The colour of the widgets' borders.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -949,7 +997,7 @@ "description": "Text styling for text when representing something that is disabled.", "anyOf": [ { - "$ref": "#/definitions/TextStyleConfig" + "$ref": "#/$defs/TextStyleConfig" }, { "type": "null" @@ -960,7 +1008,7 @@ "description": "The colour of a widget's borders when the widget is selected.", "anyOf": [ { - "$ref": "#/definitions/ColorStr" + "$ref": "#/$defs/ColorStr" }, { "type": "null" @@ -971,7 +1019,7 @@ "description": "Text styling for text when representing something that is selected.", "anyOf": [ { - "$ref": "#/definitions/TextStyleConfig" + "$ref": "#/$defs/TextStyleConfig" }, { "type": "null" @@ -982,7 +1030,18 @@ "description": "Text styling for text in general.", "anyOf": [ { - "$ref": "#/definitions/TextStyleConfig" + "$ref": "#/$defs/TextStyleConfig" + }, + { + "type": "null" + } + ] + }, + "widget_border_type": { + "description": "Widget borders type.", + "anyOf": [ + { + "$ref": "#/$defs/WidgetBorderType" }, { "type": "null" @@ -993,7 +1052,7 @@ "description": "Text styling for a widget's title.", "anyOf": [ { - "$ref": "#/definitions/TextStyleConfig" + "$ref": "#/$defs/TextStyleConfig" }, { "type": "null" @@ -1003,7 +1062,7 @@ } }, "row": { - "description": "Represents a row. This has a length of some sort (optional) and a vector of children.", + "description": "Represents a row. This has a length of some sort (optional) and a vector\n of children.", "type": "object", "properties": { "child": { @@ -1012,7 +1071,7 @@ "null" ], "items": { - "$ref": "#/definitions/RowChildren" + "$ref": "#/$defs/RowChildren" } }, "ratio": { @@ -1021,7 +1080,7 @@ "null" ], "format": "uint32", - "minimum": 0.0 + "minimum": 0 } } } diff --git a/schema/v0.10/bottom.json b/schema/v0.10/bottom.json index d109e0114..9cd2afd4a 100644 --- a/schema/v0.10/bottom.json +++ b/schema/v0.10/bottom.json @@ -1,8 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/ClementTsang/bottom/blob/main/schema/nightly/bottom.json", - "title": "Schema for bottom's configs (nightly)", - "description": "https://clementtsang.github.io/bottom/nightly/configuration/config-file", + "$id": "https://github.com/ClementTsang/bottom/blob/main/schema/v0.10/bottom.json", + "title": "Schema for bottom's configs (v0.10)", + "description": "https://clementtsang.github.io/bottom/0.10.0/configuration/config-file/", "type": "object", "properties": { "cpu": { diff --git a/schema/v0.11/bottom.json b/schema/v0.11/bottom.json new file mode 100644 index 000000000..17f9b56a1 --- /dev/null +++ b/schema/v0.11/bottom.json @@ -0,0 +1,1088 @@ +{ + "$id": "https://github.com/ClementTsang/bottom/blob/main/schema/nightly/bottom.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Schema for bottom's config file (v0.11)", + "description": "https://bottom.pages.dev/0.11.0/configuration/config-file/", + "type": "object", + "properties": { + "cpu": { + "anyOf": [ + { + "$ref": "#/$defs/CpuConfig" + }, + { + "type": "null" + } + ] + }, + "disk": { + "anyOf": [ + { + "$ref": "#/$defs/DiskConfig" + }, + { + "type": "null" + } + ] + }, + "flags": { + "anyOf": [ + { + "$ref": "#/$defs/FlagConfig" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/$defs/NetworkConfig" + }, + { + "type": "null" + } + ] + }, + "processes": { + "anyOf": [ + { + "$ref": "#/$defs/ProcessesConfig" + }, + { + "type": "null" + } + ] + }, + "row": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/row" + } + }, + "styles": { + "anyOf": [ + { + "$ref": "#/$defs/StyleConfig" + }, + { + "type": "null" + } + ] + }, + "temperature": { + "anyOf": [ + { + "$ref": "#/$defs/TempConfig" + }, + { + "type": "null" + } + ] + } + }, + "$defs": { + "BatteryStyle": { + "description": "Styling specific to the battery widget.", + "type": "object", + "properties": { + "high_battery_color": { + "description": "The colour of the battery widget bar when the battery is over 50%.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "low_battery_color": { + "description": "The colour of the battery widget bar when the battery is under 10%.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "medium_battery_color": { + "description": "The colour of the battery widget bar when the battery between 10% to 50%.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + } + } + }, + "ColorStr": { + "type": "string" + }, + "CpuConfig": { + "description": "CPU column settings.", + "type": "object", + "properties": { + "default": { + "$ref": "#/$defs/CpuDefault" + } + } + }, + "CpuDefault": { + "description": "The default selection of the CPU widget. If the given selection is invalid,\n we will fall back to all.", + "type": "string", + "enum": [ + "all", + "average" + ] + }, + "CpuStyle": { + "description": "Styling specific to the CPU widget.", + "type": "object", + "properties": { + "all_entry_color": { + "description": "The colour of the \"All\" CPU label.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "avg_entry_color": { + "description": "The colour of the average CPU label and graph line.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "cpu_core_colors": { + "description": "Colour of each CPU threads' label and graph line. Read in order.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/ColorStr" + } + } + } + }, + "DiskColumn": { + "type": "string", + "enum": [ + "Disk", + "Free", + "Free%", + "Mount", + "R/s", + "Read", + "Rps", + "Total", + "Used", + "Used%", + "W/s", + "Wps", + "Write" + ] + }, + "DiskConfig": { + "description": "Disk configuration.", + "type": "object", + "properties": { + "columns": { + "description": "A list of disk widget columns.", + "type": "array", + "items": { + "$ref": "#/$defs/DiskColumn" + } + }, + "mount_filter": { + "description": "A filter over the mount names.", + "anyOf": [ + { + "$ref": "#/$defs/IgnoreList" + }, + { + "type": "null" + } + ] + }, + "name_filter": { + "description": "A filter over the disk names.", + "anyOf": [ + { + "$ref": "#/$defs/IgnoreList" + }, + { + "type": "null" + } + ] + } + } + }, + "FinalWidget": { + "description": "Represents a widget.", + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "default": { + "type": [ + "boolean", + "null" + ] + }, + "ratio": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "type" + ] + }, + "FlagConfig": { + "type": "object", + "properties": { + "autohide_time": { + "type": [ + "boolean", + "null" + ] + }, + "average_cpu_row": { + "type": [ + "boolean", + "null" + ] + }, + "basic": { + "type": [ + "boolean", + "null" + ] + }, + "battery": { + "type": [ + "boolean", + "null" + ] + }, + "case_sensitive": { + "type": [ + "boolean", + "null" + ] + }, + "cpu_left_legend": { + "type": [ + "boolean", + "null" + ] + }, + "current_usage": { + "type": [ + "boolean", + "null" + ] + }, + "default_time_value": { + "anyOf": [ + { + "$ref": "#/$defs/StringOrNum" + }, + { + "type": "null" + } + ] + }, + "default_widget_count": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "default_widget_type": { + "type": [ + "string", + "null" + ] + }, + "disable_advanced_kill": { + "type": [ + "boolean", + "null" + ] + }, + "disable_click": { + "type": [ + "boolean", + "null" + ] + }, + "disable_gpu": { + "type": [ + "boolean", + "null" + ] + }, + "dot_marker": { + "type": [ + "boolean", + "null" + ] + }, + "enable_cache_memory": { + "type": [ + "boolean", + "null" + ] + }, + "expanded": { + "type": [ + "boolean", + "null" + ] + }, + "group_processes": { + "type": [ + "boolean", + "null" + ] + }, + "hide_avg_cpu": { + "type": [ + "boolean", + "null" + ] + }, + "hide_table_gap": { + "type": [ + "boolean", + "null" + ] + }, + "hide_time": { + "type": [ + "boolean", + "null" + ] + }, + "memory_legend": { + "type": [ + "string", + "null" + ] + }, + "network_legend": { + "type": [ + "string", + "null" + ] + }, + "network_use_binary_prefix": { + "type": [ + "boolean", + "null" + ] + }, + "network_use_bytes": { + "type": [ + "boolean", + "null" + ] + }, + "network_use_log": { + "type": [ + "boolean", + "null" + ] + }, + "no_write": { + "type": [ + "boolean", + "null" + ] + }, + "process_command": { + "type": [ + "boolean", + "null" + ] + }, + "process_memory_as_value": { + "type": [ + "boolean", + "null" + ] + }, + "rate": { + "anyOf": [ + { + "$ref": "#/$defs/StringOrNum" + }, + { + "type": "null" + } + ] + }, + "regex": { + "type": [ + "boolean", + "null" + ] + }, + "retention": { + "anyOf": [ + { + "$ref": "#/$defs/StringOrNum" + }, + { + "type": "null" + } + ] + }, + "show_table_scroll_position": { + "type": [ + "boolean", + "null" + ] + }, + "temperature_type": { + "type": [ + "string", + "null" + ] + }, + "time_delta": { + "anyOf": [ + { + "$ref": "#/$defs/StringOrNum" + }, + { + "type": "null" + } + ] + }, + "tree": { + "type": [ + "boolean", + "null" + ] + }, + "tree_collapse": { + "type": [ + "boolean", + "null" + ] + }, + "unnormalized_cpu": { + "type": [ + "boolean", + "null" + ] + }, + "use_old_network_legend": { + "type": [ + "boolean", + "null" + ] + }, + "whole_word": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "GraphStyle": { + "description": "General styling for graph widgets.", + "type": "object", + "properties": { + "graph_color": { + "description": "The general colour of the parts of the graph.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "legend_text": { + "description": "Text styling for graph's legend text.", + "anyOf": [ + { + "$ref": "#/$defs/TextStyleConfig" + }, + { + "type": "null" + } + ] + } + } + }, + "IgnoreList": { + "type": "object", + "properties": { + "case_sensitive": { + "type": "boolean", + "default": false + }, + "is_list_ignored": { + "type": "boolean", + "default": true + }, + "list": { + "type": "array", + "items": { + "type": "string" + } + }, + "regex": { + "type": "boolean", + "default": false + }, + "whole_word": { + "type": "boolean", + "default": false + } + }, + "required": [ + "list" + ] + }, + "MemoryStyle": { + "description": "Styling specific to the memory widget.", + "type": "object", + "properties": { + "arc_color": { + "description": "The colour of the ARC label and graph line.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "cache_color": { + "description": "The colour of the cache label and graph line. Does not do anything on Windows.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "gpu_colors": { + "description": "Colour of each GPU's memory label and graph line. Read in order.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/ColorStr" + } + }, + "ram_color": { + "description": "The colour of the RAM label and graph line.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "swap_color": { + "description": "The colour of the swap label and graph line.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + } + } + }, + "NetworkConfig": { + "description": "Network configuration.", + "type": "object", + "properties": { + "interface_filter": { + "description": "A filter over the network interface names.", + "anyOf": [ + { + "$ref": "#/$defs/IgnoreList" + }, + { + "type": "null" + } + ] + } + } + }, + "NetworkStyle": { + "description": "Styling specific to the network widget.", + "type": "object", + "properties": { + "rx_color": { + "description": "The colour of the RX (download) label and graph line.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "rx_total_color": { + "description": "he colour of the total RX (download) label in basic mode.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "tx_color": { + "description": "The colour of the TX (upload) label and graph line.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "tx_total_color": { + "description": "The colour of the total TX (upload) label in basic mode.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + } + } + }, + "ProcColumn": { + "description": "A column in the process widget.", + "type": "string", + "enum": [ + "CPU%", + "Command", + "Count", + "GMem", + "GMem%", + "GPU%", + "Mem", + "Mem%", + "Memory", + "Memory%", + "Name", + "PID", + "R/s", + "Read", + "Rps", + "State", + "T.Read", + "T.Write", + "TRead", + "TWrite", + "Time", + "Total Read", + "Total Write", + "User", + "Virt", + "VirtMem", + "Virtual", + "Virtual Memory", + "W/s", + "Wps", + "Write" + ] + }, + "ProcessesConfig": { + "description": "Process configuration.", + "type": "object", + "properties": { + "columns": { + "description": "A list of process widget columns.", + "type": "array", + "items": { + "$ref": "#/$defs/ProcColumn" + } + } + } + }, + "RowChildren": { + "description": "Represents a child of a Row - either a Col (column) or a FinalWidget.\n\n A Col can also have an optional length and children. We only allow columns\n to have FinalWidgets as children, lest we get some amount of mutual\n recursion between Row and Col.", + "anyOf": [ + { + "$ref": "#/$defs/FinalWidget" + }, + { + "type": "object", + "properties": { + "child": { + "type": "array", + "items": { + "$ref": "#/$defs/FinalWidget" + } + }, + "ratio": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "child" + ] + } + ] + }, + "StringOrNum": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + ] + }, + "StyleConfig": { + "description": "Style-related configs.", + "type": "object", + "properties": { + "battery": { + "description": "Styling for the battery widget.", + "anyOf": [ + { + "$ref": "#/$defs/BatteryStyle" + }, + { + "type": "null" + } + ] + }, + "cpu": { + "description": "Styling for the CPU widget.", + "anyOf": [ + { + "$ref": "#/$defs/CpuStyle" + }, + { + "type": "null" + } + ] + }, + "graphs": { + "description": "Styling for graph widgets.", + "anyOf": [ + { + "$ref": "#/$defs/GraphStyle" + }, + { + "type": "null" + } + ] + }, + "memory": { + "description": "Styling for the memory widget.", + "anyOf": [ + { + "$ref": "#/$defs/MemoryStyle" + }, + { + "type": "null" + } + ] + }, + "network": { + "description": "Styling for the network widget.", + "anyOf": [ + { + "$ref": "#/$defs/NetworkStyle" + }, + { + "type": "null" + } + ] + }, + "tables": { + "description": "Styling for table widgets.", + "anyOf": [ + { + "$ref": "#/$defs/TableStyle" + }, + { + "type": "null" + } + ] + }, + "theme": { + "description": "A built-in theme.\n\n If this is and a custom colour are both set, in the config file,\n the custom colour scheme will be prioritized first. If a theme\n is set in the command-line args, however, it will always be\n prioritized first.", + "type": [ + "string", + "null" + ] + }, + "widgets": { + "description": "Styling for general widgets.", + "anyOf": [ + { + "$ref": "#/$defs/WidgetStyle" + }, + { + "type": "null" + } + ] + } + } + }, + "TableStyle": { + "description": "General styling for table widgets.", + "type": "object", + "properties": { + "headers": { + "description": "Text styling for table headers.", + "anyOf": [ + { + "$ref": "#/$defs/TextStyleConfig" + }, + { + "type": "null" + } + ] + } + } + }, + "TempConfig": { + "description": "Temperature configuration.", + "type": "object", + "properties": { + "sensor_filter": { + "description": "A filter over the sensor names.", + "anyOf": [ + { + "$ref": "#/$defs/IgnoreList" + }, + { + "type": "null" + } + ] + } + } + }, + "TextStyleConfig": { + "description": "A style for text.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "object", + "properties": { + "bg_color": { + "description": "A built-in ANSI colour, RGB hex, or RGB colour code.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "bold": { + "description": "Whether to make this text bolded or not. If not set,\n will default to built-in defaults.", + "type": [ + "boolean", + "null" + ] + }, + "color": { + "description": "A built-in ANSI colour, RGB hex, or RGB colour code.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "italics": { + "description": "Whether to make this text italicized or not. If not set,\n will default to built-in defaults.", + "type": [ + "boolean", + "null" + ] + } + } + } + ] + }, + "WidgetBorderType": { + "type": "string", + "enum": [ + "Default", + "Rounded", + "Double", + "Thick" + ] + }, + "WidgetStyle": { + "description": "General styling for generic widgets.", + "type": "object", + "properties": { + "border_color": { + "description": "The colour of the widgets' borders.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "disabled_text": { + "description": "Text styling for text when representing something that is disabled.", + "anyOf": [ + { + "$ref": "#/$defs/TextStyleConfig" + }, + { + "type": "null" + } + ] + }, + "selected_border_color": { + "description": "The colour of a widget's borders when the widget is selected.", + "anyOf": [ + { + "$ref": "#/$defs/ColorStr" + }, + { + "type": "null" + } + ] + }, + "selected_text": { + "description": "Text styling for text when representing something that is selected.", + "anyOf": [ + { + "$ref": "#/$defs/TextStyleConfig" + }, + { + "type": "null" + } + ] + }, + "text": { + "description": "Text styling for text in general.", + "anyOf": [ + { + "$ref": "#/$defs/TextStyleConfig" + }, + { + "type": "null" + } + ] + }, + "widget_border_type": { + "description": "Widget borders type.", + "anyOf": [ + { + "$ref": "#/$defs/WidgetBorderType" + }, + { + "type": "null" + } + ] + }, + "widget_title": { + "description": "Text styling for a widget's title.", + "anyOf": [ + { + "$ref": "#/$defs/TextStyleConfig" + }, + { + "type": "null" + } + ] + } + } + }, + "row": { + "description": "Represents a row. This has a length of some sort (optional) and a vector\n of children.", + "type": "object", + "properties": { + "child": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/RowChildren" + } + }, + "ratio": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + } + } + } + } +} diff --git a/schema/v0.9/bottom.json b/schema/v0.9/bottom.json index 043226453..4b1f4f403 100644 --- a/schema/v0.9/bottom.json +++ b/schema/v0.9/bottom.json @@ -1,8 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/ClementTsang/bottom/blob/main/schema/v1.0/bottom.json", + "$id": "https://github.com/ClementTsang/bottom/blob/main/schema/v0.9/bottom.json", "$comment": "https://clementtsang.github.io/bottom/0.9.6/configuration/config-file/default-config/", - "title": "Schema for bottom's configs (v1.0)", + "title": "Schema for bottom's configs (v0.9)", "type": "object", "definitions": { "row": { diff --git a/scripts/cirrus/release.py b/scripts/cirrus/release.py index 8c131592a..03938f50b 100644 --- a/scripts/cirrus/release.py +++ b/scripts/cirrus/release.py @@ -20,8 +20,6 @@ # Form of each task is (TASK_ALIAS, FILE_NAME). TASKS: List[Tuple[str, str]] = [ - ("freebsd_13_3_build", "bottom_x86_64-unknown-freebsd-13-3.tar.gz"), - ("freebsd_14_0_build", "bottom_x86_64-unknown-freebsd-14-0.tar.gz"), ("linux_2_17_build", "bottom_x86_64-unknown-linux-gnu-2-17.tar.gz"), ] URL = "https://api.cirrus-ci.com/graphql" diff --git a/scripts/packager.py b/scripts/packager.py deleted file mode 100644 index f4e147df0..000000000 --- a/scripts/packager.py +++ /dev/null @@ -1,78 +0,0 @@ -import hashlib -import sys -from string import Template - -args = sys.argv -version = args[1] -template_file_path = args[2] -generated_file_path = args[3] - -# SHA512, SHA256, or SHA1 -hash_type = args[4] - -# Deployment files -deployment_file_path_1 = args[5] -deployment_file_path_2 = args[6] if len(args) > 6 else None -deployment_file_path_3 = args[7] if len(args) > 7 else None - -print("Generating package for file: %s" % deployment_file_path_1) -if deployment_file_path_2 is not None: - print("and for file: %s" % deployment_file_path_2) -if deployment_file_path_3 is not None: - print("and for file: %s" % deployment_file_path_3) -print(" VERSION: %s" % version) -print(" TEMPLATE PATH: %s" % template_file_path) -print(" SAVING AT: %s" % generated_file_path) -print(" USING HASH TYPE: %s" % hash_type) - - -def get_hash(deployment_file): - if str.lower(hash_type) == "sha512": - deployment_hash = hashlib.sha512(deployment_file.read()).hexdigest() - elif str.lower(hash_type) == "sha256": - deployment_hash = hashlib.sha256(deployment_file.read()).hexdigest() - elif str.lower(hash_type) == "sha1": - deployment_hash = hashlib.sha1(deployment_file.read()).hexdigest() - else: - print( - 'Unsupported hash format "%s". Please use SHA512, SHA256, or SHA1.', - hash_type, - ) - exit(1) - - print("Generated hash: %s" % str(deployment_hash)) - return deployment_hash - - -with open(deployment_file_path_1, "rb") as deployment_file_1: - deployment_hash_1 = get_hash(deployment_file_1) - - deployment_hash_2 = None - if deployment_file_path_2 is not None: - with open(deployment_file_path_2, "rb") as deployment_file_2: - deployment_hash_2 = get_hash(deployment_file_2) - - deployment_hash_3 = None - if deployment_file_path_3 is not None: - with open(deployment_file_path_3, "rb") as deployment_file_3: - deployment_hash_3 = get_hash(deployment_file_3) - - with open(template_file_path, "r") as template_file: - template = Template(template_file.read()) - - substitutes = dict() - substitutes["version"] = version - substitutes["hash1"] = deployment_hash_1 - if deployment_hash_2 is not None: - substitutes["hash2"] = deployment_hash_2 - if deployment_hash_3 is not None: - substitutes["hash3"] = deployment_hash_3 - - substitute = template.safe_substitute(substitutes) - - print("\n================== Generated package file ==================\n") - print(substitute) - print("\n============================================================\n") - - with open(generated_file_path, "w") as generated_file: - generated_file.write(substitute) diff --git a/scripts/schema/generate.sh b/scripts/schema/generate.sh new file mode 100755 index 000000000..c7b89b41d --- /dev/null +++ b/scripts/schema/generate.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +cd "$(dirname "$0")"; +cd ../.. + +cargo run --bin schema --features="generate_schema" -- $1 > schema/nightly/bottom.json diff --git a/scripts/schema/requirements.txt b/scripts/schema/requirements.txt index b87036296..fd1837ba9 100644 --- a/scripts/schema/requirements.txt +++ b/scripts/schema/requirements.txt @@ -1 +1 @@ -jsonschema-rs == 0.18.0 +jsonschema-rs == 0.26.1 diff --git a/scripts/schema/validator.py b/scripts/schema/validator.py index 94060f6e0..4a0bdf93a 100644 --- a/scripts/schema/validator.py +++ b/scripts/schema/validator.py @@ -40,7 +40,7 @@ def main(): with open(file, "rb") as f, open(schema) as s: try: - validator = jsonschema_rs.JSONSchema.from_str(s.read()) + validator = jsonschema_rs.validator_for(s.read()) except: print("Couldn't create validator.") exit() @@ -51,7 +51,7 @@ def main(): read_file = re.sub( r"^#(\s\s+)([a-zA-Z\[])", r"\2", read_file, flags=re.MULTILINE ) - print(f"uncommented file: \n{read_file}") + print(f"uncommented file: \n{read_file}\n=====\n") toml_str = tomllib.loads(read_file) else: diff --git a/src/app.rs b/src/app.rs index b2d82fccc..8d9417ace 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,37 +1,30 @@ -pub mod data_farmer; +pub mod data; pub mod filter; -pub mod frozen_state; pub mod layout_manager; -mod process_killer; -pub mod query; pub mod states; -use std::{ - cmp::{max, min}, - time::Instant, -}; +use std::time::Instant; -use anyhow::bail; use concat_string::concat_string; -use data_farmer::*; +use data::*; use filter::*; -use frozen_state::FrozenState; use hashbrown::HashMap; use layout_manager::*; pub use states::*; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; +use crate::canvas::dialogs::process_kill_dialog::ProcessKillDialog; +use crate::widgets::TreeCollapsed; use crate::{ - canvas::components::time_chart::LegendPosition, - constants, convert_mem_data_points, convert_swap_data_points, - data_collection::{processes::Pid, temperature}, - data_conversion::ConvertedData, - get_network_points, + canvas::components::time_graph::LegendPosition, + constants, utils::data_units::DataUnit, widgets::{ProcWidgetColumn, ProcWidgetMode}, }; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +const STALE_MIN_MILLISECONDS: u64 = 30 * 1000; // Lowest is 30 seconds + +#[derive(Debug, Clone, Eq, PartialEq, Default, Copy)] pub enum AxisScaling { #[default] Log, @@ -43,7 +36,7 @@ pub enum AxisScaling { #[derive(Debug, Default, Eq, PartialEq)] pub struct AppConfigFields { pub update_rate: u64, - pub temperature_type: temperature::TemperatureType, + pub temperature_type: TemperatureType, pub use_dot: bool, pub cpu_left_legend: bool, pub show_average_cpu: bool, // TODO: Unify this in CPU options @@ -60,6 +53,7 @@ pub struct AppConfigFields { pub enable_gpu: bool, pub enable_cache_memory: bool, pub show_table_scroll_position: bool, + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] pub is_advanced_kill: bool, pub memory_legend_position: Option, // TODO: Remove these, move network details state-side. @@ -69,6 +63,7 @@ pub struct AppConfigFields { pub network_use_binary_prefix: bool, pub retention_ms: u64, pub dedicated_average_row: bool, + pub default_tree_collapse: bool, } /// For filtering out information @@ -80,44 +75,17 @@ pub struct DataFilters { pub net_filter: Option, } -cfg_if::cfg_if! { - if #[cfg(target_os = "linux")] { - /// The max signal we can send to a process on Linux. - pub const MAX_PROCESS_SIGNAL: usize = 64; - } else if #[cfg(target_os = "macos")] { - /// The max signal we can send to a process on macOS. - pub const MAX_PROCESS_SIGNAL: usize = 31; - } else if #[cfg(target_os = "freebsd")] { - /// The max signal we can send to a process on FreeBSD. - /// See [https://www.freebsd.org/cgi/man.cgi?query=signal&apropos=0&sektion=3&manpath=FreeBSD+13.1-RELEASE+and+Ports&arch=default&format=html] - /// for more details. - pub const MAX_PROCESS_SIGNAL: usize = 33; - } else if #[cfg(target_os = "windows")] { - /// The max signal we can send to a process. For Windows, we only have support for one signal (kill). - pub const MAX_PROCESS_SIGNAL: usize = 1; - } else { - /// The max signal we can send to a process. As a fallback, we only support one signal (kill). - pub const MAX_PROCESS_SIGNAL: usize = 1; - } -} - pub struct App { awaiting_second_char: bool, second_char: Option, - pub dd_err: Option, // FIXME: The way we do deletes is really gross. - to_delete_process_list: Option<(String, Vec)>, - pub frozen_state: FrozenState, + pub data_store: DataStore, last_key_press: Instant, - pub converted_data: ConvertedData, - pub data_collection: DataCollection, - pub delete_dialog_state: AppDeleteDialogState, + pub(crate) process_kill_dialog: ProcessKillDialog, pub help_dialog_state: AppHelpDialogState, pub is_expanded: bool, pub is_force_redraw: bool, pub is_determining_widget_boundary: bool, pub basic_mode_use_percent: bool, - #[cfg(target_family = "unix")] - pub user_table: crate::data_collection::processes::UserTable, pub states: AppWidgetStates, pub app_config_fields: AppConfigFields, pub widget_map: HashMap, @@ -136,20 +104,14 @@ impl App { Self { awaiting_second_char: false, second_char: None, - dd_err: None, - to_delete_process_list: None, - frozen_state: FrozenState::default(), + data_store: DataStore::default(), last_key_press: Instant::now(), - converted_data: ConvertedData::default(), - data_collection: DataCollection::default(), - delete_dialog_state: AppDeleteDialogState::default(), + process_kill_dialog: ProcessKillDialog::default(), help_dialog_state: AppHelpDialogState::default(), is_expanded, is_force_redraw: false, is_determining_widget_boundary: false, basic_mode_use_percent: false, - #[cfg(target_family = "unix")] - user_table: crate::data_collection::processes::UserTable::default(), states, app_config_fields, widget_map, @@ -161,82 +123,33 @@ impl App { /// Update the data in the [`App`]. pub fn update_data(&mut self) { - let data_source = match &self.frozen_state { - FrozenState::NotFrozen => &self.data_collection, - FrozenState::Frozen(data) => data, - }; + let data_source = self.data_store.get_data(); + // FIXME: (points_rework_v1) maybe separate PR but would it make more sense to store references of data? + // Would it also make more sense to move the "data set" step to the draw step, and make it only set if force + // update is set here? for proc in self.states.proc_state.widget_states.values_mut() { if proc.force_update_data { proc.set_table_data(data_source); - proc.force_update_data = false; } } - // FIXME: Make this CPU force update less terrible. - if self.states.cpu_state.force_update.is_some() { - self.converted_data.convert_cpu_data(data_source); - self.converted_data.load_avg_data = data_source.load_avg_harvest; - - self.states.cpu_state.force_update = None; - } - - // FIXME: This is a bit of a temp hack to move data over. - { - let data = &self.converted_data.cpu_data; - for cpu in self.states.cpu_state.widget_states.values_mut() { - cpu.update_table(data); + for temp in self.states.temp_state.widget_states.values_mut() { + if temp.force_update_data { + temp.set_table_data(&data_source.temp_data); } } - { - let data = &self.converted_data.temp_data; - for temp in self.states.temp_state.widget_states.values_mut() { - if temp.force_update_data { - temp.set_table_data(data); - temp.force_update_data = false; - } - } - } - { - let data = &self.converted_data.disk_data; - for disk in self.states.disk_state.widget_states.values_mut() { - if disk.force_update_data { - disk.set_table_data(data); - disk.force_update_data = false; - } - } - } - - // TODO: [OPT] Prefer reassignment over new vectors? - if self.states.mem_state.force_update.is_some() { - self.converted_data.mem_data = convert_mem_data_points(data_source); - #[cfg(not(target_os = "windows"))] - { - self.converted_data.cache_data = crate::convert_cache_data_points(data_source); - } - self.converted_data.swap_data = convert_swap_data_points(data_source); - #[cfg(feature = "zfs")] - { - self.converted_data.arc_data = crate::convert_arc_data_points(data_source); - } - #[cfg(feature = "gpu")] - { - self.converted_data.gpu_data = crate::convert_gpu_data(data_source); + for cpu in self.states.cpu_state.widget_states.values_mut() { + if cpu.force_update_data { + cpu.set_legend_data(&data_source.cpu_harvest); } - self.states.mem_state.force_update = None; } - if self.states.net_state.force_update.is_some() { - let (rx, tx) = get_network_points( - data_source, - &self.app_config_fields.network_scale_type, - &self.app_config_fields.network_unit_type, - self.app_config_fields.network_use_binary_prefix, - ); - self.converted_data.network_data_rx = rx; - self.converted_data.network_data_tx = tx; - self.states.net_state.force_update = None; + for disk in self.states.disk_state.widget_states.values_mut() { + if disk.force_update_data { + disk.set_table_data(data_source); + } } } @@ -246,7 +159,7 @@ impl App { // Reset dialog state self.help_dialog_state.is_showing_help = false; - self.delete_dialog_state.is_showing_dd = false; + self.process_kill_dialog.reset(); // Close all searches and reset it self.states @@ -257,44 +170,27 @@ impl App { state.proc_search.search_state.reset(); }); - // Clear current delete list - self.to_delete_process_list = None; - self.dd_err = None; - - // Unfreeze. - self.frozen_state.thaw(); + self.data_store.reset(); // Reset zoom self.reset_cpu_zoom(); self.reset_mem_zoom(); self.reset_net_zoom(); - - // Reset data - self.data_collection.reset(); } pub fn should_get_widget_bounds(&self) -> bool { self.is_force_redraw || self.is_determining_widget_boundary } - fn close_dd(&mut self) { - self.delete_dialog_state.is_showing_dd = false; - self.delete_dialog_state.selected_signal = KillSignal::default(); - self.delete_dialog_state.scroll_pos = 0; - self.to_delete_process_list = None; - self.dd_err = None; - } - pub fn on_esc(&mut self) { self.reset_multi_tap_keys(); - if self.is_in_dialog() { - if self.help_dialog_state.is_showing_help { - self.help_dialog_state.is_showing_help = false; - self.help_dialog_state.scroll_state.current_scroll_index = 0; - } else { - self.close_dd(); - } + if self.process_kill_dialog.is_open() { + self.process_kill_dialog.on_esc(); + self.is_force_redraw = true; + } else if self.help_dialog_state.is_showing_help { + self.help_dialog_state.is_showing_help = false; + self.help_dialog_state.scroll_state.current_scroll_index = 0; self.is_force_redraw = true; } else { match self.current_widget.widget_type { @@ -363,7 +259,7 @@ impl App { } fn is_in_dialog(&self) -> bool { - self.help_dialog_state.is_showing_help || self.delete_dialog_state.is_showing_dd + self.help_dialog_state.is_showing_help || self.process_kill_dialog.is_open() } fn ignore_normal_keybinds(&self) -> bool { @@ -529,9 +425,9 @@ impl App { proc_widget_state.force_rerender_and_update(); } ProcWidgetMode::Normal => { - proc_widget_state.mode = ProcWidgetMode::Tree { - collapsed_pids: Default::default(), - }; + proc_widget_state.mode = ProcWidgetMode::Tree(TreeCollapsed::new( + self.app_config_fields.default_tree_collapse, + )); proc_widget_state.force_rerender_and_update(); } ProcWidgetMode::Grouped => {} @@ -541,30 +437,9 @@ impl App { /// One of two functions allowed to run while in a dialog... pub fn on_enter(&mut self) { - if self.delete_dialog_state.is_showing_dd { - if self.dd_err.is_some() { - self.close_dd(); - } else if self.delete_dialog_state.selected_signal != KillSignal::Cancel { - // If within dd... - if self.dd_err.is_none() { - // Also ensure that we didn't just fail a dd... - let dd_result = self.kill_highlighted_process(); - self.delete_dialog_state.scroll_pos = 0; - self.delete_dialog_state.selected_signal = KillSignal::default(); - - // Check if there was an issue... if so, inform the user. - if let Err(dd_err) = dd_result { - self.dd_err = Some(dd_err.to_string()); - } else { - self.delete_dialog_state.is_showing_dd = false; - } - } - } else { - self.delete_dialog_state.scroll_pos = 0; - self.delete_dialog_state.selected_signal = KillSignal::default(); - self.delete_dialog_state.is_showing_dd = false; - } - self.is_force_redraw = true; + if self.process_kill_dialog.is_open() { + // Not the best way of doing things for now but works as glue. + self.process_kill_dialog.on_enter(); } else if !self.is_in_dialog() { if let BottomWidgetType::ProcSort = self.current_widget.widget_type { if let Some(proc_widget_state) = self @@ -582,16 +457,17 @@ impl App { } pub fn on_delete(&mut self) { - if let BottomWidgetType::ProcSearch = self.current_widget.widget_type { - let is_in_search_widget = self.is_in_search_widget(); - if let Some(proc_widget_state) = self - .states - .proc_state - .widget_states - .get_mut(&(self.current_widget.widget_id - 1)) - { - if is_in_search_widget { - if proc_widget_state.proc_search.search_state.is_enabled + match self.current_widget.widget_type { + BottomWidgetType::ProcSearch => { + let is_in_search_widget = self.is_in_search_widget(); + if let Some(proc_widget_state) = self + .states + .proc_state + .widget_states + .get_mut(&(self.current_widget.widget_id - 1)) + { + if is_in_search_widget + && proc_widget_state.proc_search.search_state.is_enabled && proc_widget_state.cursor_char_index() < proc_widget_state .proc_search @@ -621,10 +497,12 @@ impl App { proc_widget_state.update_query(); } - } else { - self.start_killing_process() } } + BottomWidgetType::Proc => { + self.kill_current_process(); + } + _ => {} } } @@ -671,93 +549,42 @@ impl App { } } - pub fn get_process_filter(&self, widget_id: u64) -> &Option { - if let Some(process_widget_state) = self.states.proc_state.widget_states.get(&widget_id) { - &process_widget_state.proc_search.search_state.query - } else { - &None - } - } - - #[cfg(target_family = "unix")] - pub fn on_number(&mut self, number_char: char) { - if self.delete_dialog_state.is_showing_dd { - if self - .delete_dialog_state - .last_number_press - .map_or(100, |ins| ins.elapsed().as_millis()) - >= 400 - { - self.delete_dialog_state.keyboard_signal_select = 0; - } - let mut kbd_signal = self.delete_dialog_state.keyboard_signal_select * 10; - kbd_signal += number_char.to_digit(10).unwrap() as usize; - if kbd_signal > 64 { - kbd_signal %= 100; - } - #[cfg(target_os = "linux")] - if kbd_signal > 64 || kbd_signal == 32 || kbd_signal == 33 { - kbd_signal %= 10; - } - #[cfg(target_os = "macos")] - if kbd_signal > 31 { - kbd_signal %= 10; - } - self.delete_dialog_state.selected_signal = KillSignal::Kill(kbd_signal); - if kbd_signal < 10 { - self.delete_dialog_state.keyboard_signal_select = kbd_signal; - } else { - self.delete_dialog_state.keyboard_signal_select = 0; - } - self.delete_dialog_state.last_number_press = Some(Instant::now()); - } - } - pub fn on_up_key(&mut self) { if !self.is_in_dialog() { self.decrement_position_count(); + self.reset_multi_tap_keys(); } else if self.help_dialog_state.is_showing_help { self.help_scroll_up(); - } else if self.delete_dialog_state.is_showing_dd { - #[cfg(target_os = "windows")] - self.on_right_key(); - #[cfg(target_family = "unix")] - { - if self.app_config_fields.is_advanced_kill { - self.on_left_key(); - } else { - self.on_right_key(); - } - } - return; + self.reset_multi_tap_keys(); + } else if self.process_kill_dialog.is_open() { + self.process_kill_dialog.on_up_key(); } - self.reset_multi_tap_keys(); } pub fn on_down_key(&mut self) { if !self.is_in_dialog() { self.increment_position_count(); + self.reset_multi_tap_keys(); } else if self.help_dialog_state.is_showing_help { self.help_scroll_down(); - } else if self.delete_dialog_state.is_showing_dd { - #[cfg(target_os = "windows")] - self.on_left_key(); - #[cfg(target_family = "unix")] - { - if self.app_config_fields.is_advanced_kill { - self.on_right_key(); - } else { - self.on_left_key(); - } - } - return; + self.reset_multi_tap_keys(); + } else if self.process_kill_dialog.is_open() { + self.process_kill_dialog.on_down_key(); } - self.reset_multi_tap_keys(); } pub fn on_left_key(&mut self) { if !self.is_in_dialog() { match self.current_widget.widget_type { + BottomWidgetType::Proc => { + if let Some(proc_widget_state) = self + .states + .proc_state + .get_mut_widget_state(self.current_widget.widget_id) + { + proc_widget_state.collapse_current_tree_branch_entry(); + } + } BottomWidgetType::ProcSearch => { let is_in_search_widget = self.is_in_search_widget(); if let Some(proc_widget_state) = self @@ -776,7 +603,8 @@ impl App { } } BottomWidgetType::Battery => { - if self.converted_data.battery_data.len() > 1 { + #[cfg(feature = "battery")] + if self.data_store.get_data().battery_harvest.len() > 1 { if let Some(battery_widget_state) = self .states .battery_state @@ -790,35 +618,23 @@ impl App { } _ => {} } - } else if self.delete_dialog_state.is_showing_dd { - #[cfg(target_family = "unix")] - { - if self.app_config_fields.is_advanced_kill { - match self.delete_dialog_state.selected_signal { - KillSignal::Kill(prev_signal) => { - self.delete_dialog_state.selected_signal = match prev_signal - 1 { - 0 => KillSignal::Cancel, - // 32 + 33 are skipped - 33 => KillSignal::Kill(31), - signal => KillSignal::Kill(signal), - }; - } - KillSignal::Cancel => {} - }; - } else { - self.delete_dialog_state.selected_signal = KillSignal::default(); - } - } - #[cfg(target_os = "windows")] - { - self.delete_dialog_state.selected_signal = KillSignal::Kill(1); - } + } else if self.process_kill_dialog.is_open() { + self.process_kill_dialog.on_left_key(); } } pub fn on_right_key(&mut self) { if !self.is_in_dialog() { match self.current_widget.widget_type { + BottomWidgetType::Proc => { + if let Some(proc_widget_state) = self + .states + .proc_state + .get_mut_widget_state(self.current_widget.widget_id) + { + proc_widget_state.expand_current_tree_branch_entry(); + } + } BottomWidgetType::ProcSearch => { let is_in_search_widget = self.is_in_search_widget(); if let Some(proc_widget_state) = self @@ -837,62 +653,34 @@ impl App { } } BottomWidgetType::Battery => { - if self.converted_data.battery_data.len() > 1 { - let battery_count = self.converted_data.battery_data.len(); - if let Some(battery_widget_state) = self - .states - .battery_state - .get_mut_widget_state(self.current_widget.widget_id) - { - if battery_widget_state.currently_selected_battery_index - < battery_count - 1 + #[cfg(feature = "battery")] + { + let battery_count = self.data_store.get_data().battery_harvest.len(); + if battery_count > 1 { + if let Some(battery_widget_state) = self + .states + .battery_state + .get_mut_widget_state(self.current_widget.widget_id) { - battery_widget_state.currently_selected_battery_index += 1; + if battery_widget_state.currently_selected_battery_index + < battery_count - 1 + { + battery_widget_state.currently_selected_battery_index += 1; + } } } } } _ => {} } - } else if self.delete_dialog_state.is_showing_dd { - #[cfg(target_family = "unix")] - { - if self.app_config_fields.is_advanced_kill { - let new_signal = match self.delete_dialog_state.selected_signal { - KillSignal::Cancel => 1, - // 32+33 are skipped - #[cfg(target_os = "linux")] - KillSignal::Kill(31) => 34, - #[cfg(target_os = "macos")] - KillSignal::Kill(31) => 31, - KillSignal::Kill(64) => 64, - KillSignal::Kill(signal) => signal + 1, - }; - self.delete_dialog_state.selected_signal = KillSignal::Kill(new_signal); - } else { - self.delete_dialog_state.selected_signal = KillSignal::Cancel; - } - } - #[cfg(target_os = "windows")] - { - self.delete_dialog_state.selected_signal = KillSignal::Cancel; - } + } else if self.process_kill_dialog.is_open() { + self.process_kill_dialog.on_right_key(); } } pub fn on_page_up(&mut self) { - if self.delete_dialog_state.is_showing_dd { - let mut new_signal = match self.delete_dialog_state.selected_signal { - KillSignal::Cancel => 0, - KillSignal::Kill(signal) => max(signal, 8) - 8, - }; - if new_signal > 23 && new_signal < 33 { - new_signal -= 2; - } - self.delete_dialog_state.selected_signal = match new_signal { - 0 => KillSignal::Cancel, - sig => KillSignal::Kill(sig), - }; + if self.process_kill_dialog.is_open() { + self.process_kill_dialog.on_page_up(); } else if self.help_dialog_state.is_showing_help { let current = &mut self.help_dialog_state.scroll_state.current_scroll_index; let amount = self.help_dialog_state.height; @@ -911,15 +699,8 @@ impl App { } pub fn on_page_down(&mut self) { - if self.delete_dialog_state.is_showing_dd { - let mut new_signal = match self.delete_dialog_state.selected_signal { - KillSignal::Cancel => 8, - KillSignal::Kill(signal) => min(signal + 8, MAX_PROCESS_SIGNAL), - }; - if new_signal > 31 && new_signal < 42 { - new_signal += 2; - } - self.delete_dialog_state.selected_signal = KillSignal::Kill(new_signal); + if self.process_kill_dialog.is_open() { + self.process_kill_dialog.on_page_down(); } else if self.help_dialog_state.is_showing_help { let current = self.help_dialog_state.scroll_state.current_scroll_index; let amount = self.help_dialog_state.height; @@ -1106,47 +887,21 @@ impl App { } } - pub fn start_killing_process(&mut self) { - self.reset_multi_tap_keys(); - - if let Some(pws) = self - .states - .proc_state - .widget_states - .get(&self.current_widget.widget_id) - { - if let Some(current) = pws.table.current_item() { - let id = current.id.to_string(); - if let Some(pids) = pws - .id_pid_map - .get(&id) - .cloned() - .or_else(|| Some(vec![current.pid])) - { - let current_process = (id, pids); - - self.to_delete_process_list = Some(current_process); - self.delete_dialog_state.is_showing_dd = true; - self.is_determining_widget_boundary = true; - } - } - } - // FIXME: This should handle errors. - } - pub fn on_char_key(&mut self, caught_char: char) { // Skip control code chars if caught_char.is_control() { return; } + const MAX_KEY_TIMEOUT_IN_MILLISECONDS: u64 = 1000; + // Forbid any char key presses when showing a dialog box... if !self.ignore_normal_keybinds() { let current_key_press_inst = Instant::now(); if current_key_press_inst .duration_since(self.last_key_press) .as_millis() - > constants::MAX_KEY_TIMEOUT_IN_MILLISECONDS.into() + > MAX_KEY_TIMEOUT_IN_MILLISECONDS.into() { self.reset_multi_tap_keys(); } @@ -1204,34 +959,50 @@ impl App { 'j' | 'k' | 'g' | 'G' => self.handle_char(caught_char), _ => {} } - } else if self.delete_dialog_state.is_showing_dd { - match caught_char { - 'h' => self.on_left_key(), - 'j' => self.on_down_key(), - 'k' => self.on_up_key(), - 'l' => self.on_right_key(), - #[cfg(target_family = "unix")] - '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => { - self.on_number(caught_char) - } - 'g' => { - let mut is_first_g = true; - if let Some(second_char) = self.second_char { - if self.awaiting_second_char && second_char == 'g' { - is_first_g = false; - self.awaiting_second_char = false; - self.second_char = None; - self.skip_to_first(); + } else if self.process_kill_dialog.is_open() { + self.process_kill_dialog.on_char(caught_char); + } + } + + /// Kill the currently selected process if we are in the process widget. + /// + /// TODO: This ideally gets abstracted out into a separate widget. + pub(crate) fn kill_current_process(&mut self) { + if let Some(pws) = self + .states + .proc_state + .widget_states + .get(&self.current_widget.widget_id) + { + if let Some(current) = pws.table.current_item() { + let id = current.id.to_string(); + if let Some(pids) = pws + .id_pid_map + .get(&id) + .cloned() + .or_else(|| Some(vec![current.pid])) + { + let current_process = (id, pids); + + let use_simple_selection = { + cfg_if::cfg_if! { + if #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] { + !self.app_config_fields.is_advanced_kill + } else { + true + } } - } + }; - if is_first_g { - self.awaiting_second_char = true; - self.second_char = Some('g'); - } + self.process_kill_dialog.start_process_kill( + current_process.0, + current_process.1, + use_simple_selection, + ); + + // TODO: I don't think most of this is needed. + self.is_determining_widget_boundary = true; } - 'G' => self.skip_to_last(), - _ => {} } } } @@ -1251,7 +1022,9 @@ impl App { self.awaiting_second_char = false; self.second_char = None; - self.start_killing_process(); + self.reset_multi_tap_keys(); + + self.kill_current_process(); } } @@ -1286,9 +1059,7 @@ impl App { 'G' => self.skip_to_last(), 'k' => self.on_up_key(), 'j' => self.on_down_key(), - 'f' => { - self.frozen_state.toggle(&self.data_collection); // TODO: Thawing should force a full data refresh and redraw immediately. - } + 'f' => self.data_store.toggle_frozen(), 'c' => { if let BottomWidgetType::Proc = self.current_widget.widget_type { if let Some(proc_widget_state) = self @@ -1468,36 +1239,6 @@ impl App { } } - pub fn kill_highlighted_process(&mut self) -> anyhow::Result<()> { - if let BottomWidgetType::Proc = self.current_widget.widget_type { - if let Some((_, pids)) = &self.to_delete_process_list { - #[cfg(target_family = "unix")] - let signal = match self.delete_dialog_state.selected_signal { - KillSignal::Kill(sig) => sig, - KillSignal::Cancel => 15, // should never happen, so just TERM - }; - for pid in pids { - #[cfg(target_family = "unix")] - { - process_killer::kill_process_given_pid(*pid, signal)?; - } - #[cfg(target_os = "windows")] - { - process_killer::kill_process_given_pid(*pid)?; - } - } - } - self.to_delete_process_list = None; - Ok(()) - } else { - bail!("Cannot kill processes if the current widget is not the Process widget!"); - } - } - - pub fn get_to_delete_processes(&self) -> Option<(String, Vec)> { - self.to_delete_process_list.clone() - } - fn toggle_expand_widget(&mut self) { if self.is_expanded { self.is_expanded = false; @@ -1991,7 +1732,7 @@ impl App { .proc_state .get_mut_widget_state(self.current_widget.widget_id) { - proc_widget_state.table.to_first(); + proc_widget_state.table.scroll_to_first(); } } BottomWidgetType::ProcSort => { @@ -2000,7 +1741,7 @@ impl App { .proc_state .get_mut_widget_state(self.current_widget.widget_id - 2) { - proc_widget_state.sort_table.to_first(); + proc_widget_state.sort_table.scroll_to_first(); } } BottomWidgetType::Temp => { @@ -2009,7 +1750,7 @@ impl App { .temp_state .get_mut_widget_state(self.current_widget.widget_id) { - temp_widget_state.table.to_first(); + temp_widget_state.table.scroll_to_first(); } } BottomWidgetType::Disk => { @@ -2018,7 +1759,7 @@ impl App { .disk_state .get_mut_widget_state(self.current_widget.widget_id) { - disk_widget_state.table.to_first(); + disk_widget_state.table.scroll_to_first(); } } BottomWidgetType::CpuLegend => { @@ -2027,7 +1768,7 @@ impl App { .cpu_state .get_mut_widget_state(self.current_widget.widget_id - 1) { - cpu_widget_state.table.to_first(); + cpu_widget_state.table.scroll_to_first(); } } @@ -2036,8 +1777,8 @@ impl App { self.reset_multi_tap_keys(); } else if self.help_dialog_state.is_showing_help { self.help_dialog_state.scroll_state.current_scroll_index = 0; - } else if self.delete_dialog_state.is_showing_dd { - self.delete_dialog_state.selected_signal = KillSignal::Cancel; + } else if self.process_kill_dialog.is_open() { + self.process_kill_dialog.go_to_first(); } } @@ -2050,7 +1791,7 @@ impl App { .proc_state .get_mut_widget_state(self.current_widget.widget_id) { - proc_widget_state.table.to_last(); + proc_widget_state.table.scroll_to_last(); } } BottomWidgetType::ProcSort => { @@ -2059,7 +1800,7 @@ impl App { .proc_state .get_mut_widget_state(self.current_widget.widget_id - 2) { - proc_widget_state.sort_table.to_last(); + proc_widget_state.sort_table.scroll_to_last(); } } BottomWidgetType::Temp => { @@ -2068,7 +1809,7 @@ impl App { .temp_state .get_mut_widget_state(self.current_widget.widget_id) { - temp_widget_state.table.to_last(); + temp_widget_state.table.scroll_to_last(); } } BottomWidgetType::Disk => { @@ -2077,8 +1818,8 @@ impl App { .disk_state .get_mut_widget_state(self.current_widget.widget_id) { - if !self.converted_data.disk_data.is_empty() { - disk_widget_state.table.to_last(); + if !self.data_store.get_data().disk_harvest.is_empty() { + disk_widget_state.table.scroll_to_last(); } } } @@ -2088,7 +1829,7 @@ impl App { .cpu_state .get_mut_widget_state(self.current_widget.widget_id - 1) { - cpu_widget_state.table.to_last(); + cpu_widget_state.table.scroll_to_last(); } } _ => {} @@ -2097,8 +1838,8 @@ impl App { } else if self.help_dialog_state.is_showing_help { self.help_dialog_state.scroll_state.current_scroll_index = self.help_dialog_state.scroll_state.max_scroll_index; - } else if self.delete_dialog_state.is_showing_dd { - self.delete_dialog_state.selected_signal = KillSignal::Kill(MAX_PROCESS_SIGNAL); + } else if self.process_kill_dialog.is_open() { + self.process_kill_dialog.go_to_last(); } } @@ -2207,14 +1948,9 @@ impl App { } pub fn handle_scroll_up(&mut self) { - if self.delete_dialog_state.is_showing_dd { - #[cfg(target_family = "unix")] - { - self.on_up_key(); - return; - } - } - if self.help_dialog_state.is_showing_help { + if self.process_kill_dialog.is_open() { + self.process_kill_dialog.on_scroll_up(); + } else if self.help_dialog_state.is_showing_help { self.help_scroll_up(); } else if self.current_widget.widget_type.is_widget_graph() { self.zoom_in(); @@ -2224,14 +1960,9 @@ impl App { } pub fn handle_scroll_down(&mut self) { - if self.delete_dialog_state.is_showing_dd { - #[cfg(target_family = "unix")] - { - self.on_down_key(); - return; - } - } - if self.help_dialog_state.is_showing_help { + if self.process_kill_dialog.is_open() { + self.process_kill_dialog.on_scroll_down(); + } else if self.help_dialog_state.is_showing_help { self.help_scroll_down(); } else if self.current_widget.widget_type.is_widget_graph() { self.zoom_out(); @@ -2284,7 +2015,6 @@ impl App { if new_time <= self.app_config_fields.retention_ms { cpu_widget_state.current_display_time = new_time; - self.states.cpu_state.force_update = Some(self.current_widget.widget_id); if self.app_config_fields.autohide_time { cpu_widget_state.autohide_timer = Some(Instant::now()); } @@ -2292,7 +2022,6 @@ impl App { != self.app_config_fields.retention_ms { cpu_widget_state.current_display_time = self.app_config_fields.retention_ms; - self.states.cpu_state.force_update = Some(self.current_widget.widget_id); if self.app_config_fields.autohide_time { cpu_widget_state.autohide_timer = Some(Instant::now()); } @@ -2312,7 +2041,6 @@ impl App { if new_time <= self.app_config_fields.retention_ms { mem_widget_state.current_display_time = new_time; - self.states.mem_state.force_update = Some(self.current_widget.widget_id); if self.app_config_fields.autohide_time { mem_widget_state.autohide_timer = Some(Instant::now()); } @@ -2320,7 +2048,6 @@ impl App { != self.app_config_fields.retention_ms { mem_widget_state.current_display_time = self.app_config_fields.retention_ms; - self.states.mem_state.force_update = Some(self.current_widget.widget_id); if self.app_config_fields.autohide_time { mem_widget_state.autohide_timer = Some(Instant::now()); } @@ -2340,7 +2067,6 @@ impl App { if new_time <= self.app_config_fields.retention_ms { net_widget_state.current_display_time = new_time; - self.states.net_state.force_update = Some(self.current_widget.widget_id); if self.app_config_fields.autohide_time { net_widget_state.autohide_timer = Some(Instant::now()); } @@ -2348,7 +2074,6 @@ impl App { != self.app_config_fields.retention_ms { net_widget_state.current_display_time = self.app_config_fields.retention_ms; - self.states.net_state.force_update = Some(self.current_widget.widget_id); if self.app_config_fields.autohide_time { net_widget_state.autohide_timer = Some(Instant::now()); } @@ -2372,17 +2097,13 @@ impl App { .current_display_time .saturating_sub(self.app_config_fields.time_interval); - if new_time >= constants::STALE_MIN_MILLISECONDS { + if new_time >= STALE_MIN_MILLISECONDS { cpu_widget_state.current_display_time = new_time; - self.states.cpu_state.force_update = Some(self.current_widget.widget_id); if self.app_config_fields.autohide_time { cpu_widget_state.autohide_timer = Some(Instant::now()); } - } else if cpu_widget_state.current_display_time - != constants::STALE_MIN_MILLISECONDS - { - cpu_widget_state.current_display_time = constants::STALE_MIN_MILLISECONDS; - self.states.cpu_state.force_update = Some(self.current_widget.widget_id); + } else if cpu_widget_state.current_display_time != STALE_MIN_MILLISECONDS { + cpu_widget_state.current_display_time = STALE_MIN_MILLISECONDS; if self.app_config_fields.autohide_time { cpu_widget_state.autohide_timer = Some(Instant::now()); } @@ -2400,17 +2121,13 @@ impl App { .current_display_time .saturating_sub(self.app_config_fields.time_interval); - if new_time >= constants::STALE_MIN_MILLISECONDS { + if new_time >= STALE_MIN_MILLISECONDS { mem_widget_state.current_display_time = new_time; - self.states.mem_state.force_update = Some(self.current_widget.widget_id); if self.app_config_fields.autohide_time { mem_widget_state.autohide_timer = Some(Instant::now()); } - } else if mem_widget_state.current_display_time - != constants::STALE_MIN_MILLISECONDS - { - mem_widget_state.current_display_time = constants::STALE_MIN_MILLISECONDS; - self.states.mem_state.force_update = Some(self.current_widget.widget_id); + } else if mem_widget_state.current_display_time != STALE_MIN_MILLISECONDS { + mem_widget_state.current_display_time = STALE_MIN_MILLISECONDS; if self.app_config_fields.autohide_time { mem_widget_state.autohide_timer = Some(Instant::now()); } @@ -2428,17 +2145,13 @@ impl App { .current_display_time .saturating_sub(self.app_config_fields.time_interval); - if new_time >= constants::STALE_MIN_MILLISECONDS { + if new_time >= STALE_MIN_MILLISECONDS { net_widget_state.current_display_time = new_time; - self.states.net_state.force_update = Some(self.current_widget.widget_id); if self.app_config_fields.autohide_time { net_widget_state.autohide_timer = Some(Instant::now()); } - } else if net_widget_state.current_display_time - != constants::STALE_MIN_MILLISECONDS - { - net_widget_state.current_display_time = constants::STALE_MIN_MILLISECONDS; - self.states.net_state.force_update = Some(self.current_widget.widget_id); + } else if net_widget_state.current_display_time != STALE_MIN_MILLISECONDS { + net_widget_state.current_display_time = STALE_MIN_MILLISECONDS; if self.app_config_fields.autohide_time { net_widget_state.autohide_timer = Some(Instant::now()); } @@ -2457,7 +2170,6 @@ impl App { .get_mut(&self.current_widget.widget_id) { cpu_widget_state.current_display_time = self.app_config_fields.default_time_value; - self.states.cpu_state.force_update = Some(self.current_widget.widget_id); if self.app_config_fields.autohide_time { cpu_widget_state.autohide_timer = Some(Instant::now()); } @@ -2472,7 +2184,6 @@ impl App { .get_mut(&self.current_widget.widget_id) { mem_widget_state.current_display_time = self.app_config_fields.default_time_value; - self.states.mem_state.force_update = Some(self.current_widget.widget_id); if self.app_config_fields.autohide_time { mem_widget_state.autohide_timer = Some(Instant::now()); } @@ -2487,7 +2198,6 @@ impl App { .get_mut(&self.current_widget.widget_id) { net_widget_state.current_display_time = self.app_config_fields.default_time_value; - self.states.net_state.force_update = Some(self.current_widget.widget_id); if self.app_config_fields.autohide_time { net_widget_state.autohide_timer = Some(Instant::now()); } @@ -2584,24 +2294,7 @@ impl App { // Second short circuit --- are we in the dd dialog state? If so, only check // yes/no/signals and bail after. - if self.is_in_dialog() { - match self.delete_dialog_state.button_positions.iter().find( - |(tl_x, tl_y, br_x, br_y, _idx)| { - (x >= *tl_x && y >= *tl_y) && (x <= *br_x && y <= *br_y) - }, - ) { - Some((_, _, _, _, 0)) => { - self.delete_dialog_state.selected_signal = KillSignal::Cancel - } - Some((_, _, _, _, idx)) => { - if *idx > 31 { - self.delete_dialog_state.selected_signal = KillSignal::Kill(*idx + 2) - } else { - self.delete_dialog_state.selected_signal = KillSignal::Kill(*idx) - } - } - _ => {} - } + if self.process_kill_dialog.is_open() && self.process_kill_dialog.on_click(x, y) { return; } @@ -2802,6 +2495,7 @@ impl App { } } BottomWidgetType::Battery => { + #[cfg(feature = "battery")] if let Some(battery_widget_state) = self .states .battery_state @@ -2813,10 +2507,12 @@ impl App { { if (x >= *tlc_x && y >= *tlc_y) && (x <= *brc_x && y <= *brc_y) { - if itx >= self.converted_data.battery_data.len() { + let num_batteries = + self.data_store.get_data().battery_harvest.len(); + if itx >= num_batteries { // range check to keep within current data battery_widget_state.currently_selected_battery_index = - self.converted_data.battery_data.len() - 1; + num_batteries - 1; } else { battery_widget_state.currently_selected_battery_index = itx; diff --git a/src/app/data/mod.rs b/src/app/data/mod.rs new file mode 100644 index 000000000..d82b0d711 --- /dev/null +++ b/src/app/data/mod.rs @@ -0,0 +1,13 @@ +//! How we manage data internally. + +mod time_series; +pub use time_series::{TimeSeriesData, Values}; + +mod process; +pub use process::ProcessData; + +mod store; +pub use store::*; + +mod temperature; +pub use temperature::*; diff --git a/src/app/data/process.rs b/src/app/data/process.rs new file mode 100644 index 000000000..b64889423 --- /dev/null +++ b/src/app/data/process.rs @@ -0,0 +1,55 @@ +use std::{collections::BTreeMap, vec::Vec}; + +use hashbrown::HashMap; + +use crate::collection::processes::{Pid, ProcessHarvest}; + +#[derive(Clone, Debug, Default)] +pub struct ProcessData { + /// A PID to process data map. + pub process_harvest: BTreeMap, + + /// A mapping between a process PID to any children process PIDs. + pub process_parent_mapping: HashMap>, + + /// PIDs corresponding to processes that have no parents. + pub orphan_pids: Vec, +} + +impl ProcessData { + pub(super) fn ingest(&mut self, list_of_processes: Vec) { + self.process_parent_mapping.clear(); + + // Reverse as otherwise the pid mappings are in the wrong order. + list_of_processes.iter().rev().for_each(|process_harvest| { + if let Some(parent_pid) = process_harvest.parent_pid { + if let Some(entry) = self.process_parent_mapping.get_mut(&parent_pid) { + entry.push(process_harvest.pid); + } else { + self.process_parent_mapping + .insert(parent_pid, vec![process_harvest.pid]); + } + } + }); + + self.process_parent_mapping.shrink_to_fit(); + + let process_pid_map = list_of_processes + .into_iter() + .map(|process| (process.pid, process)) + .collect(); + self.process_harvest = process_pid_map; + + // We collect all processes that either: + // - Do not have a parent PID (that is, they are orphan processes) + // - Have a parent PID but we don't have the parent (we promote them as orphans) + self.orphan_pids = self + .process_harvest + .iter() + .filter_map(|(pid, process_harvest)| match process_harvest.parent_pid { + Some(parent_pid) if self.process_harvest.contains_key(&parent_pid) => None, + _ => Some(*pid), + }) + .collect(); + } +} diff --git a/src/app/data/store.rs b/src/app/data/store.rs new file mode 100644 index 000000000..c9bbccaba --- /dev/null +++ b/src/app/data/store.rs @@ -0,0 +1,324 @@ +use std::{ + time::{Duration, Instant}, + vec::Vec, +}; + +use super::{ProcessData, TimeSeriesData}; +#[cfg(feature = "battery")] +use crate::collection::batteries; +use crate::{ + app::AppConfigFields, + collection::{Data, cpu, disks, memory::MemData, network}, + dec_bytes_per_second_string, + utils::data_units::DataUnit, + widgets::{DiskWidgetData, TempWidgetData}, +}; + +/// A collection of data. This is where we dump data into. +/// +/// TODO: Maybe reduce visibility of internal data, make it only accessible through DataStore? +#[derive(Debug, Clone)] +pub struct StoredData { + pub last_update_time: Instant, // FIXME: (points_rework_v1) we could be able to remove this with some more refactoring. + pub timeseries_data: TimeSeriesData, + pub network_harvest: network::NetworkHarvest, + pub ram_harvest: Option, + pub swap_harvest: Option, + #[cfg(not(target_os = "windows"))] + pub cache_harvest: Option, + #[cfg(feature = "zfs")] + pub arc_harvest: Option, + #[cfg(feature = "gpu")] + pub gpu_harvest: Vec<(String, MemData)>, + pub cpu_harvest: cpu::CpuHarvest, + pub load_avg_harvest: cpu::LoadAvgHarvest, + pub process_data: ProcessData, + /// TODO: (points_rework_v1) Might be a better way to do this without having to store here? + pub prev_io: Vec<(u64, u64)>, + pub disk_harvest: Vec, + pub temp_data: Vec, + #[cfg(feature = "battery")] + pub battery_harvest: Vec, +} + +impl Default for StoredData { + fn default() -> Self { + StoredData { + last_update_time: Instant::now(), + timeseries_data: TimeSeriesData::default(), + network_harvest: network::NetworkHarvest::default(), + ram_harvest: None, + #[cfg(not(target_os = "windows"))] + cache_harvest: None, + swap_harvest: None, + cpu_harvest: cpu::CpuHarvest::default(), + load_avg_harvest: cpu::LoadAvgHarvest::default(), + process_data: Default::default(), + prev_io: Vec::default(), + disk_harvest: Vec::default(), + temp_data: Vec::default(), + #[cfg(feature = "battery")] + battery_harvest: Vec::default(), + #[cfg(feature = "zfs")] + arc_harvest: None, + #[cfg(feature = "gpu")] + gpu_harvest: Vec::default(), + } + } +} + +impl StoredData { + pub fn reset(&mut self) { + *self = StoredData::default(); + } + + #[allow( + clippy::boxed_local, + reason = "This avoids warnings on certain platforms (e.g. 32-bit)." + )] + fn eat_data(&mut self, mut data: Box, settings: &AppConfigFields) { + let harvested_time = data.collection_time; + + // We must adjust all the network values to their selected type (defaults to bits). + if matches!(settings.network_unit_type, DataUnit::Byte) { + if let Some(network) = &mut data.network { + network.rx /= 8; + network.tx /= 8; + } + } + + if !settings.use_basic_mode { + self.timeseries_data.add(&data); + } + + if let Some(network) = data.network { + self.network_harvest = network; + } + + self.ram_harvest = data.memory; + self.swap_harvest = data.swap; + + #[cfg(not(target_os = "windows"))] + { + self.cache_harvest = data.cache; + } + + #[cfg(feature = "zfs")] + { + self.arc_harvest = data.arc; + } + + #[cfg(feature = "gpu")] + if let Some(gpu) = data.gpu { + self.gpu_harvest = gpu; + } + + if let Some(cpu) = data.cpu { + self.cpu_harvest = cpu; + } + + if let Some(load_avg) = data.load_avg { + self.load_avg_harvest = load_avg; + } + + self.temp_data = data + .temperature_sensors + .map(|sensors| { + sensors + .into_iter() + .map(|temp| TempWidgetData { + sensor: temp.name, + temperature: temp + .temperature + .map(|c| settings.temperature_type.convert_temp_unit(c)), + }) + .collect() + }) + .unwrap_or_default(); + + if let Some(disks) = data.disks { + if let Some(io) = data.io { + self.eat_disks(disks, io, harvested_time); + } + } + + if let Some(list_of_processes) = data.list_of_processes { + self.process_data.ingest(list_of_processes); + } + + #[cfg(feature = "battery")] + { + if let Some(list_of_batteries) = data.list_of_batteries { + self.battery_harvest = list_of_batteries; + } + } + + // And we're done eating. Update time and push the new entry! + self.last_update_time = harvested_time; + } + + fn eat_disks( + &mut self, disks: Vec, io: disks::IoHarvest, harvested_time: Instant, + ) { + let time_since_last_harvest = harvested_time + .duration_since(self.last_update_time) + .as_secs_f64(); + + self.disk_harvest.clear(); + + let prev_io_diff = disks.len().saturating_sub(self.prev_io.len()); + self.prev_io.reserve(prev_io_diff); + self.prev_io.extend((0..prev_io_diff).map(|_| (0, 0))); + + for (itx, device) in disks.into_iter().enumerate() { + let Some(checked_name) = ({ + #[cfg(target_os = "windows")] + { + match &device.volume_name { + Some(volume_name) => Some(volume_name.as_str()), + None => device.name.split('/').next_back(), + } + } + #[cfg(not(target_os = "windows"))] + { + #[cfg(feature = "zfs")] + { + if !device.name.starts_with('/') { + Some(device.name.as_str()) // use the whole zfs + // dataset name + } else { + device.name.split('/').next_back() + } + } + #[cfg(not(feature = "zfs"))] + { + device.name.split('/').next_back() + } + } + }) else { + continue; + }; + + let io_device = { + #[cfg(target_os = "macos")] + { + use std::sync::OnceLock; + + use regex::Regex; + + // Must trim one level further for macOS! + static DISK_REGEX: OnceLock = OnceLock::new(); + + #[expect( + clippy::regex_creation_in_loops, + reason = "this is fine since it's done via a static OnceLock. In the future though, separate it out." + )] + if let Some(new_name) = DISK_REGEX + .get_or_init(|| Regex::new(r"disk\d+").unwrap()) + .find(checked_name) + { + io.get(new_name.as_str()) + } else { + None + } + } + #[cfg(not(target_os = "macos"))] + { + io.get(checked_name) + } + }; + + let (mut io_read, mut io_write) = ("N/A".into(), "N/A".into()); + if let Some(Some(io_device)) = io_device { + if let Some(prev_io) = self.prev_io.get_mut(itx) { + let r_rate = ((io_device.read_bytes.saturating_sub(prev_io.0)) as f64 + / time_since_last_harvest) + .round() as u64; + + let w_rate = ((io_device.write_bytes.saturating_sub(prev_io.1)) as f64 + / time_since_last_harvest) + .round() as u64; + + *prev_io = (io_device.read_bytes, io_device.write_bytes); + + io_read = dec_bytes_per_second_string(r_rate).into(); + io_write = dec_bytes_per_second_string(w_rate).into(); + } + } + + let summed_total_bytes = match (device.used_space, device.free_space) { + (Some(used), Some(free)) => Some(used + free), + _ => None, + }; + + self.disk_harvest.push(DiskWidgetData { + name: device.name, + mount_point: device.mount_point, + free_bytes: device.free_space, + used_bytes: device.used_space, + total_bytes: device.total_space, + summed_total_bytes, + io_read, + io_write, + }); + } + } +} + +/// If we freeze data collection updates, we want to return a "frozen" copy +/// of the data at the time, while still updating things in the background. +#[derive(Default)] +pub enum FrozenState { + #[default] + NotFrozen, + Frozen(Box), +} + +/// What data to share to other parts of the application. +#[derive(Default)] +pub struct DataStore { + frozen_state: FrozenState, + main: StoredData, +} + +impl DataStore { + /// Toggle whether the [`DataState`] is frozen or not. + pub fn toggle_frozen(&mut self) { + match &self.frozen_state { + FrozenState::NotFrozen => { + self.frozen_state = FrozenState::Frozen(Box::new(self.main.clone())); + } + FrozenState::Frozen(_) => self.frozen_state = FrozenState::NotFrozen, + } + } + + /// Return whether the [`DataState`] is frozen or not. + pub fn is_frozen(&self) -> bool { + matches!(self.frozen_state, FrozenState::Frozen(_)) + } + + /// Return a reference to the currently available data. Note that if the data is + /// in a frozen state, it will return the snapshot of data from when it was frozen. + pub fn get_data(&self) -> &StoredData { + match &self.frozen_state { + FrozenState::NotFrozen => &self.main, + FrozenState::Frozen(collected_data) => collected_data, + } + } + + /// Eat data. + pub fn eat_data(&mut self, data: Box, settings: &AppConfigFields) { + self.main.eat_data(data, settings); + } + + /// Clean data. + pub fn clean_data(&mut self, max_duration: Duration) { + self.main.timeseries_data.prune(max_duration); + } + + /// Reset data state. + pub fn reset(&mut self) { + self.frozen_state = FrozenState::NotFrozen; + self.main = StoredData::default(); + } +} diff --git a/src/app/data/temperature.rs b/src/app/data/temperature.rs new file mode 100644 index 000000000..6647eec96 --- /dev/null +++ b/src/app/data/temperature.rs @@ -0,0 +1,83 @@ +//! Code around temperature data. + +use std::{fmt::Display, str::FromStr}; + +#[derive(Clone, Debug, Copy, PartialEq, Eq, Default)] +pub enum TemperatureType { + #[default] + Celsius, + Kelvin, + Fahrenheit, +} + +impl FromStr for TemperatureType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "fahrenheit" | "f" => Ok(TemperatureType::Fahrenheit), + "kelvin" | "k" => Ok(TemperatureType::Kelvin), + "celsius" | "c" => Ok(TemperatureType::Celsius), + _ => Err(format!( + "'{s}' is an invalid temperature type, use one of: [kelvin, k, celsius, c, fahrenheit, f]." + )), + } + } +} + +impl TemperatureType { + /// Given a temperature in Celsius, covert it if necessary for a different + /// unit. + pub fn convert_temp_unit(&self, celsius: f32) -> TypedTemperature { + match self { + TemperatureType::Celsius => TypedTemperature::Celsius(celsius.ceil() as u32), + TemperatureType::Kelvin => TypedTemperature::Kelvin((celsius + 273.15).ceil() as u32), + TemperatureType::Fahrenheit => { + TypedTemperature::Fahrenheit(((celsius * (9.0 / 5.0)) + 32.0).ceil() as u32) + } + } + } +} + +/// A temperature and its type. +#[derive(Debug, PartialEq, Clone, Eq, PartialOrd, Ord)] +pub enum TypedTemperature { + Celsius(u32), + Kelvin(u32), + Fahrenheit(u32), +} + +impl Display for TypedTemperature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TypedTemperature::Celsius(val) => write!(f, "{val}°C"), + TypedTemperature::Kelvin(val) => write!(f, "{val}K"), + TypedTemperature::Fahrenheit(val) => write!(f, "{val}°F"), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn temp_conversions() { + const TEMP: f32 = 100.0; + + assert_eq!( + TemperatureType::Celsius.convert_temp_unit(TEMP), + TypedTemperature::Celsius(TEMP as u32), + ); + + assert_eq!( + TemperatureType::Kelvin.convert_temp_unit(TEMP), + TypedTemperature::Kelvin(373.15_f32.ceil() as u32) + ); + + assert_eq!( + TemperatureType::Fahrenheit.convert_temp_unit(TEMP), + TypedTemperature::Fahrenheit(212) + ); + } +} diff --git a/src/app/data/time_series.rs b/src/app/data/time_series.rs new file mode 100644 index 000000000..d93f21307 --- /dev/null +++ b/src/app/data/time_series.rs @@ -0,0 +1,229 @@ +//! Time series data. + +use std::{ + cmp::Ordering, + time::{Duration, Instant}, + vec::Vec, +}; + +#[cfg(feature = "gpu")] +use hashbrown::{HashMap, HashSet}; // TODO: Try fxhash again. +use timeless::data::ChunkedData; + +use crate::collection::Data; + +/// Values corresponding to a time slice. +pub type Values = ChunkedData; + +/// Represents time series data in a chunked, deduped manner. +/// +/// Properties: +/// - Time in this manner is represented in a reverse-offset fashion from the current time. +/// - All data is stored in SoA fashion. +/// - Values are stored in a chunked format, which facilitates gaps in data collection if needed. +/// - Additional metadata is stored to make data pruning over time easy. +#[derive(Clone, Debug, Default)] +pub struct TimeSeriesData { + /// Time values. + /// + /// TODO: (points_rework_v1) Either store millisecond-level only or offsets only. + pub time: Vec, + + /// Network RX data. + pub rx: Values, + + /// Network TX data. + pub tx: Values, + + /// CPU data. + pub cpu: Vec, + + /// RAM memory data. + pub ram: Values, + + /// Swap data. + pub swap: Values, + + #[cfg(not(target_os = "windows"))] + /// Cache data. + pub cache_mem: Values, + + #[cfg(feature = "zfs")] + /// Arc data. + pub arc_mem: Values, + + #[cfg(feature = "gpu")] + /// GPU memory data. + pub gpu_mem: HashMap, +} + +impl TimeSeriesData { + /// Add a new data point. + pub fn add(&mut self, data: &Data) { + self.time.push(data.collection_time); + + if let Some(network) = &data.network { + self.rx.push(network.rx as f64); + self.tx.push(network.tx as f64); + } else { + self.rx.insert_break(); + self.tx.insert_break(); + } + + if let Some(cpu) = &data.cpu { + match self.cpu.len().cmp(&cpu.len()) { + Ordering::Less => { + let diff = cpu.len() - self.cpu.len(); + self.cpu.reserve_exact(diff); + + for _ in 0..diff { + self.cpu.push(Default::default()); + } + } + Ordering::Greater => { + let diff = self.cpu.len() - cpu.len(); + let offset = self.cpu.len() - diff; + + for curr in &mut self.cpu[offset..] { + curr.insert_break(); + } + } + Ordering::Equal => {} + } + + for (curr, new_data) in self.cpu.iter_mut().zip(cpu.iter()) { + curr.push(new_data.usage.into()); + } + } else { + for c in &mut self.cpu { + c.insert_break(); + } + } + + if let Some(memory) = &data.memory { + self.ram.push(memory.percentage()); + } else { + self.ram.insert_break(); + } + + if let Some(swap) = &data.swap { + self.swap.push(swap.percentage()); + } else { + self.swap.insert_break(); + } + + #[cfg(not(target_os = "windows"))] + { + if let Some(cache) = &data.cache { + self.cache_mem.push(cache.percentage()); + } else { + self.cache_mem.insert_break(); + } + } + + #[cfg(feature = "zfs")] + { + if let Some(arc) = &data.arc { + self.arc_mem.push(arc.percentage()); + } else { + self.arc_mem.insert_break(); + } + } + + #[cfg(feature = "gpu")] + { + if let Some(gpu) = &data.gpu { + let mut not_visited = self + .gpu_mem + .keys() + .map(String::to_owned) + .collect::>(); + + for (name, new_data) in gpu { + not_visited.remove(name); + + if !self.gpu_mem.contains_key(name) { + self.gpu_mem + .insert(name.to_string(), ChunkedData::default()); + } + + let curr = self + .gpu_mem + .get_mut(name) + .expect("entry must exist as it was created above"); + curr.push(new_data.percentage()); + } + + for nv in not_visited { + if let Some(entry) = self.gpu_mem.get_mut(&nv) { + entry.insert_break(); + } + } + } else { + for g in self.gpu_mem.values_mut() { + g.insert_break(); + } + } + } + } + + /// Prune any data older than the given duration. + pub fn prune(&mut self, max_age: Duration) { + if self.time.is_empty() { + return; + } + + let now = Instant::now(); + let end = { + let partition_point = self + .time + .partition_point(|then| now.duration_since(*then) > max_age); + + // Partition point returns the first index that does not match the predicate, so minus one. + if partition_point > 0 { + partition_point - 1 + } else { + // If the partition point was 0, then it means all values are too new to be pruned. + // crate::info!("Skipping prune."); + return; + } + }; + + // crate::info!("Pruning up to index {end}."); + + // Note that end here is _inclusive_. + self.time.drain(0..=end); + self.time.shrink_to_fit(); + + let _ = self.rx.prune_and_shrink_to_fit(end); + let _ = self.tx.prune_and_shrink_to_fit(end); + + for cpu in &mut self.cpu { + let _ = cpu.prune_and_shrink_to_fit(end); + } + + let _ = self.ram.prune_and_shrink_to_fit(end); + let _ = self.swap.prune_and_shrink_to_fit(end); + + #[cfg(not(target_os = "windows"))] + let _ = self.cache_mem.prune_and_shrink_to_fit(end); + + #[cfg(feature = "zfs")] + let _ = self.arc_mem.prune_and_shrink_to_fit(end); + + #[cfg(feature = "gpu")] + { + self.gpu_mem.retain(|_, gpu| { + let _ = gpu.prune(end); + + // Remove the entry if it is empty. We can always add it again later. + if gpu.no_elements() { + false + } else { + gpu.shrink_to_fit(); + true + } + }); + } + } +} diff --git a/src/app/data_farmer.rs b/src/app/data_farmer.rs deleted file mode 100644 index f756862a5..000000000 --- a/src/app/data_farmer.rs +++ /dev/null @@ -1,480 +0,0 @@ -//! In charge of cleaning, processing, and managing data. I couldn't think of -//! a better name for the file. Since I called data collection "harvesting", -//! then this is the farmer I guess. -//! -//! Essentially the main goal is to shift the initial calculation and -//! distribution of joiner points and data to one central location that will -//! only do it *once* upon receiving the data --- as opposed to doing it on -//! canvas draw, which will be a costly process. -//! -//! This will also handle the *cleaning* of stale data. That should be done -//! in some manner (timer on another thread, some loop) that will occasionally -//! call the purging function. Failure to do so *will* result in a growing -//! memory usage and higher CPU usage - you will be trying to process more and -//! more points as this is used! - -use std::{collections::BTreeMap, time::Instant, vec::Vec}; - -use hashbrown::HashMap; - -#[cfg(feature = "battery")] -use crate::data_collection::batteries; -use crate::{ - data_collection::{ - cpu, disks, memory, network, - processes::{Pid, ProcessHarvest}, - temperature, Data, - }, - utils::data_prefixes::*, -}; - -pub type TimeOffset = f64; -pub type Value = f64; - -#[derive(Debug, Default, Clone)] -pub struct TimedData { - pub rx_data: Value, - pub tx_data: Value, - pub cpu_data: Vec, - pub load_avg_data: [f32; 3], - pub mem_data: Option, - #[cfg(not(target_os = "windows"))] - pub cache_data: Option, - pub swap_data: Option, - #[cfg(feature = "zfs")] - pub arc_data: Option, - #[cfg(feature = "gpu")] - pub gpu_data: Vec>, -} - -#[derive(Clone, Debug, Default)] -pub struct ProcessData { - /// A PID to process data map. - pub process_harvest: BTreeMap, - - /// A mapping between a process PID to any children process PIDs. - pub process_parent_mapping: HashMap>, - - /// PIDs corresponding to processes that have no parents. - pub orphan_pids: Vec, -} - -impl ProcessData { - fn ingest(&mut self, list_of_processes: Vec) { - self.process_parent_mapping.clear(); - - // Reverse as otherwise the pid mappings are in the wrong order. - list_of_processes.iter().rev().for_each(|process_harvest| { - if let Some(parent_pid) = process_harvest.parent_pid { - if let Some(entry) = self.process_parent_mapping.get_mut(&parent_pid) { - entry.push(process_harvest.pid); - } else { - self.process_parent_mapping - .insert(parent_pid, vec![process_harvest.pid]); - } - } - }); - - self.process_parent_mapping.shrink_to_fit(); - - let process_pid_map = list_of_processes - .into_iter() - .map(|process| (process.pid, process)) - .collect(); - self.process_harvest = process_pid_map; - - // We collect all processes that either: - // - Do not have a parent PID (that is, they are orphan processes) - // - Have a parent PID but we don't have the parent (we promote them as orphans) - self.orphan_pids = self - .process_harvest - .iter() - .filter_map(|(pid, process_harvest)| match process_harvest.parent_pid { - Some(parent_pid) if self.process_harvest.contains_key(&parent_pid) => None, - _ => Some(*pid), - }) - .collect(); - } -} - -/// AppCollection represents the pooled data stored within the main app -/// thread. Basically stores a (occasionally cleaned) record of the data -/// collected, and what is needed to convert into a displayable form. -/// -/// If the app is *frozen* - that is, we do not want to *display* any changing -/// data, keep updating this. As of 2021-09-08, we just clone the current -/// collection when it freezes to have a snapshot floating around. -/// -/// Note that with this method, the *app* thread is responsible for cleaning - -/// not the data collector. -#[derive(Debug, Clone)] -pub struct DataCollection { - pub current_instant: Instant, - pub timed_data_vec: Vec<(Instant, TimedData)>, - pub network_harvest: network::NetworkHarvest, - pub memory_harvest: memory::MemHarvest, - #[cfg(not(target_os = "windows"))] - pub cache_harvest: memory::MemHarvest, - pub swap_harvest: memory::MemHarvest, - pub cpu_harvest: cpu::CpuHarvest, - pub load_avg_harvest: cpu::LoadAvgHarvest, - pub process_data: ProcessData, - pub disk_harvest: Vec, - pub io_harvest: disks::IoHarvest, - pub io_labels_and_prev: Vec<((u64, u64), (u64, u64))>, - pub io_labels: Vec<(String, String)>, - pub temp_harvest: Vec, - #[cfg(feature = "battery")] - pub battery_harvest: Vec, - #[cfg(feature = "zfs")] - pub arc_harvest: memory::MemHarvest, - #[cfg(feature = "gpu")] - pub gpu_harvest: Vec<(String, memory::MemHarvest)>, -} - -impl Default for DataCollection { - fn default() -> Self { - DataCollection { - current_instant: Instant::now(), - timed_data_vec: Vec::default(), - network_harvest: network::NetworkHarvest::default(), - memory_harvest: memory::MemHarvest::default(), - #[cfg(not(target_os = "windows"))] - cache_harvest: memory::MemHarvest::default(), - swap_harvest: memory::MemHarvest::default(), - cpu_harvest: cpu::CpuHarvest::default(), - load_avg_harvest: cpu::LoadAvgHarvest::default(), - process_data: Default::default(), - disk_harvest: Vec::default(), - io_harvest: disks::IoHarvest::default(), - io_labels_and_prev: Vec::default(), - io_labels: Vec::default(), - temp_harvest: Vec::default(), - #[cfg(feature = "battery")] - battery_harvest: Vec::default(), - #[cfg(feature = "zfs")] - arc_harvest: memory::MemHarvest::default(), - #[cfg(feature = "gpu")] - gpu_harvest: Vec::default(), - } - } -} - -impl DataCollection { - pub fn reset(&mut self) { - self.timed_data_vec = Vec::default(); - self.network_harvest = network::NetworkHarvest::default(); - self.memory_harvest = memory::MemHarvest::default(); - self.swap_harvest = memory::MemHarvest::default(); - self.cpu_harvest = cpu::CpuHarvest::default(); - self.process_data = Default::default(); - self.disk_harvest = Vec::default(); - self.io_harvest = disks::IoHarvest::default(); - self.io_labels_and_prev = Vec::default(); - self.temp_harvest = Vec::default(); - #[cfg(feature = "battery")] - { - self.battery_harvest = Vec::default(); - } - #[cfg(feature = "zfs")] - { - self.arc_harvest = memory::MemHarvest::default(); - } - #[cfg(feature = "gpu")] - { - self.gpu_harvest = Vec::default(); - } - } - - pub fn clean_data(&mut self, max_time_millis: u64) { - let current_time = Instant::now(); - - let remove_index = match self - .timed_data_vec - .binary_search_by(|(instant, _timed_data)| { - current_time - .duration_since(*instant) - .as_millis() - .cmp(&(max_time_millis.into())) - .reverse() - }) { - Ok(index) => index, - Err(index) => index, - }; - - self.timed_data_vec.drain(0..remove_index); - self.timed_data_vec.shrink_to_fit(); - } - - pub fn eat_data(&mut self, harvested_data: Box) { - let harvested_time = harvested_data.collection_time; - let mut new_entry = TimedData::default(); - - // Network - if let Some(network) = harvested_data.network { - self.eat_network(network, &mut new_entry); - } - - // Memory, Swap - if let (Some(memory), Some(swap)) = (harvested_data.memory, harvested_data.swap) { - self.eat_memory_and_swap(memory, swap, &mut new_entry); - } - - // Cache memory - #[cfg(not(target_os = "windows"))] - if let Some(cache) = harvested_data.cache { - self.eat_cache(cache, &mut new_entry); - } - - #[cfg(feature = "zfs")] - if let Some(arc) = harvested_data.arc { - self.eat_arc(arc, &mut new_entry); - } - - #[cfg(feature = "gpu")] - if let Some(gpu) = harvested_data.gpu { - self.eat_gpu(gpu, &mut new_entry); - } - - // CPU - if let Some(cpu) = harvested_data.cpu { - self.eat_cpu(cpu, &mut new_entry); - } - - // Load average - if let Some(load_avg) = harvested_data.load_avg { - self.eat_load_avg(load_avg, &mut new_entry); - } - - // Temp - if let Some(temperature_sensors) = harvested_data.temperature_sensors { - self.eat_temp(temperature_sensors); - } - - // Disks - if let Some(disks) = harvested_data.disks { - if let Some(io) = harvested_data.io { - self.eat_disks(disks, io, harvested_time); - } - } - - // Processes - if let Some(list_of_processes) = harvested_data.list_of_processes { - self.eat_proc(list_of_processes); - } - - #[cfg(feature = "battery")] - { - // Battery - if let Some(list_of_batteries) = harvested_data.list_of_batteries { - self.eat_battery(list_of_batteries); - } - } - - // And we're done eating. Update time and push the new entry! - self.current_instant = harvested_time; - self.timed_data_vec.push((harvested_time, new_entry)); - } - - fn eat_memory_and_swap( - &mut self, memory: memory::MemHarvest, swap: memory::MemHarvest, new_entry: &mut TimedData, - ) { - // Memory - new_entry.mem_data = memory.use_percent; - - // Swap - new_entry.swap_data = swap.use_percent; - - // In addition copy over latest data for easy reference - self.memory_harvest = memory; - self.swap_harvest = swap; - } - - #[cfg(not(target_os = "windows"))] - fn eat_cache(&mut self, cache: memory::MemHarvest, new_entry: &mut TimedData) { - // Cache and buffer memory - new_entry.cache_data = cache.use_percent; - - // In addition copy over latest data for easy reference - self.cache_harvest = cache; - } - - fn eat_network(&mut self, network: network::NetworkHarvest, new_entry: &mut TimedData) { - // RX - if network.rx > 0 { - new_entry.rx_data = network.rx as f64; - } - - // TX - if network.tx > 0 { - new_entry.tx_data = network.tx as f64; - } - - // In addition copy over latest data for easy reference - self.network_harvest = network; - } - - fn eat_cpu(&mut self, cpu: Vec, new_entry: &mut TimedData) { - // Note this only pre-calculates the data points - the names will be - // within the local copy of cpu_harvest. Since it's all sequential - // it probably doesn't matter anyways. - cpu.iter() - .for_each(|cpu| new_entry.cpu_data.push(cpu.cpu_usage)); - - self.cpu_harvest = cpu; - } - - fn eat_load_avg(&mut self, load_avg: cpu::LoadAvgHarvest, new_entry: &mut TimedData) { - new_entry.load_avg_data = load_avg; - - self.load_avg_harvest = load_avg; - } - - fn eat_temp(&mut self, temperature_sensors: Vec) { - self.temp_harvest = temperature_sensors; - } - - fn eat_disks( - &mut self, disks: Vec, io: disks::IoHarvest, harvested_time: Instant, - ) { - let time_since_last_harvest = harvested_time - .duration_since(self.current_instant) - .as_secs_f64(); - - for (itx, device) in disks.iter().enumerate() { - let checked_name = { - #[cfg(target_os = "windows")] - { - match &device.volume_name { - Some(volume_name) => Some(volume_name.as_str()), - None => device.name.split('/').last(), - } - } - #[cfg(not(target_os = "windows"))] - { - #[cfg(feature = "zfs")] - { - if !device.name.starts_with('/') { - Some(device.name.as_str()) // use the whole zfs - // dataset name - } else { - device.name.split('/').last() - } - } - #[cfg(not(feature = "zfs"))] - { - device.name.split('/').last() - } - } - }; - - if let Some(checked_name) = checked_name { - let io_device = { - #[cfg(target_os = "macos")] - { - use std::sync::OnceLock; - - use regex::Regex; - - // Must trim one level further for macOS! - static DISK_REGEX: OnceLock = OnceLock::new(); - if let Some(new_name) = DISK_REGEX - .get_or_init(|| Regex::new(r"disk\d+").unwrap()) - .find(checked_name) - { - io.get(new_name.as_str()) - } else { - None - } - } - #[cfg(not(target_os = "macos"))] - { - io.get(checked_name) - } - }; - - if let Some(io_device) = io_device { - let (io_r_pt, io_w_pt) = if let Some(io) = io_device { - (io.read_bytes, io.write_bytes) - } else { - (0, 0) - }; - - if self.io_labels.len() <= itx { - self.io_labels.push((String::default(), String::default())); - } - - if self.io_labels_and_prev.len() <= itx { - self.io_labels_and_prev.push(((0, 0), (io_r_pt, io_w_pt))); - } - - if let Some((io_curr, io_prev)) = self.io_labels_and_prev.get_mut(itx) { - let r_rate = ((io_r_pt.saturating_sub(io_prev.0)) as f64 - / time_since_last_harvest) - .round() as u64; - let w_rate = ((io_w_pt.saturating_sub(io_prev.1)) as f64 - / time_since_last_harvest) - .round() as u64; - - *io_curr = (r_rate, w_rate); - *io_prev = (io_r_pt, io_w_pt); - - if let Some(io_labels) = self.io_labels.get_mut(itx) { - let converted_read = get_decimal_bytes(r_rate); - let converted_write = get_decimal_bytes(w_rate); - *io_labels = ( - if r_rate >= GIGA_LIMIT { - format!("{:.*}{}/s", 1, converted_read.0, converted_read.1) - } else { - format!("{:.*}{}/s", 0, converted_read.0, converted_read.1) - }, - if w_rate >= GIGA_LIMIT { - format!("{:.*}{}/s", 1, converted_write.0, converted_write.1) - } else { - format!("{:.*}{}/s", 0, converted_write.0, converted_write.1) - }, - ); - } - } - } else { - if self.io_labels.len() <= itx { - self.io_labels.push((String::default(), String::default())); - } - - if let Some(io_labels) = self.io_labels.get_mut(itx) { - *io_labels = ("N/A".to_string(), "N/A".to_string()); - } - } - } - } - - self.disk_harvest = disks; - self.io_harvest = io; - } - - fn eat_proc(&mut self, list_of_processes: Vec) { - self.process_data.ingest(list_of_processes); - } - - #[cfg(feature = "battery")] - fn eat_battery(&mut self, list_of_batteries: Vec) { - self.battery_harvest = list_of_batteries; - } - - #[cfg(feature = "zfs")] - fn eat_arc(&mut self, arc: memory::MemHarvest, new_entry: &mut TimedData) { - new_entry.arc_data = arc.use_percent; - self.arc_harvest = arc; - } - - #[cfg(feature = "gpu")] - fn eat_gpu(&mut self, gpu: Vec<(String, memory::MemHarvest)>, new_entry: &mut TimedData) { - // Note this only pre-calculates the data points - the names will be - // within the local copy of gpu_harvest. Since it's all sequential - // it probably doesn't matter anyways. - gpu.iter().for_each(|data| { - new_entry.gpu_data.push(data.1.use_percent); - }); - self.gpu_harvest = gpu; - } -} diff --git a/src/app/filter.rs b/src/app/filter.rs index c313c0b63..05e37b3e5 100644 --- a/src/app/filter.rs +++ b/src/app/filter.rs @@ -1,21 +1,32 @@ +use regex::Regex; + /// Filters used by widgets to filter out certain entries. /// TODO: Move this out maybe? #[derive(Debug, Clone)] pub struct Filter { /// Whether the filter _accepts_ all entries that match `list`, /// or _denies_ any entries that match it. - pub is_list_ignored: bool, // TODO: Maybe change to "ignore_matches"? + is_list_ignored: bool, // TODO: Maybe change to "ignore_matches"? /// The list of regexes to match against. Whether it goes through /// the filter or not depends on `is_list_ignored`. - pub list: Vec, + list: Vec, } impl Filter { + /// Create a new filter. + #[inline] + pub(crate) fn new(ignore_matches: bool, list: Vec) -> Self { + Self { + is_list_ignored: ignore_matches, + list, + } + } + /// Whether the filter should keep the entry or reject it. #[inline] - pub(crate) fn keep_entry(&self, value: &str) -> bool { - if self.has_match(value) { + pub(crate) fn should_keep(&self, entry: &str) -> bool { + if self.has_match(entry) { // If a match is found, then if we wanted to ignore if we match, return false. // If we want to keep if we match, return true. Thus, return the // inverse of `is_list_ignored`. @@ -30,6 +41,21 @@ impl Filter { pub(crate) fn has_match(&self, value: &str) -> bool { self.list.iter().any(|regex| regex.is_match(value)) } + + /// Whether entries matching the list should be ignored or kept. + #[inline] + pub(crate) fn ignore_matches(&self) -> bool { + self.is_list_ignored + } + + /// Check a filter if it exists, otherwise accept if it is [`None`]. + #[inline] + pub(crate) fn optional_should_keep(filter: &Option, entry: &str) -> bool { + filter + .as_ref() + .map(|f| f.should_keep(entry)) + .unwrap_or(true) + } } #[cfg(test)] @@ -56,7 +82,7 @@ mod test { assert_eq!( results .into_iter() - .filter(|r| ignore_true.keep_entry(r)) + .filter(|r| ignore_true.should_keep(r)) .collect::>(), vec!["wifi_0", "amd gpu"] ); @@ -69,7 +95,7 @@ mod test { assert_eq!( results .into_iter() - .filter(|r| ignore_false.keep_entry(r)) + .filter(|r| ignore_false.should_keep(r)) .collect::>(), vec!["CPU socket temperature", "motherboard temperature"] ); @@ -85,7 +111,7 @@ mod test { assert_eq!( results .into_iter() - .filter(|r| multi_true.keep_entry(r)) + .filter(|r| multi_true.should_keep(r)) .collect::>(), vec!["wifi_0", "amd gpu"] ); @@ -101,7 +127,7 @@ mod test { assert_eq!( results .into_iter() - .filter(|r| multi_false.keep_entry(r)) + .filter(|r| multi_false.should_keep(r)) .collect::>(), vec!["CPU socket temperature", "motherboard temperature"] ); diff --git a/src/app/frozen_state.rs b/src/app/frozen_state.rs deleted file mode 100644 index fc828311a..000000000 --- a/src/app/frozen_state.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::DataCollection; - -/// The [`FrozenState`] indicates whether the application state should be -/// frozen. It is either not frozen or frozen and containing a copy of the state -/// at the time. -pub enum FrozenState { - NotFrozen, - Frozen(Box), -} - -impl Default for FrozenState { - fn default() -> Self { - Self::NotFrozen - } -} - -pub type IsFrozen = bool; - -impl FrozenState { - /// Checks whether the [`FrozenState`] is currently frozen. - pub fn is_frozen(&self) -> IsFrozen { - matches!(self, FrozenState::Frozen(_)) - } - - /// Freezes the [`FrozenState`]. - pub fn freeze(&mut self, data: Box) { - *self = FrozenState::Frozen(data); - } - - /// Unfreezes the [`FrozenState`]. - pub fn thaw(&mut self) { - *self = FrozenState::NotFrozen; - } - - /// Toggles the [`FrozenState`] and returns whether it is now frozen. - pub fn toggle(&mut self, data: &DataCollection) -> IsFrozen { - if self.is_frozen() { - self.thaw(); - false - } else { - // Could we use an Arc instead? Is it worth it? - self.freeze(Box::new(data.clone())); - true - } - } -} diff --git a/src/app/layout_manager.rs b/src/app/layout_manager.rs index e125726fa..87a9830e2 100644 --- a/src/app/layout_manager.rs +++ b/src/app/layout_manager.rs @@ -19,7 +19,7 @@ type ColumnMappings = (u32, BTreeMap); impl BottomLayout { pub fn get_movement_mappings(&mut self) { - #[allow(clippy::suspicious_operation_groupings)] // Have to enable this, clippy really doesn't like me doing this with tuples... + #[expect(clippy::suspicious_operation_groupings)] // Have to enable this, clippy really doesn't like me doing this with tuples... fn is_intersecting(a: LineSegment, b: LineSegment) -> bool { a.0 >= b.0 && a.1 <= b.1 || a.1 >= b.1 && a.0 <= b.0 @@ -663,21 +663,20 @@ impl BottomLayout { BottomLayout { total_row_height_ratio: 3, rows: vec![ - BottomRow::new(vec![BottomCol::new(vec![ - BottomColRow::new(vec![cpu]).canvas_handled() + BottomRow::new(vec![ + BottomCol::new(vec![BottomColRow::new(vec![cpu]).canvas_handled()]) + .canvas_handled(), ]) - .canvas_handled()]) .canvas_handled(), - BottomRow::new(vec![BottomCol::new(vec![BottomColRow::new(vec![ - mem, net, + BottomRow::new(vec![ + BottomCol::new(vec![BottomColRow::new(vec![mem, net]).canvas_handled()]) + .canvas_handled(), ]) - .canvas_handled()]) - .canvas_handled()]) .canvas_handled(), - BottomRow::new(vec![BottomCol::new(vec![ - BottomColRow::new(vec![table]).canvas_handled() + BottomRow::new(vec![ + BottomCol::new(vec![BottomColRow::new(vec![table]).canvas_handled()]) + .canvas_handled(), ]) - .canvas_handled()]) .canvas_handled(), BottomRow::new(table_widgets).canvas_handled(), ], @@ -745,11 +744,6 @@ impl BottomRow { self.constraint = IntermediaryConstraint::CanvasHandled { ratio: None }; self } - - pub fn grow(mut self, minimum: Option) -> Self { - self.constraint = IntermediaryConstraint::Grow { minimum }; - self - } } /// Represents a single column in the layout. We assume that even if the column @@ -785,11 +779,6 @@ impl BottomCol { self.constraint = IntermediaryConstraint::CanvasHandled { ratio: None }; self } - - pub fn grow(mut self, minimum: Option) -> Self { - self.constraint = IntermediaryConstraint::Grow { minimum }; - self - } } #[derive(Clone, Default, Debug)] diff --git a/src/app/states.rs b/src/app/states.rs index 28ce85c84..be7ad254b 100644 --- a/src/app/states.rs +++ b/src/app/states.rs @@ -1,4 +1,4 @@ -use std::{ops::Range, time::Instant}; +use std::ops::Range; use hashbrown::HashMap; use indexmap::IndexMap; @@ -6,11 +6,11 @@ use unicode_ellipsis::grapheme_width; use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete, UnicodeSegmentation}; use crate::{ - app::{layout_manager::BottomWidgetType, query::*}, + app::layout_manager::BottomWidgetType, constants, widgets::{ BatteryWidgetState, CpuWidgetState, DiskTableWidget, MemWidgetState, NetWidgetState, - ProcWidgetState, TempWidgetState, + ProcWidgetState, TempWidgetState, query::ProcessQuery, }, }; @@ -21,7 +21,7 @@ pub struct AppWidgetStates { pub proc_state: ProcState, pub temp_state: TempState, pub disk_state: DiskState, - pub battery_state: BatteryState, + pub battery_state: AppBatteryState, pub basic_table_widget_state: Option, } @@ -31,34 +31,6 @@ pub enum CursorDirection { Right, } -#[derive(PartialEq, Eq)] -pub enum KillSignal { - Cancel, - Kill(usize), -} - -impl Default for KillSignal { - #[cfg(target_family = "unix")] - fn default() -> Self { - KillSignal::Kill(15) - } - #[cfg(target_os = "windows")] - fn default() -> Self { - KillSignal::Kill(1) - } -} - -#[derive(Default)] -pub struct AppDeleteDialogState { - pub is_showing_dd: bool, - pub selected_signal: KillSignal, - /// tl x, tl y, br x, br y, index/signal - pub button_positions: Vec<(u16, u16, u16, u16, usize)>, - pub keyboard_signal_select: usize, - pub last_number_press: Option, - pub scroll_pos: usize, -} - pub struct AppHelpDialogState { pub is_showing_help: bool, pub height: u16, @@ -90,7 +62,7 @@ pub struct AppSearchState { pub size_mappings: IndexMap>, /// The query. TODO: Merge this as one enum. - pub query: Option, + pub query: Option, pub error_message: Option, } @@ -285,38 +257,22 @@ impl ProcState { } pub struct NetState { - pub force_update: Option, pub widget_states: HashMap, } impl NetState { pub fn init(widget_states: HashMap) -> Self { - NetState { - force_update: None, - widget_states, - } - } - - pub fn get_mut_widget_state(&mut self, widget_id: u64) -> Option<&mut NetWidgetState> { - self.widget_states.get_mut(&widget_id) - } - - pub fn get_widget_state(&self, widget_id: u64) -> Option<&NetWidgetState> { - self.widget_states.get(&widget_id) + NetState { widget_states } } } pub struct CpuState { - pub force_update: Option, pub widget_states: HashMap, } impl CpuState { pub fn init(widget_states: HashMap) -> Self { - CpuState { - force_update: None, - widget_states, - } + CpuState { widget_states } } pub fn get_mut_widget_state(&mut self, widget_id: u64) -> Option<&mut CpuWidgetState> { @@ -329,24 +285,12 @@ impl CpuState { } pub struct MemState { - pub force_update: Option, pub widget_states: HashMap, } impl MemState { pub fn init(widget_states: HashMap) -> Self { - MemState { - force_update: None, - widget_states, - } - } - - pub fn get_mut_widget_state(&mut self, widget_id: u64) -> Option<&mut MemWidgetState> { - self.widget_states.get_mut(&widget_id) - } - - pub fn get_widget_state(&self, widget_id: u64) -> Option<&MemWidgetState> { - self.widget_states.get(&widget_id) + MemState { widget_states } } } @@ -391,29 +335,24 @@ pub struct BasicTableWidgetState { // then we can expand outwards with a normal BasicTableState and a hashmap pub currently_displayed_widget_type: BottomWidgetType, pub currently_displayed_widget_id: u64, - pub widget_id: i64, pub left_tlc: Option<(u16, u16)>, pub left_brc: Option<(u16, u16)>, pub right_tlc: Option<(u16, u16)>, pub right_brc: Option<(u16, u16)>, } -pub struct BatteryState { +pub struct AppBatteryState { pub widget_states: HashMap, } -impl BatteryState { +impl AppBatteryState { pub fn init(widget_states: HashMap) -> Self { - BatteryState { widget_states } + AppBatteryState { widget_states } } pub fn get_mut_widget_state(&mut self, widget_id: u64) -> Option<&mut BatteryWidgetState> { self.widget_states.get_mut(&widget_id) } - - pub fn get_widget_state(&self, widget_id: u64) -> Option<&BatteryWidgetState> { - self.widget_states.get(&widget_id) - } } #[derive(Default)] diff --git a/src/bin/main.rs b/src/bin/main.rs new file mode 100644 index 000000000..cf07a69bf --- /dev/null +++ b/src/bin/main.rs @@ -0,0 +1,13 @@ +//! Main entrypoint for the application. + +use bottom::{reset_stdout, start_bottom}; + +fn main() -> anyhow::Result<()> { + let mut run_error_hook = false; + + start_bottom(&mut run_error_hook).inspect_err(|_| { + if run_error_hook { + reset_stdout(); + } + }) +} diff --git a/src/bin/schema.rs b/src/bin/schema.rs new file mode 100644 index 000000000..f6efe7431 --- /dev/null +++ b/src/bin/schema.rs @@ -0,0 +1,98 @@ +#![cfg(feature = "generate_schema")] + +use bottom::{options::config, widgets}; +use clap::Parser; +use itertools::Itertools; +use serde_json::Value; +use strum::VariantArray; + +#[derive(Parser)] +struct SchemaOptions { + /// The version of the schema. + version: Option, +} + +fn generate_schema(schema_options: SchemaOptions) -> anyhow::Result<()> { + let mut schema = schemars::schema_for!(config::Config); + { + // TODO: Maybe make this case insensitive? See https://stackoverflow.com/a/68639341 + + match schema + .as_object_mut() + .unwrap() + .get_mut("$defs") + .unwrap() + .get_mut("ProcColumn") + .unwrap() + { + Value::Object(proc_columns) => { + let enums = proc_columns.get_mut("enum").unwrap(); + *enums = widgets::ProcColumn::VARIANTS + .iter() + .flat_map(|var| var.get_schema_names()) + .sorted() + .map(|v| serde_json::Value::String(v.to_string())) + .dedup() + .collect(); + } + _ => anyhow::bail!("missing proc columns definition"), + } + + match schema + .as_object_mut() + .unwrap() + .get_mut("$defs") + .unwrap() + .get_mut("DiskColumn") + .unwrap() + { + Value::Object(disk_columns) => { + let enums = disk_columns.get_mut("enum").unwrap(); + *enums = widgets::DiskColumn::VARIANTS + .iter() + .flat_map(|var| var.get_schema_names()) + .sorted() + .map(|v| serde_json::Value::String(v.to_string())) + .dedup() + .collect(); + } + _ => anyhow::bail!("missing disk columns definition"), + } + } + + let version = schema_options.version.unwrap_or("nightly".to_string()); + schema.insert( + "$id".into(), + format!("https://github.com/ClementTsang/bottom/blob/main/schema/%7Bversion%7D/bottom.json") + .into(), + ); + + schema.insert( + "description".into(), + format!( + "https://bottom.pages.dev/%7B%7D/configuration/config-file/", + if version == "nightly" { + "nightly" + } else { + "stable" + } + ) + .into(), + ); + + schema.insert( + "title".into(), + format!("Schema for bottom's config file ({version})").into(), + ); + + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); + + Ok(()) +} + +fn main() -> anyhow::Result<()> { + let schema_options = SchemaOptions::parse(); + generate_schema(schema_options)?; + + Ok(()) +} diff --git a/src/canvas.rs b/src/canvas.rs index 3ecc446f1..0a93f5a58 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -1,29 +1,34 @@ +//! Code related to drawing. +//! +//! Note that eventually this should not contain any widget-specific draw code, but rather just generic code +//! or components. + pub mod components; -mod dialogs; +pub mod dialogs; mod drawing_utils; mod widgets; use itertools::izip; use tui::{ + Frame, Terminal, backend::Backend, layout::{Constraint, Direction, Layout, Rect}, text::Span, widgets::Paragraph, - Frame, Terminal, }; use crate::{ app::{ - layout_manager::{BottomColRow, BottomLayout, BottomWidgetType, IntermediaryConstraint}, App, + layout_manager::{BottomColRow, BottomLayout, BottomWidgetType, IntermediaryConstraint}, }, constants::*, - options::config::style::ColourPalette, + options::config::style::Styles, }; /// Handles the canvas' state. pub struct Painter { - pub colours: ColourPalette, + pub styles: Styles, previous_height: u16, previous_width: u16, @@ -47,7 +52,7 @@ pub enum LayoutConstraint { } impl Painter { - pub fn init(layout: BottomLayout, styling: ColourPalette) -> anyhow::Result { + pub fn init(layout: BottomLayout, styling: Styles) -> anyhow::Result { // Now for modularity; we have to also initialize the base layouts! // We want to do this ONCE and reuse; after this we can just construct // based on the console size. @@ -131,7 +136,7 @@ impl Painter { }); let painter = Painter { - colours: styling, + styles: styling, previous_height: 0, previous_width: 0, row_constraints, @@ -149,9 +154,9 @@ impl Painter { pub fn get_border_style(&self, widget_id: u64, selected_widget_id: u64) -> tui::style::Style { let is_on_widget = widget_id == selected_widget_id; if is_on_widget { - self.colours.highlighted_border_style + self.styles.highlighted_border_style } else { - self.colours.border_style + self.styles.border_style } } @@ -159,7 +164,7 @@ impl Painter { f.render_widget( Paragraph::new(Span::styled( "Frozen, press 'f' to unfreeze", - self.colours.selected_text_style, + self.styles.selected_text_style, )), Layout::default() .horizontal_margin(1) @@ -174,14 +179,14 @@ impl Painter { use BottomWidgetType::*; terminal.draw(|f| { - let (terminal_size, frozen_draw_loc) = if app_state.frozen_state.is_frozen() { + let (terminal_size, frozen_draw_loc) = if app_state.data_store.is_frozen() { // TODO: Remove built-in cache? let split_loc = Layout::default() .constraints([Constraint::Min(0), Constraint::Length(1)]) - .split(f.size()); + .split(f.area()); (split_loc[0], Some(split_loc[1])) } else { - (f.size(), None) + (f.area(), None) }; let terminal_height = terminal_size.height; let terminal_width = terminal_size.width; @@ -195,6 +200,7 @@ impl Painter { self.previous_width = terminal_width; } + // TODO: We should probably remove this or make it done elsewhere, not the responsibility of the app. if app_state.should_get_widget_bounds() { // If we're force drawing, reset ALL mouse boundaries. for widget in app_state.widget_map.values_mut() { @@ -202,15 +208,16 @@ impl Painter { widget.bottom_right_corner = None; } - // Reset dd_dialog... - app_state.delete_dialog_state.button_positions = vec![]; + // Reset process kill dialog button locations... + app_state.process_kill_dialog.handle_redraw(); - // Reset battery dialog... + // Reset battery dialog button locations... for battery_widget in app_state.states.battery_state.widget_states.values_mut() { battery_widget.tab_click_locs = None; } } + // TODO: Make drawing dialog generic. if app_state.help_dialog_state.is_showing_help { let gen_help_len = GENERAL_HELP_TEXT.len() as u16 + 3; let border_len = terminal_height.saturating_sub(gen_help_len) / 2; @@ -243,46 +250,32 @@ impl Painter { .split(vertical_dialog_chunk[1]); self.draw_help_dialog(f, app_state, middle_dialog_chunk[1]); - } else if app_state.delete_dialog_state.is_showing_dd { - let dd_text = self.get_dd_spans(app_state); - - let text_width = if terminal_width < 100 { - terminal_width * 90 / 100 - } else { - terminal_width * 50 / 100 - }; + } else if app_state.process_kill_dialog.is_open() { + // FIXME: For width, just limit to a max size or full width. For height, not sure. Maybe pass max and let child handle? + let horizontal_padding = if terminal_width < 100 { 0 } else { 5 }; + let vertical_padding = if terminal_height < 100 { 0 } else { 5 }; - let text_height = if cfg!(target_os = "windows") - || !app_state.app_config_fields.is_advanced_kill - { - 7 - } else { - 22 - }; - - let vertical_bordering = terminal_height.saturating_sub(text_height) / 2; let vertical_dialog_chunk = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(vertical_bordering), - Constraint::Length(text_height), - Constraint::Length(vertical_bordering), + Constraint::Length(vertical_padding), + Constraint::Fill(1), + Constraint::Length(vertical_padding), ]) - .split(terminal_size); + .areas::<3>(terminal_size)[1]; - let horizontal_bordering = terminal_width.saturating_sub(text_width) / 2; - let middle_dialog_chunk = Layout::default() + let dialog_draw_area = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Length(horizontal_bordering), - Constraint::Length(text_width), - Constraint::Length(horizontal_bordering), + Constraint::Length(horizontal_padding), + Constraint::Fill(1), + Constraint::Length(horizontal_padding), ]) - .split(vertical_dialog_chunk[1]); + .areas::<3>(vertical_dialog_chunk)[1]; - // This is a bit nasty, but it works well... I guess. - app_state.delete_dialog_state.is_showing_dd = - self.draw_dd_dialog(f, dd_text, app_state, middle_dialog_chunk[1]); + app_state + .process_kill_dialog + .draw(f, dialog_draw_area, &self.styles); } else if app_state.is_expanded { if let Some(frozen_draw_loc) = frozen_draw_loc { self.draw_frozen_indicator(f, frozen_draw_loc); @@ -333,64 +326,61 @@ impl Painter { _ => 0, }; - self.draw_process(f, app_state, rect[0], true, widget_id); + self.draw_process(f, app_state, rect[0], widget_id); + } + Battery => + { + #[cfg(feature = "battery")] + self.draw_battery(f, app_state, rect[0], app_state.current_widget.widget_id) } - Battery => self.draw_battery( - f, - app_state, - rect[0], - true, - app_state.current_widget.widget_id, - ), _ => {} } } else if app_state.app_config_fields.use_basic_mode { - // Basic mode. This basically removes all graphs but otherwise + // Basic mode. This basically removes all graphs but otherwise // the same info. if let Some(frozen_draw_loc) = frozen_draw_loc { self.draw_frozen_indicator(f, frozen_draw_loc); } - let actual_cpu_data_len = app_state.converted_data.cpu_data.len().saturating_sub(1); + let data = app_state.data_store.get_data(); + let actual_cpu_data_len = data.cpu_harvest.len(); // This fixes #397, apparently if the height is 1, it can't render the CPU // bars... let cpu_height = { - let c = - (actual_cpu_data_len / 4) as u16 + u16::from(actual_cpu_data_len % 4 != 0); - - if c <= 1 { - 1 - } else { - c - } + let c = (actual_cpu_data_len / 4) as u16 + + u16::from(actual_cpu_data_len % 4 != 0) + + u16::from( + app_state.app_config_fields.dedicated_average_row + && actual_cpu_data_len.saturating_sub(1) % 4 != 0, + ); + + if c <= 1 { 1 } else { c } }; let mut mem_rows = 1; - if app_state.converted_data.swap_labels.is_some() { + if data.swap_harvest.is_some() { mem_rows += 1; // add row for swap } #[cfg(feature = "zfs")] { - if app_state.converted_data.arc_labels.is_some() { + if data.arc_harvest.is_some() { mem_rows += 1; // add row for arc } } #[cfg(not(target_os = "windows"))] { - if app_state.converted_data.cache_labels.is_some() { + if data.cache_harvest.is_some() { mem_rows += 1; } } #[cfg(feature = "gpu")] { - if let Some(gpu_data) = &app_state.converted_data.gpu_data { - mem_rows += gpu_data.len() as u16; // add row(s) for gpu - } + mem_rows += data.gpu_harvest.len() as u16; // add row(s) for gpu } if mem_rows == 1 { @@ -440,18 +430,16 @@ impl Painter { ProcSort => 2, _ => 0, }; - self.draw_process(f, app_state, vertical_chunks[3], false, wid); + self.draw_process(f, app_state, vertical_chunks[3], wid); } Temp => { self.draw_temp_table(f, app_state, vertical_chunks[3], widget_id) } - Battery => self.draw_battery( - f, - app_state, - vertical_chunks[3], - false, - widget_id, - ), + Battery => + { + #[cfg(feature = "battery")] + self.draw_battery(f, app_state, vertical_chunks[3], widget_id) + } _ => {} } } @@ -725,8 +713,12 @@ impl Painter { Net => self.draw_network(f, app_state, *draw_loc, widget.widget_id), Temp => self.draw_temp_table(f, app_state, *draw_loc, widget.widget_id), Disk => self.draw_disk_table(f, app_state, *draw_loc, widget.widget_id), - Proc => self.draw_process(f, app_state, *draw_loc, true, widget.widget_id), - Battery => self.draw_battery(f, app_state, *draw_loc, true, widget.widget_id), + Proc => self.draw_process(f, app_state, *draw_loc, widget.widget_id), + Battery => + { + #[cfg(feature = "battery")] + self.draw_battery(f, app_state, *draw_loc, widget.widget_id) + } _ => {} } } diff --git a/src/canvas/components.rs b/src/canvas/components.rs deleted file mode 100644 index 05a63112b..000000000 --- a/src/canvas/components.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Lower-level components used throughout bottom. - -pub mod data_table; -pub mod time_graph; -mod tui_widget; -pub mod widget_carousel; - -pub use tui_widget::*; diff --git a/src/canvas/components/data_table.rs b/src/canvas/components/data_table.rs index ba92e077c..c2751886b 100644 --- a/src/canvas/components/data_table.rs +++ b/src/canvas/components/data_table.rs @@ -29,6 +29,8 @@ use crate::utils::general::ClampExt; /// - [`Sortable`]: This table expects sorted data, and there are helper /// functions to facilitate things like sorting based on a selected column, /// shortcut column selection support, mouse column selection support, etc. +/// +/// FIXME: We already do all the text width checks - can we skip the underlying ones? pub struct DataTable> { pub columns: Vec, pub state: DataTableState, @@ -69,13 +71,13 @@ impl, H: ColumnHeader, S: SortType, C: DataTableColumn, H: ColumnHeader, S: SortType, C: DataTableColumn Option { - let max_index = self.data.len(); - let current_index = self.state.current_index; + let num_entries = self.data.len(); - if change == 0 - || (change > 0 && current_index == max_index) - || (change < 0 && current_index == 0) - { + if num_entries == 0 { return None; } - let csp: Result = self.state.current_index.try_into(); - if let Ok(csp) = csp { - let proposed: Result = (csp + change).try_into(); - if let Ok(proposed) = proposed { - if proposed < self.data.len() { - self.state.current_index = proposed; - self.state.scroll_direction = if change < 0 { - ScrollDirection::Up - } else { - ScrollDirection::Down - }; - - return Some(self.state.current_index); - } - } - } + let Ok(current_index): Result = self.state.current_index.try_into() else { + return None; + }; + + // We do this to clamp the proposed index to 0 if the change is greater + // than the number of entries left from the current index. This gives + // a more intuitive behaviour when using things like page up/down. + let proposed = current_index + change; - None + // We check num_entries > 0 above. + self.state.current_index = proposed.clamp(0, (num_entries - 1) as i64) as usize; + + self.state.scroll_direction = if change < 0 { + ScrollDirection::Up + } else { + ScrollDirection::Down + }; + + Some(self.state.current_index) } /// Updates the scroll position to a selected index. - #[allow(clippy::comparison_chain)] + #[expect(clippy::comparison_chain)] pub fn set_position(&mut self, new_index: usize) { let new_index = new_index.clamp_upper(self.data.len().saturating_sub(1)); if self.state.current_index < new_index { @@ -181,8 +183,7 @@ mod test { } } - #[test] - fn test_data_table_operations() { + fn create_test_table() -> DataTable { let columns = [Column::hard("a", 10), Column::hard("b", 10)]; let props = DataTableProps { title: Some("test".into()), @@ -194,16 +195,27 @@ mod test { }; let styling = DataTableStyling::default(); - let mut table = DataTable::new(columns, props, styling); + DataTable::new(columns, props, styling) + } + + #[test] + fn test_scrolling() { + let mut table = create_test_table(); table.set_data((0..=4).map(|index| TestType { index }).collect::>()); - table.to_last(); + table.scroll_to_last(); assert_eq!(table.current_index(), 4); assert_eq!(table.state.scroll_direction, ScrollDirection::Down); - table.to_first(); + table.scroll_to_first(); assert_eq!(table.current_index(), 0); assert_eq!(table.state.scroll_direction, ScrollDirection::Up); + } + + #[test] + fn test_set_position() { + let mut table = create_test_table(); + table.set_data((0..=4).map(|index| TestType { index }).collect::>()); table.set_position(4); assert_eq!(table.current_index(), 4); @@ -213,6 +225,16 @@ mod test { assert_eq!(table.current_index(), 4); assert_eq!(table.state.scroll_direction, ScrollDirection::Down); assert_eq!(table.current_item(), Some(&TestType { index: 4 })); + } + + #[test] + fn test_increment_position() { + let mut table = create_test_table(); + table.set_data((0..=4).map(|index| TestType { index }).collect::>()); + + table.set_position(4); + assert_eq!(table.current_index(), 4); + assert_eq!(table.state.scroll_direction, ScrollDirection::Down); table.increment_position(-1); assert_eq!(table.current_index(), 3); @@ -244,6 +266,30 @@ mod test { assert_eq!(table.state.scroll_direction, ScrollDirection::Down); assert_eq!(table.current_item(), Some(&TestType { index: 4 })); + // Make sure that overscrolling up causes clamping. + table.increment_position(-10); + assert_eq!(table.current_index(), 0); + assert_eq!(table.state.scroll_direction, ScrollDirection::Up); + assert_eq!(table.current_item(), Some(&TestType { index: 0 })); + + // Make sure that overscrolling down causes clamping. + table.increment_position(100); + assert_eq!(table.current_index(), 4); + assert_eq!(table.state.scroll_direction, ScrollDirection::Down); + assert_eq!(table.current_item(), Some(&TestType { index: 4 })); + } + + /// A test to ensure that scroll offsets are correctly handled when we "lose" rows. + #[test] + fn test_lose_data() { + let mut table = create_test_table(); + table.set_data((0..=4).map(|index| TestType { index }).collect::>()); + + table.set_position(4); + assert_eq!(table.current_index(), 4); + assert_eq!(table.state.scroll_direction, ScrollDirection::Down); + assert_eq!(table.current_item(), Some(&TestType { index: 4 })); + table.set_data((0..=2).map(|index| TestType { index }).collect::>()); assert_eq!(table.current_index(), 2); assert_eq!(table.state.scroll_direction, ScrollDirection::Down); diff --git a/src/canvas/components/data_table/column.rs b/src/canvas/components/data_table/column.rs index 556fbbbd3..8e547d805 100644 --- a/src/canvas/components/data_table/column.rs +++ b/src/canvas/components/data_table/column.rs @@ -62,8 +62,6 @@ pub trait DataTableColumn { fn is_hidden(&self) -> bool; - fn set_is_hidden(&mut self, is_hidden: bool); - /// The actually displayed "header". fn header(&self) -> Cow<'static, str>; @@ -114,25 +112,12 @@ impl DataTableColumn for Column { self.is_hidden } - #[inline] - fn set_is_hidden(&mut self, is_hidden: bool) { - self.is_hidden = is_hidden; - } - fn header(&self) -> Cow<'static, str> { self.inner.text() } } impl Column { - pub const fn new(inner: H) -> Self { - Self { - inner, - bounds: ColumnWidthBounds::FollowHeader, - is_hidden: false, - } - } - pub const fn hard(inner: H, width: u16) -> Self { Self { inner, diff --git a/src/canvas/components/data_table/draw.rs b/src/canvas/components/data_table/draw.rs index 0fe5bb916..d97c3b80c 100644 --- a/src/canvas/components/data_table/draw.rs +++ b/src/canvas/components/data_table/draw.rs @@ -5,12 +5,11 @@ use std::{ use concat_string::concat_string; use tui::{ + Frame, layout::{Constraint, Direction, Layout, Rect}, text::{Line, Span, Text}, - widgets::{Block, Borders, Row, Table}, - Frame, + widgets::{Block, Row, Table}, }; -use unicode_segmentation::UnicodeSegmentation; use super::{ CalculateColumnWidths, ColumnHeader, ColumnWidthBounds, DataTable, DataTableColumn, DataToCell, @@ -18,8 +17,8 @@ use super::{ }; use crate::{ app::layout_manager::BottomWidget, - canvas::Painter, - constants::{SIDE_BORDERS, TABLE_GAP_HEIGHT_LIMIT}, + canvas::{Painter, drawing_utils::widget_block}, + constants::TABLE_GAP_HEIGHT_LIMIT, utils::strings::truncate_to_text, }; @@ -68,46 +67,41 @@ where C: DataTableColumn, { fn block<'a>(&self, draw_info: &'a DrawInfo, data_len: usize) -> Block<'a> { - let border_style = match draw_info.selection_state { - SelectionState::NotSelected => self.styling.border_style, - SelectionState::Selected | SelectionState::Expanded => { - self.styling.highlighted_border_style - } + let is_selected = match draw_info.selection_state { + SelectionState::NotSelected => false, + SelectionState::Selected | SelectionState::Expanded => true, }; - if !self.props.is_basic { - let block = Block::default() - .borders(Borders::ALL) - .border_style(border_style); + let border_style = if is_selected { + self.styling.highlighted_border_style + } else { + self.styling.border_style + }; - if let Some(title) = self.generate_title(draw_info, data_len) { - block.title(title) - } else { - block + let mut block = widget_block(self.props.is_basic, is_selected, self.styling.border_type) + .border_style(border_style); + + if let Some((left_title, right_title)) = self.generate_title(draw_info, data_len) { + if !self.props.is_basic { + block = block.title_top(left_title); + } + + if let Some(right_title) = right_title { + block = block.title_top(right_title); } - } else if draw_info.is_on_widget() { - // Implies it is basic mode but selected. - Block::default() - .borders(SIDE_BORDERS) - .border_style(border_style) - } else { - Block::default().borders(Borders::NONE) } + + block } /// Generates a title, given the available space. - pub fn generate_title<'a>( - &self, draw_info: &'a DrawInfo, total_items: usize, - ) -> Option> { + fn generate_title( + &self, draw_info: &'_ DrawInfo, total_items: usize, + ) -> Option<(Line<'static>, Option>)> { self.props.title.as_ref().map(|title| { let current_index = self.state.current_index.saturating_add(1); let draw_loc = draw_info.loc; let title_style = self.styling.title_style; - let border_style = if draw_info.is_on_widget() { - self.styling.highlighted_border_style - } else { - self.styling.border_style - }; let title = if self.props.show_table_scroll_position { let pos = current_index.to_string(); @@ -123,19 +117,15 @@ where title.to_string() }; - if draw_info.is_expanded() { - let title_base = concat_string!(title, "── Esc to go back "); - let lines = "─".repeat(usize::from(draw_loc.width).saturating_sub( - UnicodeSegmentation::graphemes(title_base.as_str(), true).count() + 2, - )); - let esc = concat_string!("─", lines, "─ Esc to go back "); - Line::from(vec![ - Span::styled(title, title_style), - Span::styled(esc, border_style), - ]) + let left_title = Line::from(Span::styled(title, title_style)).left_aligned(); + + let right_title = if draw_info.is_expanded() { + Some(Line::from(" Esc to go back ").right_aligned()) } else { - Line::from(Span::styled(title, title_style)) - } + None + }; + + (left_title, right_title) }) } @@ -143,11 +133,10 @@ where &mut self, f: &mut Frame<'_>, draw_info: &DrawInfo, widget: Option<&mut BottomWidget>, painter: &Painter, ) { - let draw_horizontal = !self.props.is_basic || draw_info.is_on_widget(); let draw_loc = draw_info.loc; let margined_draw_loc = Layout::default() .constraints([Constraint::Percentage(100)]) - .horizontal_margin(u16::from(!draw_horizontal)) + .horizontal_margin(u16::from(self.props.is_basic && !draw_info.is_on_widget())) .direction(Direction::Horizontal) .split(draw_loc)[0]; @@ -202,8 +191,9 @@ where if !self.data.is_empty() || !self.first_draw { if self.first_draw { - self.first_draw = false; // TODO: Doing it this way is fine, but it could be done better (e.g. showing - // custom no results/entries message) + // TODO: Doing it this way is fine, but it could be done better (e.g. showing + // custom no results/entries message) + self.first_draw = false; if let Some(first_index) = self.first_index { self.set_position(first_index); } @@ -256,7 +246,7 @@ where self.state.calculated_widths.iter().map(|nzu| nzu.get()), ) .block(block) - .highlight_style(highlight_style) + .row_highlight_style(highlight_style) .style(self.styling.text_style); if show_header { diff --git a/src/canvas/components/data_table/sortable.rs b/src/canvas/components/data_table/sortable.rs index 659fc9396..c3ef4f49d 100644 --- a/src/canvas/components/data_table/sortable.rs +++ b/src/canvas/components/data_table/sortable.rs @@ -25,11 +25,16 @@ impl SortOrder { SortOrder::Descending => SortOrder::Ascending, } } + + /// A hack to get a const default. + pub const fn const_default() -> Self { + Self::Ascending + } } impl Default for SortOrder { fn default() -> Self { - Self::Ascending + Self::const_default() } } @@ -163,11 +168,6 @@ where self.is_hidden } - #[inline] - fn set_is_hidden(&mut self, is_hidden: bool) { - self.is_hidden = is_hidden; - } - fn header(&self) -> Cow<'static, str> { self.inner.header() } @@ -195,18 +195,18 @@ where /// Creates a new [`SortColumn`] with a hard width, which has no shortcut /// and sorts by default in ascending order ([`SortOrder::Ascending`]). - pub fn hard(inner: T, width: u16) -> Self { + pub const fn hard(inner: T, width: u16) -> Self { Self { inner, bounds: ColumnWidthBounds::Hard(width), is_hidden: false, - default_order: SortOrder::default(), + default_order: SortOrder::const_default(), } } /// Creates a new [`SortColumn`] with a soft width, which has no shortcut /// and sorts by default in ascending order ([`SortOrder::Ascending`]). - pub fn soft(inner: T, max_percentage: Option) -> Self { + pub const fn soft(inner: T, max_percentage: Option) -> Self { Self { inner, bounds: ColumnWidthBounds::Soft { @@ -214,18 +214,12 @@ where max_percentage, }, is_hidden: false, - default_order: SortOrder::default(), + default_order: SortOrder::const_default(), } } - /// Sets the default sort order to [`SortOrder::Ascending`]. - pub fn default_ascending(mut self) -> Self { - self.default_order = SortOrder::Ascending; - self - } - /// Sets the default sort order to [`SortOrder::Descending`]. - pub fn default_descending(mut self) -> Self { + pub const fn default_descending(mut self) -> Self { self.default_order = SortOrder::Descending; self } diff --git a/src/canvas/components/data_table/styling.rs b/src/canvas/components/data_table/styling.rs index 0006e8ec1..1fd63a027 100644 --- a/src/canvas/components/data_table/styling.rs +++ b/src/canvas/components/data_table/styling.rs @@ -1,11 +1,12 @@ -use tui::style::Style; +use tui::{style::Style, widgets::BorderType}; -use crate::options::config::style::ColourPalette; +use crate::options::config::style::Styles; #[derive(Default)] pub struct DataTableStyling { pub header_style: Style, pub border_style: Style, + pub border_type: BorderType, pub highlighted_border_style: Style, pub text_style: Style, pub highlighted_text_style: Style, @@ -13,14 +14,15 @@ pub struct DataTableStyling { } impl DataTableStyling { - pub fn from_palette(colours: &ColourPalette) -> Self { + pub fn from_palette(styles: &Styles) -> Self { Self { - header_style: colours.table_header_style, - border_style: colours.border_style, - highlighted_border_style: colours.highlighted_border_style, - text_style: colours.text_style, - highlighted_text_style: colours.selected_text_style, - title_style: colours.widget_title_style, + header_style: styles.table_header_style, + border_style: styles.border_style, + border_type: styles.border_type, + highlighted_border_style: styles.highlighted_border_style, + text_style: styles.text_style, + highlighted_text_style: styles.selected_text_style, + title_style: styles.widget_title_style, } } } diff --git a/src/canvas/components/mod.rs b/src/canvas/components/mod.rs new file mode 100644 index 000000000..9de306969 --- /dev/null +++ b/src/canvas/components/mod.rs @@ -0,0 +1,6 @@ +//! Lower-level or shared drawing components used throughout bottom. + +pub mod data_table; +pub mod pipe_gauge; +pub mod time_graph; +pub mod widget_carousel; diff --git a/src/canvas/components/tui_widget/pipe_gauge.rs b/src/canvas/components/pipe_gauge.rs similarity index 92% rename from src/canvas/components/tui_widget/pipe_gauge.rs rename to src/canvas/components/pipe_gauge.rs index f8905e9f6..fb365c48e 100644 --- a/src/canvas/components/tui_widget/pipe_gauge.rs +++ b/src/canvas/components/pipe_gauge.rs @@ -9,6 +9,7 @@ use tui::{ #[derive(Debug, Clone, Copy)] pub enum LabelLimit { None, + #[expect(dead_code)] Auto(u16), Bars, StartLabel, @@ -32,7 +33,7 @@ pub struct PipeGauge<'a> { hide_parts: LabelLimit, } -impl<'a> Default for PipeGauge<'a> { +impl Default for PipeGauge<'_> { fn default() -> Self { Self { block: None, @@ -95,7 +96,7 @@ impl<'a> PipeGauge<'a> { } } -impl<'a> Widget for PipeGauge<'a> { +impl Widget for PipeGauge<'_> { fn render(mut self, area: Rect, buf: &mut Buffer) { buf.set_style(area, self.label_style); let gauge_area = match self.block.take() { @@ -203,13 +204,15 @@ impl<'a> Widget for PipeGauge<'a> { let pipe_end = start + (f64::from(end.saturating_sub(start)) * self.ratio).floor() as u16; for col in start..pipe_end { - buf.get_mut(col, row).set_symbol("|").set_style(Style { - fg: self.gauge_style.fg, - bg: None, - add_modifier: self.gauge_style.add_modifier, - sub_modifier: self.gauge_style.sub_modifier, - underline_color: None, - }); + if let Some(cell) = buf.cell_mut((col, row)) { + cell.set_symbol("|").set_style(Style { + fg: self.gauge_style.fg, + bg: None, + add_modifier: self.gauge_style.add_modifier, + sub_modifier: self.gauge_style.sub_modifier, + underline_color: None, + }); + } } if (end_label.width() as u16) < end.saturating_sub(start) { diff --git a/src/canvas/components/time_graph.rs b/src/canvas/components/time_graph/base.rs similarity index 59% rename from src/canvas/components/time_graph.rs rename to src/canvas/components/time_graph/base.rs index fa0697600..4e372f6c5 100644 --- a/src/canvas/components/time_graph.rs +++ b/src/canvas/components/time_graph/base.rs @@ -1,37 +1,61 @@ -use std::borrow::Cow; +mod time_chart; +use std::{borrow::Cow, time::Instant}; use concat_string::concat_string; +pub use time_chart::*; use tui::{ + Frame, layout::{Constraint, Rect}, style::Style, symbols::Marker, text::{Line, Span}, - widgets::{Block, Borders, GraphType}, - Frame, + widgets::{BorderType, GraphType}, }; -use unicode_segmentation::UnicodeSegmentation; -use super::time_chart::{ - Axis, Dataset, LegendPosition, Point, TimeChart, DEFAULT_LEGEND_CONSTRAINTS, -}; +use crate::{app::data::Values, canvas::drawing_utils::widget_block}; /// Represents the data required by the [`TimeGraph`]. -pub struct GraphData<'a> { - pub points: &'a [Point], - pub style: Style, - pub name: Option>, +/// +/// TODO: We may be able to get rid of this intermediary data structure. +#[derive(Default)] +pub(crate) struct GraphData<'a> { + time: &'a [Instant], + values: Option<&'a Values>, + style: Style, + name: Option>, +} + +impl<'a> GraphData<'a> { + pub fn time(mut self, time: &'a [Instant]) -> Self { + self.time = time; + self + } + + pub fn values(mut self, values: &'a Values) -> Self { + self.values = Some(values); + self + } + + pub fn style(mut self, style: Style) -> Self { + self.style = style; + self + } + + pub fn name(mut self, name: Cow<'a, str>) -> Self { + self.name = Some(name); + self + } } pub struct TimeGraph<'a> { - /// The min and max x boundaries. Expects a f64 representing the time range - /// in milliseconds. - pub x_bounds: [u64; 2], + /// The min x value. + pub x_min: f64, /// Whether to hide the time/x-labels. pub hide_x_labels: bool, /// The min and max y boundaries. - pub y_bounds: [f64; 2], + pub y_bounds: AxisBound, /// Any y-labels. pub y_labels: &'a [Cow<'a, str>], @@ -42,9 +66,15 @@ pub struct TimeGraph<'a> { /// The border style. pub border_style: Style, + /// The border type. + pub border_type: BorderType, + /// The graph title. pub title: Cow<'a, str>, + /// Whether this graph is selected. + pub is_selected: bool, + /// Whether this graph is expanded. pub is_expanded: bool, @@ -60,24 +90,26 @@ pub struct TimeGraph<'a> { /// The marker type. Unlike ratatui's native charts, we assume /// only a single type of marker. pub marker: Marker, + + /// The chart scaling. + pub scaling: ChartScaling, } -impl<'a> TimeGraph<'a> { +impl TimeGraph<'_> { /// Generates the [`Axis`] for the x-axis. fn generate_x_axis(&self) -> Axis<'_> { // Due to how we display things, we need to adjust the time bound values. - let time_start = -(self.x_bounds[1] as f64); - let adjusted_x_bounds = [time_start, 0.0]; + let adjusted_x_bounds = AxisBound::Min(self.x_min); if self.hide_x_labels { Axis::default().bounds(adjusted_x_bounds) } else { - let xb_one = (self.x_bounds[1] / 1000).to_string(); - let xb_zero = (self.x_bounds[0] / 1000).to_string(); + let x_bound_left = ((-self.x_min) as u64 / 1000).to_string(); + let x_bound_right = "0s"; let x_labels = vec![ - Span::styled(concat_string!(xb_one, "s"), self.graph_style), - Span::styled(concat_string!(xb_zero, "s"), self.graph_style), + Span::styled(concat_string!(x_bound_left, "s"), self.graph_style), + Span::styled(x_bound_right, self.graph_style), ]; Axis::default() @@ -100,29 +132,6 @@ impl<'a> TimeGraph<'a> { ) } - /// Generates a title for the [`TimeGraph`] widget, given the available - /// space. - fn generate_title(&self, draw_loc: Rect) -> Line<'_> { - if self.is_expanded { - let title_base = concat_string!(self.title, "── Esc to go back "); - Line::from(vec![ - Span::styled(self.title.as_ref(), self.title_style), - Span::styled( - concat_string!( - "─", - "─".repeat(usize::from(draw_loc.width).saturating_sub( - UnicodeSegmentation::graphemes(title_base.as_str(), true).count() + 2 - )), - "─ Esc to go back " - ), - self.border_style, - ), - ]) - } else { - Line::from(Span::styled(self.title.as_ref(), self.title_style)) - } - } - /// Draws a time graph at [`Rect`] location provided by `draw_loc`. A time /// graph is used to display data points throughout time in the x-axis. /// @@ -132,17 +141,24 @@ impl<'a> TimeGraph<'a> { /// graph. /// - Expects `graph_data`, which represents *what* data to draw, and /// various details like style and optional legends. - pub fn draw_time_graph(&self, f: &mut Frame<'_>, draw_loc: Rect, graph_data: &[GraphData<'_>]) { + pub fn draw(&self, f: &mut Frame<'_>, draw_loc: Rect, graph_data: Vec>) { + // TODO: (points_rework_v1) can we reduce allocations in the underlying graph by saving some sort of state? + let x_axis = self.generate_x_axis(); let y_axis = self.generate_y_axis(); + let data = graph_data.into_iter().map(create_dataset).collect(); + + let block = { + let mut b = widget_block(false, self.is_selected, self.border_type) + .border_style(self.border_style) + .title_top(Line::styled(self.title.as_ref(), self.title_style)); - // This is some ugly manual loop unswitching. Maybe unnecessary. - // TODO: Optimize this step. Cut out unneeded points. - let data = graph_data.iter().map(create_dataset).collect(); - let block = Block::default() - .title(self.generate_title(draw_loc)) - .borders(Borders::ALL) - .border_style(self.border_style); + if self.is_expanded { + b = b.title_top(Line::styled(" Esc to go back ", self.title_style).right_aligned()) + } + + b + }; f.render_widget( TimeChart::new(data) @@ -155,30 +171,38 @@ impl<'a> TimeGraph<'a> { .hidden_legend_constraints( self.legend_constraints .unwrap_or(DEFAULT_LEGEND_CONSTRAINTS), - ), + ) + .scaling(self.scaling), draw_loc, ) } } /// Creates a new [`Dataset`]. -fn create_dataset<'a>(data: &'a GraphData<'a>) -> Dataset<'a> { +fn create_dataset(data: GraphData<'_>) -> Dataset<'_> { let GraphData { - points, + time, + values, style, name, } = data; + let Some(values) = values else { + return Dataset::default(); + }; + let dataset = Dataset::default() - .style(*style) - .data(points) + .style(style) + .data(time, values) .graph_type(GraphType::Line); - if let Some(name) = name { - dataset.name(name.as_ref()) + let dataset = if let Some(name) = name { + dataset.name(name) } else { dataset - } + }; + + dataset } #[cfg(test)] @@ -186,14 +210,14 @@ mod test { use std::borrow::Cow; use tui::{ - layout::Rect, style::{Color, Style}, symbols::Marker, - text::{Line, Span}, + text::Span, + widgets::BorderType, }; - use super::TimeGraph; - use crate::canvas::components::time_chart::Axis; + use super::{AxisBound, ChartScaling, TimeGraph}; + use crate::canvas::components::time_graph::Axis; const Y_LABELS: [Cow<'static, str>; 3] = [ Cow::Borrowed("0%"), @@ -204,17 +228,20 @@ mod test { fn create_time_graph() -> TimeGraph<'static> { TimeGraph { title: " Network ".into(), - x_bounds: [0, 15000], + x_min: -15000.0, hide_x_labels: false, - y_bounds: [0.0, 100.5], + y_bounds: AxisBound::Max(100.5), y_labels: &Y_LABELS, graph_style: Style::default().fg(Color::Red), border_style: Style::default().fg(Color::Blue), + border_type: BorderType::Plain, + is_selected: false, is_expanded: false, title_style: Style::default().fg(Color::Cyan), legend_position: None, legend_constraints: None, marker: Marker::Braille, + scaling: ChartScaling::Linear, } } @@ -225,7 +252,7 @@ mod test { let x_axis = tg.generate_x_axis(); let actual = Axis::default() - .bounds([-15000.0, 0.0]) + .bounds(AxisBound::Min(-15000.0)) .labels(vec![Span::styled("15s", style), Span::styled("0s", style)]) .style(style); assert_eq!(x_axis.bounds, actual.bounds); @@ -240,7 +267,7 @@ mod test { let y_axis = tg.generate_y_axis(); let actual = Axis::default() - .bounds([0.0, 100.5]) + .bounds(AxisBound::Max(100.5)) .labels(vec![ Span::styled("0%", style), Span::styled("50%", style), @@ -252,26 +279,4 @@ mod test { assert_eq!(y_axis.labels, actual.labels); assert_eq!(y_axis.style, actual.style); } - - #[test] - fn time_graph_gen_title() { - let mut time_graph = create_time_graph(); - let draw_loc = Rect::new(0, 0, 32, 100); - - let title = time_graph.generate_title(draw_loc); - assert_eq!( - title, - Line::from(Span::styled(" Network ", Style::default().fg(Color::Cyan))) - ); - - time_graph.is_expanded = true; - let title = time_graph.generate_title(draw_loc); - assert_eq!( - title, - Line::from(vec![ - Span::styled(" Network ", Style::default().fg(Color::Cyan)), - Span::styled("───── Esc to go back ", Style::default().fg(Color::Blue)) - ]) - ); - } } diff --git a/src/canvas/components/tui_widget/time_chart.rs b/src/canvas/components/time_graph/base/time_chart.rs similarity index 90% rename from src/canvas/components/tui_widget/time_chart.rs rename to src/canvas/components/time_graph/base/time_chart.rs index 922908d25..127b82392 100644 --- a/src/canvas/components/tui_widget/time_chart.rs +++ b/src/canvas/components/time_graph/base/time_chart.rs @@ -5,9 +5,10 @@ //! the specializations are factored out to `time_chart/points.rs`. mod canvas; +mod grid; mod points; -use std::{cmp::max, str::FromStr}; +use std::{cmp::max, str::FromStr, time::Instant}; use canvas::*; use tui::{ @@ -16,16 +17,44 @@ use tui::{ style::{Color, Style, Styled}, symbols::{self, Marker}, text::{Line, Span}, - widgets::{block::BlockExt, Block, Borders, GraphType, Widget}, + widgets::{Block, Borders, GraphType, Widget, block::BlockExt}, }; use unicode_width::UnicodeWidthStr; +use crate::{ + app::data::Values, + utils::general::{saturating_log2, saturating_log10}, +}; + pub const DEFAULT_LEGEND_CONSTRAINTS: (Constraint, Constraint) = (Constraint::Ratio(1, 4), Constraint::Length(4)); /// A single graph point. pub type Point = (f64, f64); +/// An axis bound type. Allows us to save a f64 since we know that we are +/// usually bound from some values [0.0, a], or [-b, 0.0]. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub enum AxisBound { + /// Just 0. + #[default] + Zero, + /// Bound by a minimum value to 0. + Min(f64), + /// Bound by 0 and a max value. + Max(f64), +} + +impl AxisBound { + fn get_bounds(&self) -> [f64; 2] { + match self { + AxisBound::Zero => [0.0, 0.0], + AxisBound::Min(min) => [*min, 0.0], + AxisBound::Max(max) => [0.0, *max], + } + } +} + /// An X or Y axis for the [`TimeChart`] widget #[derive(Debug, Default, Clone, PartialEq)] pub struct Axis<'a> { @@ -33,7 +62,7 @@ pub struct Axis<'a> { pub(crate) title: Option>, /// Bounds for the axis (all data points outside these limits will not be /// represented) - pub(crate) bounds: [f64; 2], + pub(crate) bounds: AxisBound, /// A list of labels to put to the left or below the axis pub(crate) labels: Option>>, /// The style used to draw the axis itself @@ -47,10 +76,8 @@ impl<'a> Axis<'a> { /// /// It will be displayed at the end of the axis. For an X axis this is the /// right, for a Y axis, this is the top. - /// - /// This is a fluent setter method which must be chained or used as it - /// consumes self #[must_use = "method moves the value of self and returns the modified value"] + #[cfg_attr(not(test), expect(dead_code))] pub fn title(mut self, title: T) -> Axis<'a> where T: Into>, @@ -59,14 +86,9 @@ impl<'a> Axis<'a> { self } - /// Sets the bounds of this axis - /// - /// In other words, sets the min and max value on this axis. - /// - /// This is a fluent setter method which must be chained or used as it - /// consumes self + /// Sets the bounds of this axis. #[must_use = "method moves the value of self and returns the modified value"] - pub fn bounds(mut self, bounds: [f64; 2]) -> Axis<'a> { + pub fn bounds(mut self, bounds: AxisBound) -> Axis<'a> { self.bounds = bounds; self } @@ -96,6 +118,7 @@ impl<'a> Axis<'a> { /// /// On the X axis, this parameter only affects the first label. #[must_use = "method moves the value of self and returns the modified value"] + #[expect(dead_code)] pub fn labels_alignment(mut self, alignment: Alignment) -> Axis<'a> { self.labels_alignment = alignment; self @@ -223,7 +246,7 @@ impl FromStr for LegendPosition { type Err = ParseLegendPositionError; fn from_str(s: &str) -> Result { - match s.to_ascii_lowercase().as_str() { + match s { "top" => Ok(Self::Top), "top-left" => Ok(Self::TopLeft), "top-right" => Ok(Self::TopRight), @@ -237,23 +260,28 @@ impl FromStr for LegendPosition { } } +#[derive(Debug, Default, Clone)] +enum Data<'a> { + Some { + times: &'a [Instant], + values: &'a Values, + }, + #[default] + None, +} + /// A group of data points /// /// This is the main element composing a [`TimeChart`]. /// /// A dataset can be [named](Dataset::name). Only named datasets will be /// rendered in the legend. -/// -/// After that, you can pass it data with [`Dataset::data`]. Data is an array of -/// `f64` tuples (`(f64, f64)`), the first element being X and the second Y. -/// It's also worth noting that, unlike the [`Rect`], here the Y axis is bottom -/// to top, as in math. -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone)] pub struct Dataset<'a> { /// Name of the dataset (used in the legend if shown) name: Option>, - /// A reference to the actual data - data: &'a [(f64, f64)], + /// A reference to data. + data: Data<'a>, /// Symbol used for each points of this dataset marker: symbols::Marker, /// Determines graph type used for drawing points @@ -275,15 +303,15 @@ impl<'a> Dataset<'a> { /// Sets the data points of this dataset /// - /// Points will then either be rendered as scrattered points or with lines + /// Points will then either be rendered as scattered points or with lines /// between them depending on [`Dataset::graph_type`]. /// /// Data consist in an array of `f64` tuples (`(f64, f64)`), the first /// element being X and the second Y. It's also worth noting that, /// unlike the [`Rect`], here the Y axis is bottom to top, as in math. #[must_use = "method moves the value of self and returns the modified value"] - pub fn data(mut self, data: &'a [(f64, f64)]) -> Dataset<'a> { - self.data = data; + pub fn data(mut self, times: &'a [Instant], values: &'a Values) -> Dataset<'a> { + self.data = Data::Some { times, values }; self } @@ -295,10 +323,8 @@ impl<'a> Dataset<'a> { /// /// Note [`Marker::Braille`] requires a font that supports Unicode Braille /// Patterns. - /// - /// This is a fluent setter method which must be chained or used as it - /// consumes self #[must_use = "method moves the value of self and returns the modified value"] + #[expect(dead_code)] pub fn marker(mut self, marker: symbols::Marker) -> Dataset<'a> { self.marker = marker; self @@ -354,6 +380,28 @@ struct ChartLayout { graph_area: Rect, } +/// Whether to additionally scale all values before displaying them. Defaults to none. +#[derive(Default, Debug, Clone, Copy)] +pub(crate) enum ChartScaling { + #[default] + Linear, + Log10, + Log2, +} + +impl ChartScaling { + /// Scale a value. + pub(super) fn scale(&self, value: f64) -> f64 { + // Remember to do saturating log checks as otherwise 0.0 becomes inf, and you get + // gaps! + match self { + ChartScaling::Linear => value, + ChartScaling::Log10 => saturating_log10(value), + ChartScaling::Log2 => saturating_log2(value), + } + } +} + /// A "custom" chart, just a slightly tweaked [`tui::widgets::Chart`] from /// ratatui, but with greater control over the legend, and built with the idea /// of drawing data points relative to a time-based x-axis. @@ -364,7 +412,7 @@ struct ChartLayout { /// - Automatic interpolation to points that fall *just* outside of the screen. /// /// TODO: Support for putting the legend on the left side. -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Debug, Default, Clone)] pub struct TimeChart<'a> { /// A block to display around the widget eventually block: Option>, @@ -380,17 +428,17 @@ pub struct TimeChart<'a> { legend_style: Style, /// Constraints used to determine whether the legend should be shown or not hidden_legend_constraints: (Constraint, Constraint), - /// The position detnermine where the legenth is shown or hide regaurdless + /// The position determining whether the length is shown or hidden, regardless /// of `hidden_legend_constraints` legend_position: Option, /// The marker type. marker: Marker, + /// Whether to scale the values differently. + scaling: ChartScaling, } impl<'a> TimeChart<'a> { - /// Creates a chart with the given [datasets](Dataset) - /// - /// A chart can render multiple datasets. + /// Creates a chart with the given [datasets](Dataset). pub fn new(datasets: Vec>) -> TimeChart<'a> { TimeChart { block: None, @@ -402,6 +450,7 @@ impl<'a> TimeChart<'a> { hidden_legend_constraints: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)), legend_position: Some(LegendPosition::default()), marker: Marker::Braille, + scaling: ChartScaling::default(), } } @@ -475,7 +524,7 @@ impl<'a> TimeChart<'a> { self } - /// Sets the position of a legend or hide it + /// Sets the position of a legend or hide it. /// /// The default is [`LegendPosition::TopRight`]. /// @@ -493,6 +542,13 @@ impl<'a> TimeChart<'a> { self } + /// Set chart scaling. + #[must_use = "method moves the value of self and returns the modified value"] + pub fn scaling(mut self, scaling: ChartScaling) -> TimeChart<'a> { + self.scaling = scaling; + self + } + /// Compute the internal layout of the chart given the area. If the area is /// too small some elements may be automatically hidden fn layout(&self, area: Rect) -> ChartLayout { @@ -692,6 +748,8 @@ impl<'a> TimeChart<'a> { fn render_y_labels( &self, buf: &mut Buffer, layout: &ChartLayout, chart_area: Rect, graph_area: Rect, ) { + // FIXME: Control how many y-axis labels are rendered based on height. + let Some(x) = layout.label_y else { return }; let labels = self.y_axis.labels.as_ref().unwrap(); let labels_len = labels.len() as u16; @@ -722,8 +780,11 @@ impl Widget for TimeChart<'_> { // Sample the style of the entire widget. This sample will be used to reset the // style of the cells that are part of the components put on top of the - // grah area (i.e legend and axis names). - let original_style = buf.get(area.left(), area.top()).style(); + // graph area (i.e legend and axis names). + let Some(original_style) = buf.cell((area.left(), area.top())).map(|cell| cell.style()) + else { + return; + }; let layout = self.layout(chart_area); let graph_area = layout.graph_area; @@ -736,32 +797,38 @@ impl Widget for TimeChart<'_> { if let Some(y) = layout.axis_x { for x in graph_area.left()..graph_area.right() { - buf.get_mut(x, y) - .set_symbol(symbols::line::HORIZONTAL) - .set_style(self.x_axis.style); + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_symbol(symbols::line::HORIZONTAL) + .set_style(self.x_axis.style); + } } } if let Some(x) = layout.axis_y { for y in graph_area.top()..graph_area.bottom() { - buf.get_mut(x, y) - .set_symbol(symbols::line::VERTICAL) - .set_style(self.y_axis.style); + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_symbol(symbols::line::VERTICAL) + .set_style(self.y_axis.style); + } } } if let Some(y) = layout.axis_x { if let Some(x) = layout.axis_y { - buf.get_mut(x, y) - .set_symbol(symbols::line::BOTTOM_LEFT) - .set_style(self.x_axis.style); + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_symbol(symbols::line::BOTTOM_LEFT) + .set_style(self.x_axis.style); + } } } + let x_bounds = self.x_axis.bounds.get_bounds(); + let y_bounds = self.y_axis.bounds.get_bounds(); + Canvas::default() .background_color(self.style.bg.unwrap_or(Color::Reset)) - .x_bounds(self.x_axis.bounds) - .y_bounds(self.y_axis.bounds) + .x_bounds(x_bounds) + .y_bounds(y_bounds) .marker(self.marker) .paint(|ctx| { self.draw_points(ctx); @@ -806,10 +873,15 @@ impl Widget for TimeChart<'_> { if let Some(legend_area) = layout.legend_area { buf.set_style(legend_area, original_style); - Block::default() + let block = Block::default() .borders(Borders::ALL) - .border_style(self.legend_style) - .render(legend_area, buf); + .border_style(self.legend_style); + for pos in block.inner(legend_area).positions() { + if let Some(cell) = buf.cell_mut(pos) { + cell.set_symbol(" "); + } + } + block.render(legend_area, buf); for (i, (dataset_name, dataset_style)) in self .datasets @@ -892,7 +964,7 @@ mod tests { .iter() .enumerate() .map(|(i, (x, y, cell))| { - let expected_cell = expected.get(*x, *y); + let expected_cell = expected.cell((*x, *y)).unwrap(); indoc::formatdoc! {" {i}: at ({x}, {y}) expected: {expected_cell:?} @@ -921,6 +993,8 @@ mod tests { }; } + use std::time::Duration; + use tui::style::{Modifier, Stylize}; use super::*; @@ -933,7 +1007,17 @@ mod tests { #[test] fn it_should_hide_the_legend() { - let data = [(0.0, 5.0), (1.0, 6.0), (3.0, 7.0)]; + let now = Instant::now(); + let times = [ + now, + now.checked_add(Duration::from_secs(1)).unwrap(), + now.checked_add(Duration::from_secs(2)).unwrap(), + ]; + let mut values = Values::default(); + values.push(5.0); + values.push(6.0); + values.push(7.0); + let cases = [ LegendTestCase { chart_area: Rect::new(0, 0, 100, 100), @@ -950,7 +1034,7 @@ mod tests { let datasets = (0..10) .map(|i| { let name = format!("Dataset #{i}"); - Dataset::default().name(name).data(&data) + Dataset::default().name(name).data(×, &values) }) .collect::>(); let chart = TimeChart::new(datasets) @@ -1028,7 +1112,7 @@ mod tests { } #[test] - fn datasets_without_name_dont_contribute_to_legend_height() { + fn datasets_without_name_do_not_contribute_to_legend_height() { let data_named_1 = Dataset::default().name("data1"); // must occupy a row in legend let data_named_2 = Dataset::default().name(""); // must occupy a row in legend, even if name is empty let data_unnamed = Dataset::default(); // must not occupy a row in legend @@ -1038,7 +1122,7 @@ mod tests { assert!(layout.legend_area.is_some()); assert_eq!(layout.legend_area.unwrap().height, 4); // 2 for borders, 2 - // for rows + // for rows } #[test] diff --git a/src/canvas/components/time_graph/base/time_chart/canvas.rs b/src/canvas/components/time_graph/base/time_chart/canvas.rs new file mode 100644 index 000000000..4378bba63 --- /dev/null +++ b/src/canvas/components/time_graph/base/time_chart/canvas.rs @@ -0,0 +1,362 @@ +//! Vendored from +//! and . +//! +//! The main thing this is pulled in for is overriding how `BrailleGrid`'s draw +//! logic works, as changing it is needed in order to draw all datasets in only +//! one layer back in [`super::TimeChart::render`]. More specifically, +//! the current implementation in ratatui `|=`s all the cells together if they +//! overlap, but since we are smashing all the layers together which may have +//! different colours, we instead just _replace_ whatever was in that cell +//! with the newer colour + character. +//! +//! See and for the +//! original motivation. + +use tui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Style}, + symbols, + text::Line, + widgets::{ + Block, Widget, + canvas::{Line as CanvasLine, Points}, + }, +}; + +use super::grid::{BrailleGrid, CharGrid, Grid, HalfBlockGrid}; + +/// Interface for all shapes that may be drawn on a Canvas widget. +pub trait Shape { + fn draw(&self, painter: &mut Painter<'_, '_>); +} + +impl Shape for CanvasLine { + fn draw(&self, painter: &mut Painter<'_, '_>) { + let (x1, y1) = match painter.get_point(self.x1, self.y1) { + Some(c) => c, + None => return, + }; + let (x2, y2) = match painter.get_point(self.x2, self.y2) { + Some(c) => c, + None => return, + }; + let (dx, x_range) = if x2 >= x1 { + (x2 - x1, x1..=x2) + } else { + (x1 - x2, x2..=x1) + }; + let (dy, y_range) = if y2 >= y1 { + (y2 - y1, y1..=y2) + } else { + (y1 - y2, y2..=y1) + }; + + if dx == 0 { + for y in y_range { + painter.paint(x1, y, self.color); + } + } else if dy == 0 { + for x in x_range { + painter.paint(x, y1, self.color); + } + } else if dy < dx { + if x1 > x2 { + draw_line_low(painter, x2, y2, x1, y1, self.color); + } else { + draw_line_low(painter, x1, y1, x2, y2, self.color); + } + } else if y1 > y2 { + draw_line_high(painter, x2, y2, x1, y1, self.color); + } else { + draw_line_high(painter, x1, y1, x2, y2, self.color); + } + } +} + +fn draw_line_low( + painter: &mut Painter<'_, '_>, x1: usize, y1: usize, x2: usize, y2: usize, color: Color, +) { + let dx = (x2 - x1) as isize; + let dy = (y2 as isize - y1 as isize).abs(); + let mut d = 2 * dy - dx; + let mut y = y1; + for x in x1..=x2 { + painter.paint(x, y, color); + if d > 0 { + y = if y1 > y2 { + y.saturating_sub(1) + } else { + y.saturating_add(1) + }; + d -= 2 * dx; + } + d += 2 * dy; + } +} + +fn draw_line_high( + painter: &mut Painter<'_, '_>, x1: usize, y1: usize, x2: usize, y2: usize, color: Color, +) { + let dx = (x2 as isize - x1 as isize).abs(); + let dy = (y2 - y1) as isize; + let mut d = 2 * dx - dy; + let mut x = x1; + for y in y1..=y2 { + painter.paint(x, y, color); + if d > 0 { + x = if x1 > x2 { + x.saturating_sub(1) + } else { + x.saturating_add(1) + }; + d -= 2 * dy; + } + d += 2 * dx; + } +} + +impl Shape for Points<'_> { + fn draw(&self, painter: &mut Painter<'_, '_>) { + for (x, y) in self.coords { + if let Some((x, y)) = painter.get_point(*x, *y) { + painter.paint(x, y, self.color); + } + } + } +} + +/// Label to draw some text on the canvas +#[derive(Debug, Clone)] +pub struct Label<'a> { + x: f64, + y: f64, + spans: Line<'a>, +} + +#[derive(Debug)] +pub struct Painter<'a, 'b> { + context: &'a mut Context<'b>, + resolution: (f64, f64), +} + +impl Painter<'_, '_> { + /// Convert the (x, y) coordinates to location of a point on the grid. + pub fn get_point(&self, x: f64, y: f64) -> Option<(usize, usize)> { + let [left, right] = self.context.x_bounds; + let [bottom, top] = self.context.y_bounds; + if x < left || x > right || y < bottom || y > top { + return None; + } + let width = right - left; + let height = top - bottom; + if width <= 0.0 || height <= 0.0 { + return None; + } + let x = ((x - left) * (self.resolution.0 - 1.0) / width).round() as usize; + let y = ((top - y) * (self.resolution.1 - 1.0) / height).round() as usize; + Some((x, y)) + } + + /// Paint a point of the grid. + pub fn paint(&mut self, x: usize, y: usize, color: Color) { + self.context.grid.paint(x, y, color); + } +} + +impl<'a, 'b> From<&'a mut Context<'b>> for Painter<'a, 'b> { + fn from(context: &'a mut Context<'b>) -> Painter<'a, 'b> { + let resolution = context.grid.resolution(); + Painter { + context, + resolution, + } + } +} + +/// Holds the state of the Canvas when painting to it. +#[derive(Debug)] +pub struct Context<'a> { + x_bounds: [f64; 2], + y_bounds: [f64; 2], + grid: Box, + dirty: bool, + labels: Vec>, +} + +impl<'a> Context<'a> { + pub fn new( + width: u16, height: u16, x_bounds: [f64; 2], y_bounds: [f64; 2], marker: symbols::Marker, + ) -> Context<'a> { + let grid: Box = match marker { + symbols::Marker::Dot => Box::new(CharGrid::new(width, height, '•')), + symbols::Marker::Block => Box::new(CharGrid::new(width, height, '█')), + symbols::Marker::Bar => Box::new(CharGrid::new(width, height, '▄')), + symbols::Marker::Braille => Box::new(BrailleGrid::new(width, height)), + symbols::Marker::HalfBlock => Box::new(HalfBlockGrid::new(width, height)), + }; + Context { + x_bounds, + y_bounds, + grid, + dirty: false, + labels: Vec::new(), + } + } + + /// Draw any object that may implement the Shape trait + pub fn draw(&mut self, shape: &S) + where + S: Shape, + { + self.dirty = true; + let mut painter = Painter::from(self); + shape.draw(&mut painter); + } +} + +/// The Canvas widget may be used to draw more detailed figures using braille +/// patterns (each cell can have a braille character in 8 different positions). +pub struct Canvas<'a, F> +where + F: Fn(&mut Context<'_>), +{ + block: Option>, + x_bounds: [f64; 2], + y_bounds: [f64; 2], + painter: Option, + background_color: Color, + marker: symbols::Marker, +} + +impl<'a, F> Default for Canvas<'a, F> +where + F: Fn(&mut Context<'_>), +{ + fn default() -> Canvas<'a, F> { + Canvas { + block: None, + x_bounds: [0.0, 0.0], + y_bounds: [0.0, 0.0], + painter: None, + background_color: Color::Reset, + marker: symbols::Marker::Braille, + } + } +} + +impl<'a, F> Canvas<'a, F> +where + F: Fn(&mut Context<'_>), +{ + pub fn x_bounds(mut self, bounds: [f64; 2]) -> Canvas<'a, F> { + self.x_bounds = bounds; + self + } + + pub fn y_bounds(mut self, bounds: [f64; 2]) -> Canvas<'a, F> { + self.y_bounds = bounds; + self + } + + /// Store the closure that will be used to draw to the Canvas + pub fn paint(mut self, f: F) -> Canvas<'a, F> { + self.painter = Some(f); + self + } + + pub fn background_color(mut self, color: Color) -> Canvas<'a, F> { + self.background_color = color; + self + } + + /// Change the type of points used to draw the shapes. By default the + /// braille patterns are used as they provide a more fine grained result + /// but you might want to use the simple dot or block instead if the + /// targeted terminal does not support those symbols. + pub fn marker(mut self, marker: symbols::Marker) -> Canvas<'a, F> { + self.marker = marker; + self + } +} + +impl Widget for Canvas<'_, F> +where + F: Fn(&mut Context<'_>), +{ + fn render(mut self, area: Rect, buf: &mut Buffer) { + let canvas_area = match self.block.take() { + Some(b) => { + let inner_area = b.inner(area); + b.render(area, buf); + inner_area + } + None => area, + }; + + buf.set_style(canvas_area, Style::default().bg(self.background_color)); + + let width = canvas_area.width as usize; + + let painter = match self.painter { + Some(ref p) => p, + None => return, + }; + + // Create a blank context that match the size of the canvas + let mut ctx = Context::new( + canvas_area.width, + canvas_area.height, + self.x_bounds, + self.y_bounds, + self.marker, + ); + // Paint to this context + painter(&mut ctx); + + // Paint whatever is in the ctx. + let layer = ctx.grid.save(); + + for (i, (ch, (fg, bg))) in layer + .string + .chars() + .zip(layer.colors.into_iter()) + .enumerate() + { + if ch != ' ' && ch != '\u{2800}' { + let (x, y) = (i % width, i / width); + if let Some(cell) = + buf.cell_mut((x as u16 + canvas_area.left(), y as u16 + canvas_area.top())) + { + cell.set_char(ch).set_fg(fg).set_bg(bg); + } + } + } + + // Reset the grid and mark as non-dirty. + ctx.grid.reset(); + ctx.dirty = false; + + // Finally draw the labels + let left = self.x_bounds[0]; + let right = self.x_bounds[1]; + let top = self.y_bounds[1]; + let bottom = self.y_bounds[0]; + let width = (self.x_bounds[1] - self.x_bounds[0]).abs(); + let height = (self.y_bounds[1] - self.y_bounds[0]).abs(); + let resolution = { + let width = f64::from(canvas_area.width - 1); + let height = f64::from(canvas_area.height - 1); + (width, height) + }; + for label in ctx + .labels + .iter() + .filter(|l| l.x >= left && l.x <= right && l.y <= top && l.y >= bottom) + { + let x = ((label.x - left) * resolution.0 / width) as u16 + canvas_area.left(); + let y = ((top - label.y) * resolution.1 / height) as u16 + canvas_area.top(); + buf.set_line(x, y, &label.spans, canvas_area.right() - x); + } + } +} diff --git a/src/canvas/components/time_graph/base/time_chart/grid.rs b/src/canvas/components/time_graph/base/time_chart/grid.rs new file mode 100644 index 000000000..73aadb52d --- /dev/null +++ b/src/canvas/components/time_graph/base/time_chart/grid.rs @@ -0,0 +1,296 @@ +use std::{fmt::Debug, iter::zip}; + +use itertools::Itertools; +use tui::{style::Color, symbols}; + +#[derive(Debug, Clone)] +pub(super) struct Layer { + pub(super) string: String, + pub(super) colors: Vec<(Color, Color)>, +} + +/// A [`Grid`] is a trait that represents a grid of cells, drawn in a +/// specific way. +pub(super) trait Grid: Debug { + /// Get the resolution of the grid in number of dots. + /// + /// This doesn't have to be the same as the number of rows and columns of the grid. For example, + /// a grid of Braille patterns will have a resolution of 2x4 dots per cell. This means that a + /// grid of 10x10 cells will have a resolution of 20x40 dots. + fn resolution(&self) -> (f64, f64); + /// Paint a point of the grid. + /// + /// The point is expressed in number of dots starting at the origin of the grid in the top left + /// corner. Note that this is not the same as the `(x, y)` coordinates of the canvas. + fn paint(&mut self, x: usize, y: usize, color: Color); + /// Save the current state of the [`Grid`] as a layer to be rendered + fn save(&self) -> Layer; + /// Reset the grid to its initial state + fn reset(&mut self); +} + +/// The `BrailleGrid` is a grid made up of cells each containing a Braille pattern. +/// +/// This makes it possible to draw shapes with a resolution of 2x4 dots per cell. This is useful +/// when you want to draw shapes with a high resolution. Font support for Braille patterns is +/// required to see the dots. If your terminal or font does not support this unicode block, you +/// will see unicode replacement characters (�) instead of braille dots. +/// +/// This grid type only supports a single foreground color for each 2x4 dots cell. There is no way +/// to set the individual color of each dot in the braille pattern. +#[derive(Debug)] +pub(super) struct BrailleGrid { + /// Width of the grid in number of terminal columns + width: u16, + /// Height of the grid in number of terminal rows + height: u16, + /// Represents the unicode braille patterns. Will take a value between `0x2800` and `0x28FF`; + /// this is converted to an utf16 string when converting to a layer. See + /// for more info. + /// + /// FIXME: (points_rework_v1) isn't this really inefficient to go u16 -> String from utf16? + utf16_code_points: Vec, + /// The color of each cell only supports foreground colors for now as there's no way to + /// individually set the background color of each dot in the braille pattern. + colors: Vec, +} + +impl BrailleGrid { + /// Create a new `BrailleGrid` with the given width and height measured in terminal columns and + /// rows respectively. + pub(super) fn new(width: u16, height: u16) -> Self { + let length = usize::from(width * height); + Self { + width, + height, + utf16_code_points: vec![symbols::braille::BLANK; length], + colors: vec![Color::Reset; length], + } + } +} + +impl Grid for BrailleGrid { + fn resolution(&self) -> (f64, f64) { + (f64::from(self.width) * 2.0, f64::from(self.height) * 4.0) + } + + fn save(&self) -> Layer { + let string = String::from_utf16(&self.utf16_code_points).unwrap(); + // the background color is always reset for braille patterns + let colors = self.colors.iter().map(|c| (*c, Color::Reset)).collect(); + Layer { string, colors } + } + + fn reset(&mut self) { + self.utf16_code_points.fill(symbols::braille::BLANK); + self.colors.fill(Color::Reset); + } + + fn paint(&mut self, x: usize, y: usize, color: Color) { + // Note the braille array corresponds to: + // ⠁⠈ + // ⠂⠐ + // ⠄⠠ + // ⡀⢀ + + let index = y / 4 * self.width as usize + x / 2; + + // The ratatui/tui-rs implementation; this gives a more merged + // look but it also makes it a bit harder to read in some cases. + + // if let Some(c) = self.utf16_code_points.get_mut(index) { + // *c |= symbols::braille::DOTS[y % 4][x % 2]; + // } + // if let Some(c) = self.colors.get_mut(index) { + // *c = color; + // } + + // Custom implementation to distinguish between lines better. + if let Some(curr_color) = self.colors.get_mut(index) { + if *curr_color != color { + *curr_color = color; + if let Some(cell) = self.utf16_code_points.get_mut(index) { + *cell = symbols::braille::BLANK | symbols::braille::DOTS[y % 4][x % 2]; + } + } else if let Some(cell) = self.utf16_code_points.get_mut(index) { + *cell |= symbols::braille::DOTS[y % 4][x % 2]; + } + } + } +} + +/// The `CharGrid` is a grid made up of cells each containing a single character. +/// +/// This makes it possible to draw shapes with a resolution of 1x1 dots per cell. This is useful +/// when you want to draw shapes with a low resolution. +#[derive(Debug)] +pub(super) struct CharGrid { + /// Width of the grid in number of terminal columns + width: u16, + /// Height of the grid in number of terminal rows + height: u16, + /// Represents a single character for each cell + cells: Vec, + /// The color of each cell + colors: Vec, + /// The character to use for every cell - e.g. a block, dot, etc. + cell_char: char, +} + +impl CharGrid { + /// Create a new `CharGrid` with the given width and height measured in terminal columns and + /// rows respectively. + pub(super) fn new(width: u16, height: u16, cell_char: char) -> Self { + let length = usize::from(width * height); + Self { + width, + height, + cells: vec![' '; length], + colors: vec![Color::Reset; length], + cell_char, + } + } +} + +impl Grid for CharGrid { + fn resolution(&self) -> (f64, f64) { + (f64::from(self.width), f64::from(self.height)) + } + + fn save(&self) -> Layer { + Layer { + string: self.cells.iter().collect(), + colors: self.colors.iter().map(|c| (*c, Color::Reset)).collect(), + } + } + + fn reset(&mut self) { + self.cells.fill(' '); + self.colors.fill(Color::Reset); + } + + fn paint(&mut self, x: usize, y: usize, color: Color) { + let index = y * self.width as usize + x; + // using get_mut here because we are indexing the vector with usize values + // and we want to make sure we don't panic if the index is out of bounds + if let Some(c) = self.cells.get_mut(index) { + *c = self.cell_char; + } + if let Some(c) = self.colors.get_mut(index) { + *c = color; + } + } +} + +/// The `HalfBlockGrid` is a grid made up of cells each containing a half block character. +/// +/// In terminals, each character is usually twice as tall as it is wide. Unicode has a couple of +/// vertical half block characters, the upper half block '▀' and lower half block '▄' which take up +/// half the height of a normal character but the full width. Together with an empty space ' ' and a +/// full block '█', we can effectively double the resolution of a single cell. In addition, because +/// each character can have a foreground and background color, we can control the color of the upper +/// and lower half of each cell. This allows us to draw shapes with a resolution of 1x2 "pixels" per +/// cell. +/// +/// This allows for more flexibility than the `BrailleGrid` which only supports a single +/// foreground color for each 2x4 dots cell, and the `CharGrid` which only supports a single +/// character for each cell. +#[derive(Debug)] +pub(super) struct HalfBlockGrid { + /// Width of the grid in number of terminal columns + width: u16, + /// Height of the grid in number of terminal rows + height: u16, + /// Represents a single color for each "pixel" arranged in column, row order + pixels: Vec>, +} + +impl HalfBlockGrid { + /// Create a new `HalfBlockGrid` with the given width and height measured in terminal columns + /// and rows respectively. + pub(super) fn new(width: u16, height: u16) -> Self { + Self { + width, + height, + pixels: vec![vec![Color::Reset; width as usize]; height as usize * 2], + } + } +} + +impl Grid for HalfBlockGrid { + fn resolution(&self) -> (f64, f64) { + (f64::from(self.width), f64::from(self.height) * 2.0) + } + + fn save(&self) -> Layer { + // Given that we store the pixels in a grid, and that we want to use 2 pixels arranged + // vertically to form a single terminal cell, which can be either empty, upper half block, + // lower half block or full block, we need examine the pixels in vertical pairs to decide + // what character to print in each cell. So these are the 4 states we use to represent each + // cell: + // + // 1. upper: reset, lower: reset => ' ' fg: reset / bg: reset + // 2. upper: reset, lower: color => '▄' fg: lower color / bg: reset + // 3. upper: color, lower: reset => '▀' fg: upper color / bg: reset + // 4. upper: color, lower: color => '▀' fg: upper color / bg: lower color + // + // Note that because the foreground reset color (i.e. default foreground color) is usually + // not the same as the background reset color (i.e. default background color), we need to + // swap around the colors for that state (2 reset/color). + // + // When the upper and lower colors are the same, we could continue to use an upper half + // block, but we choose to use a full block instead. This allows us to write unit tests that + // treat the cell as a single character instead of two half block characters. + + // first we join each adjacent row together to get an iterator that contains vertical pairs + // of pixels, with the lower row being the first element in the pair + // + // TODO: Whenever I add this as a valid marker, make sure this works fine with + // the overridden time_chart drawing-layer-thing. + let vertical_color_pairs = self + .pixels + .iter() + .tuples() + .flat_map(|(upper_row, lower_row)| zip(upper_row, lower_row)); + + // then we work out what character to print for each pair of pixels + let string = vertical_color_pairs + .clone() + .map(|(upper, lower)| match (upper, lower) { + (Color::Reset, Color::Reset) => ' ', + (Color::Reset, _) => symbols::half_block::LOWER, + (_, Color::Reset) => symbols::half_block::UPPER, + (&lower, &upper) => { + if lower == upper { + symbols::half_block::FULL + } else { + symbols::half_block::UPPER + } + } + }) + .collect(); + + // then we convert these each vertical pair of pixels into a foreground and background color + let colors = vertical_color_pairs + .map(|(upper, lower)| { + let (fg, bg) = match (upper, lower) { + (Color::Reset, Color::Reset) => (Color::Reset, Color::Reset), + (Color::Reset, &lower) => (lower, Color::Reset), + (&upper, Color::Reset) => (upper, Color::Reset), + (&upper, &lower) => (upper, lower), + }; + (fg, bg) + }) + .collect(); + + Layer { string, colors } + } + + fn reset(&mut self) { + self.pixels.fill(vec![Color::Reset; self.width as usize]); + } + + fn paint(&mut self, x: usize, y: usize, color: Color) { + self.pixels[y][x] = color; + } +} diff --git a/src/canvas/components/time_graph/base/time_chart/points.rs b/src/canvas/components/time_graph/base/time_chart/points.rs new file mode 100644 index 000000000..dd2ad058f --- /dev/null +++ b/src/canvas/components/time_graph/base/time_chart/points.rs @@ -0,0 +1,127 @@ +use itertools::Itertools; +use tui::{ + style::Color, + widgets::{ + GraphType, + canvas::{Line as CanvasLine, Points}, + }, +}; + +use super::{Context, Data, Point, TimeChart}; + +impl TimeChart<'_> { + pub(crate) fn draw_points(&self, ctx: &mut Context<'_>) { + // Idea is to: + // - Go over all datasets, determine *where* a point will be drawn. + // - Last point wins for what gets drawn. + // - We set _all_ points for all datasets before actually rendering. + // + // By doing this, it's a bit more efficient from my experience than looping + // over each dataset and rendering a new layer each time. + // + // See https://github.com/ClementTsang/bottom/pull/918 and + // https://github.com/ClementTsang/bottom/pull/937 for the original motivation. + // + // We also additionally do some interpolation logic because we may get caught + // missing some points when drawing, but we generally want to avoid + // jarring gaps between the edges when there's a point that is off + // screen and so a line isn't drawn (right edge generally won't have this issue + // issue but it can happen in some cases). + + for dataset in &self.datasets { + let Data::Some { times, values } = dataset.data else { + continue; + }; + + let Some(current_time) = times.last() else { + continue; + }; + + let color = dataset.style.fg.unwrap_or(Color::Reset); + let left_edge = self.x_axis.bounds.get_bounds()[0]; + + // TODO: (points_rework_v1) Can we instead modify the range so it's based on the epoch rather than having to convert? + // TODO: (points_rework_v1) Is this efficient? Or should I prune using take_while first? + for (curr, next) in values + .iter_along_base(times) + .rev() + .map(|(&time, &val)| { + let from_start = -(current_time.duration_since(time).as_millis() as f64); + + // XXX: Should this be generic over dataset.graph_type instead? That would allow us to move + // transformations behind a type - however, that also means that there's some complexity added. + (from_start, self.scaling.scale(val)) + }) + .tuple_windows() + { + if curr.0 == left_edge { + // The current point hits the left edge. Draw just the current point and halt. + ctx.draw(&Points { + coords: &[curr], + color, + }); + + break; + } else if next.0 < left_edge { + // The next point goes past the left edge. Interpolate a point + the line and halt. + let interpolated = interpolate_point(&next, &curr, left_edge); + + ctx.draw(&CanvasLine { + x1: curr.0, + y1: curr.1, + x2: left_edge, + y2: interpolated, + color, + }); + + break; + } else { + // Draw the current point and the line to the next point. + if let GraphType::Line = dataset.graph_type { + ctx.draw(&CanvasLine { + x1: curr.0, + y1: curr.1, + x2: next.0, + y2: next.1, + color, + }); + } else { + ctx.draw(&Points { + coords: &[curr], + color, + }); + } + } + } + } + } +} + +/// Returns the y-axis value for a given `x`, given two points to draw a line +/// between. +fn interpolate_point(older_point: &Point, newer_point: &Point, x: f64) -> f64 { + let delta_x = newer_point.0 - older_point.0; + let delta_y = newer_point.1 - older_point.1; + let slope = delta_y / delta_x; + + (older_point.1 + (x - older_point.0) * slope).max(0.0) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn time_chart_test_interpolation() { + let data = [(-3.0, 8.0), (-1.0, 6.0), (0.0, 5.0)]; + + assert_eq!(interpolate_point(&data[1], &data[2], 0.0), 5.0); + assert_eq!(interpolate_point(&data[1], &data[2], -0.25), 5.25); + assert_eq!(interpolate_point(&data[1], &data[2], -0.5), 5.5); + assert_eq!(interpolate_point(&data[0], &data[1], -1.0), 6.0); + assert_eq!(interpolate_point(&data[0], &data[1], -1.5), 6.5); + assert_eq!(interpolate_point(&data[0], &data[1], -2.0), 7.0); + assert_eq!(interpolate_point(&data[0], &data[1], -2.5), 7.5); + assert_eq!(interpolate_point(&data[0], &data[1], -3.0), 8.0); + } +} diff --git a/src/canvas/components/time_graph/mod.rs b/src/canvas/components/time_graph/mod.rs new file mode 100644 index 000000000..beba361e4 --- /dev/null +++ b/src/canvas/components/time_graph/mod.rs @@ -0,0 +1,4 @@ +mod base; +pub mod variants; + +pub(crate) use base::*; diff --git a/src/canvas/components/time_graph/variants/auto_y_axis.rs b/src/canvas/components/time_graph/variants/auto_y_axis.rs new file mode 100644 index 000000000..432f6b243 --- /dev/null +++ b/src/canvas/components/time_graph/variants/auto_y_axis.rs @@ -0,0 +1,2 @@ +//! A variant of a [`crate::canvas::components::time_graph::TimeGraph`] that +//! automatically adjusts the y-axis based on the data provided. diff --git a/src/canvas/components/time_graph/variants/mod.rs b/src/canvas/components/time_graph/variants/mod.rs new file mode 100644 index 000000000..2efeaf757 --- /dev/null +++ b/src/canvas/components/time_graph/variants/mod.rs @@ -0,0 +1,15 @@ +use tui::style::Style; + +use crate::options::config::style::Styles; + +pub(crate) mod auto_y_axis; +pub(crate) mod percent; + +fn get_border_style(styles: &Styles, widget_id: u64, selected_widget_id: u64) -> Style { + let is_on_widget = widget_id == selected_widget_id; + if is_on_widget { + styles.highlighted_border_style + } else { + styles.border_style + } +} diff --git a/src/canvas/components/time_graph/variants/percent.rs b/src/canvas/components/time_graph/variants/percent.rs new file mode 100644 index 000000000..9382a2d88 --- /dev/null +++ b/src/canvas/components/time_graph/variants/percent.rs @@ -0,0 +1,96 @@ +//! A variant of a [`TimeGraph`] that expects data to be in a percentage format, from 0.0 to 100.0. + +use std::borrow::Cow; + +use tui::{layout::Constraint, symbols::Marker}; + +use crate::{ + app::AppConfigFields, + canvas::components::time_graph::{ + AxisBound, ChartScaling, LegendPosition, TimeGraph, variants::get_border_style, + }, + options::config::style::Styles, +}; + +/// Acts as a wrapper for a [`TimeGraph`] that expects data to be in a percentage format, +pub(crate) struct PercentTimeGraph<'a> { + /// The total display range of the graph in milliseconds. + /// + /// TODO: Make this a [`std::time::Duration`]. + pub(crate) display_range: u64, + + /// Whether to hide the x-axis labels. + pub(crate) hide_x_labels: bool, + + /// The app config fields. + /// + /// This is mostly used as a shared mutability workaround due to [`App`] + /// being a giant state struct. + pub(crate) app_config_fields: &'a AppConfigFields, + + /// The current widget selected by the app. + /// + /// This is mostly used as a shared mutability workaround due to [`App`] + /// being a giant state struct. + pub(crate) current_widget: u64, + + /// Whether the current widget is expanded. + /// + /// This is mostly used as a shared mutability workaround due to [`App`] + /// being a giant state struct. + pub(crate) is_expanded: bool, + + /// The title of the graph. + pub(crate) title: Cow<'a, str>, + + /// A reference to the styles. + pub(crate) styles: &'a Styles, + + /// The widget ID corresponding to this graph. + pub(crate) widget_id: u64, + + /// The position of the legend. + pub(crate) legend_position: Option, + + /// The constraints for the legend. + pub(crate) legend_constraints: Option<(Constraint, Constraint)>, +} + +impl<'a> PercentTimeGraph<'a> { + /// Return the final [`TimeGraph`]. + pub fn build(self) -> TimeGraph<'a> { + const Y_BOUNDS: AxisBound = AxisBound::Max(100.5); + const Y_LABELS: [Cow<'static, str>; 2] = [Cow::Borrowed(" 0%"), Cow::Borrowed("100%")]; + + let x_min = -(self.display_range as f64); + + let marker = if self.app_config_fields.use_dot { + Marker::Dot + } else { + Marker::Braille + }; + + let graph_style = self.styles.graph_style; + let border_style = get_border_style(self.styles, self.widget_id, self.current_widget); + let title_style = self.styles.widget_title_style; + let border_type = self.styles.border_type; + + TimeGraph { + x_min, + hide_x_labels: self.hide_x_labels, + y_bounds: Y_BOUNDS, + y_labels: &Y_LABELS, + graph_style, + border_style, + border_type, + title: self.title, + is_selected: self.current_widget == self.widget_id, + is_expanded: self.is_expanded, + title_style, + legend_position: self.legend_position, + legend_constraints: self.legend_constraints, + marker, + scaling: ChartScaling::Linear, + } + } +} diff --git a/src/canvas/components/tui_widget.rs b/src/canvas/components/tui_widget.rs deleted file mode 100644 index c9dc772f6..000000000 --- a/src/canvas/components/tui_widget.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Components derived from ratatui widgets. - -pub mod pipe_gauge; -pub mod time_chart; diff --git a/src/canvas/components/tui_widget/time_chart/canvas.rs b/src/canvas/components/tui_widget/time_chart/canvas.rs deleted file mode 100644 index 7f9b1c922..000000000 --- a/src/canvas/components/tui_widget/time_chart/canvas.rs +++ /dev/null @@ -1,675 +0,0 @@ -//! Vendored from -//! and . -//! -//! The main thing this is pulled in for is overriding how `BrailleGrid`'s draw -//! logic works, as changing it is needed in order to draw all datasets in only -//! one layer back in [`super::TimeChart::render`]. More specifically, -//! the current implementation in ratatui `|=`s all the cells together if they -//! overlap, but since we are smashing all the layers together which may have -//! different colours, we instead just _replace_ whatever was in that cell -//! with the newer colour + character. -//! -//! See and for the -//! original motivation. - -use std::{fmt::Debug, iter::zip}; - -use itertools::Itertools; -use tui::{ - buffer::Buffer, - layout::Rect, - style::{Color, Style}, - symbols, - text::Line, - widgets::{ - canvas::{Line as CanvasLine, Points}, - Block, Widget, - }, -}; - -/// Interface for all shapes that may be drawn on a Canvas widget. -pub trait Shape { - fn draw(&self, painter: &mut Painter<'_, '_>); -} - -impl Shape for CanvasLine { - fn draw(&self, painter: &mut Painter<'_, '_>) { - let (x1, y1) = match painter.get_point(self.x1, self.y1) { - Some(c) => c, - None => return, - }; - let (x2, y2) = match painter.get_point(self.x2, self.y2) { - Some(c) => c, - None => return, - }; - let (dx, x_range) = if x2 >= x1 { - (x2 - x1, x1..=x2) - } else { - (x1 - x2, x2..=x1) - }; - let (dy, y_range) = if y2 >= y1 { - (y2 - y1, y1..=y2) - } else { - (y1 - y2, y2..=y1) - }; - - if dx == 0 { - for y in y_range { - painter.paint(x1, y, self.color); - } - } else if dy == 0 { - for x in x_range { - painter.paint(x, y1, self.color); - } - } else if dy < dx { - if x1 > x2 { - draw_line_low(painter, x2, y2, x1, y1, self.color); - } else { - draw_line_low(painter, x1, y1, x2, y2, self.color); - } - } else if y1 > y2 { - draw_line_high(painter, x2, y2, x1, y1, self.color); - } else { - draw_line_high(painter, x1, y1, x2, y2, self.color); - } - } -} - -fn draw_line_low( - painter: &mut Painter<'_, '_>, x1: usize, y1: usize, x2: usize, y2: usize, color: Color, -) { - let dx = (x2 - x1) as isize; - let dy = (y2 as isize - y1 as isize).abs(); - let mut d = 2 * dy - dx; - let mut y = y1; - for x in x1..=x2 { - painter.paint(x, y, color); - if d > 0 { - y = if y1 > y2 { - y.saturating_sub(1) - } else { - y.saturating_add(1) - }; - d -= 2 * dx; - } - d += 2 * dy; - } -} - -fn draw_line_high( - painter: &mut Painter<'_, '_>, x1: usize, y1: usize, x2: usize, y2: usize, color: Color, -) { - let dx = (x2 as isize - x1 as isize).abs(); - let dy = (y2 - y1) as isize; - let mut d = 2 * dx - dy; - let mut x = x1; - for y in y1..=y2 { - painter.paint(x, y, color); - if d > 0 { - x = if x1 > x2 { - x.saturating_sub(1) - } else { - x.saturating_add(1) - }; - d -= 2 * dy; - } - d += 2 * dx; - } -} - -impl Shape for Points<'_> { - fn draw(&self, painter: &mut Painter<'_, '_>) { - for (x, y) in self.coords { - if let Some((x, y)) = painter.get_point(*x, *y) { - painter.paint(x, y, self.color); - } - } - } -} - -/// Label to draw some text on the canvas -#[derive(Debug, Clone)] -pub struct Label<'a> { - x: f64, - y: f64, - spans: Line<'a>, -} - -#[derive(Debug, Clone)] -struct Layer { - string: String, - colors: Vec<(Color, Color)>, -} - -trait Grid: Debug { - // fn width(&self) -> u16; - // fn height(&self) -> u16; - fn resolution(&self) -> (f64, f64); - fn paint(&mut self, x: usize, y: usize, color: Color); - fn save(&self) -> Layer; - fn reset(&mut self); -} - -#[derive(Debug, Clone)] -struct BrailleGrid { - width: u16, - height: u16, - cells: Vec, - colors: Vec, -} - -impl BrailleGrid { - fn new(width: u16, height: u16) -> BrailleGrid { - let length = usize::from(width * height); - BrailleGrid { - width, - height, - cells: vec![symbols::braille::BLANK; length], - colors: vec![Color::Reset; length], - } - } -} - -impl Grid for BrailleGrid { - // fn width(&self) -> u16 { - // self.width - // } - - // fn height(&self) -> u16 { - // self.height - // } - - fn resolution(&self) -> (f64, f64) { - ( - f64::from(self.width) * 2.0 - 1.0, - f64::from(self.height) * 4.0 - 1.0, - ) - } - - fn save(&self) -> Layer { - Layer { - string: String::from_utf16(&self.cells).unwrap(), - colors: self.colors.iter().map(|c| (*c, Color::Reset)).collect(), - } - } - - fn reset(&mut self) { - for c in &mut self.cells { - *c = symbols::braille::BLANK; - } - for c in &mut self.colors { - *c = Color::Reset; - } - } - - fn paint(&mut self, x: usize, y: usize, color: Color) { - let index = y / 4 * self.width as usize + x / 2; - if let Some(curr_color) = self.colors.get_mut(index) { - if *curr_color != color { - *curr_color = color; - if let Some(cell) = self.cells.get_mut(index) { - *cell = symbols::braille::BLANK; - - *cell |= symbols::braille::DOTS[y % 4][x % 2]; - } - } else if let Some(c) = self.cells.get_mut(index) { - *c |= symbols::braille::DOTS[y % 4][x % 2]; - } - } - } -} - -#[derive(Debug, Clone)] -struct CharGrid { - width: u16, - height: u16, - cells: Vec, - colors: Vec, - cell_char: char, -} - -impl CharGrid { - fn new(width: u16, height: u16, cell_char: char) -> CharGrid { - let length = usize::from(width * height); - CharGrid { - width, - height, - cells: vec![' '; length], - colors: vec![Color::Reset; length], - cell_char, - } - } -} - -impl Grid for CharGrid { - // fn width(&self) -> u16 { - // self.width - // } - - // fn height(&self) -> u16 { - // self.height - // } - - fn resolution(&self) -> (f64, f64) { - (f64::from(self.width) - 1.0, f64::from(self.height) - 1.0) - } - - fn save(&self) -> Layer { - Layer { - string: self.cells.iter().collect(), - colors: self.colors.iter().map(|c| (*c, Color::Reset)).collect(), - } - } - - fn reset(&mut self) { - for c in &mut self.cells { - *c = ' '; - } - for c in &mut self.colors { - *c = Color::Reset; - } - } - - fn paint(&mut self, x: usize, y: usize, color: Color) { - let index = y * self.width as usize + x; - if let Some(c) = self.cells.get_mut(index) { - *c = self.cell_char; - } - if let Some(c) = self.colors.get_mut(index) { - *c = color; - } - } -} - -#[derive(Debug)] -pub struct Painter<'a, 'b> { - context: &'a mut Context<'b>, - resolution: (f64, f64), -} - -/// The HalfBlockGrid is a grid made up of cells each containing a half block -/// character. -/// -/// In terminals, each character is usually twice as tall as it is wide. Unicode -/// has a couple of vertical half block characters, the upper half block '▀' and -/// lower half block '▄' which take up half the height of a normal character but -/// the full width. Together with an empty space ' ' and a full block '█', we -/// can effectively double the resolution of a single cell. In addition, because -/// each character can have a foreground and background color, we can control -/// the color of the upper and lower half of each cell. This allows us to draw -/// shapes with a resolution of 1x2 "pixels" per cell. -/// -/// This allows for more flexibility than the BrailleGrid which only supports a -/// single foreground color for each 2x4 dots cell, and the CharGrid which only -/// supports a single character for each cell. -#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] -struct HalfBlockGrid { - /// width of the grid in number of terminal columns - width: u16, - /// height of the grid in number of terminal rows - height: u16, - /// represents a single color for each "pixel" arranged in column, row order - pixels: Vec>, -} - -impl HalfBlockGrid { - /// Create a new [`HalfBlockGrid`] with the given width and height measured - /// in terminal columns and rows respectively. - fn new(width: u16, height: u16) -> HalfBlockGrid { - HalfBlockGrid { - width, - height, - pixels: vec![vec![Color::Reset; width as usize]; height as usize * 2], - } - } -} - -impl Grid for HalfBlockGrid { - // fn width(&self) -> u16 { - // self.width - // } - - // fn height(&self) -> u16 { - // self.height - // } - - fn resolution(&self) -> (f64, f64) { - (f64::from(self.width), f64::from(self.height) * 2.0) - } - - fn save(&self) -> Layer { - // Given that we store the pixels in a grid, and that we want to use 2 pixels - // arranged vertically to form a single terminal cell, which can be - // either empty, upper half block, lower half block or full block, we - // need examine the pixels in vertical pairs to decide what character to - // print in each cell. So these are the 4 states we use to represent each - // cell: - // - // 1. upper: reset, lower: reset => ' ' fg: reset / bg: reset - // 2. upper: reset, lower: color => '▄' fg: lower color / bg: reset - // 3. upper: color, lower: reset => '▀' fg: upper color / bg: reset - // 4. upper: color, lower: color => '▀' fg: upper color / bg: lower color - // - // Note that because the foreground reset color (i.e. default foreground color) - // is usually not the same as the background reset color (i.e. default - // background color), we need to swap around the colors for that state - // (2 reset/color). - // - // When the upper and lower colors are the same, we could continue to use an - // upper half block, but we choose to use a full block instead. This - // allows us to write unit tests that treat the cell as a single - // character instead of two half block characters. - - // Note we implement this slightly differently to what is done in ratatui's - // repo, since their version doesn't seem to compile for me... - // TODO: Whenever I add this as a valid marker, make sure this works fine with - // the overriden time_chart drawing-layer-thing. - - // Join the upper and lower rows, and emit a tuple vector of strings to print, - // and their colours. - let (string, colors) = self - .pixels - .iter() - .tuples() - .flat_map(|(upper_row, lower_row)| zip(upper_row, lower_row)) - .map(|(upper, lower)| match (upper, lower) { - (Color::Reset, Color::Reset) => (' ', (Color::Reset, Color::Reset)), - (Color::Reset, &lower) => (symbols::half_block::LOWER, (Color::Reset, lower)), - (&upper, Color::Reset) => (symbols::half_block::UPPER, (upper, Color::Reset)), - (&upper, &lower) => { - let c = if lower == upper { - symbols::half_block::FULL - } else { - symbols::half_block::UPPER - }; - - (c, (upper, lower)) - } - }) - .unzip(); - - Layer { string, colors } - } - - fn reset(&mut self) { - self.pixels.fill(vec![Color::Reset; self.width as usize]); - } - - fn paint(&mut self, x: usize, y: usize, color: Color) { - self.pixels[y][x] = color; - } -} - -impl<'a, 'b> Painter<'a, 'b> { - /// Convert the (x, y) coordinates to location of a point on the grid - /// - /// # Examples: - /// ``` - /// use tui::{ - /// symbols, - /// widgets::canvas::{Context, Painter}, - /// }; - /// - /// let mut ctx = Context::new(2, 2, [1.0, 2.0], [0.0, 2.0], symbols::Marker::Braille); - /// let mut painter = Painter::from(&mut ctx); - /// let point = painter.get_point(1.0, 0.0); - /// assert_eq!(point, Some((0, 7))); - /// let point = painter.get_point(1.5, 1.0); - /// assert_eq!(point, Some((1, 3))); - /// let point = painter.get_point(0.0, 0.0); - /// assert_eq!(point, None); - /// let point = painter.get_point(2.0, 2.0); - /// assert_eq!(point, Some((3, 0))); - /// let point = painter.get_point(1.0, 2.0); - /// assert_eq!(point, Some((0, 0))); - /// ``` - pub fn get_point(&self, x: f64, y: f64) -> Option<(usize, usize)> { - let left = self.context.x_bounds[0]; - let right = self.context.x_bounds[1]; - let top = self.context.y_bounds[1]; - let bottom = self.context.y_bounds[0]; - if x < left || x > right || y < bottom || y > top { - return None; - } - let width = (self.context.x_bounds[1] - self.context.x_bounds[0]).abs(); - let height = (self.context.y_bounds[1] - self.context.y_bounds[0]).abs(); - if width == 0.0 || height == 0.0 { - return None; - } - let x = ((x - left) * self.resolution.0 / width) as usize; - let y = ((top - y) * self.resolution.1 / height) as usize; - Some((x, y)) - } - - /// Paint a point of the grid - /// - /// # Examples: - /// ``` - /// use tui::{ - /// style::Color, - /// symbols, - /// widgets::canvas::{Context, Painter}, - /// }; - /// - /// let mut ctx = Context::new(1, 1, [0.0, 2.0], [0.0, 2.0], symbols::Marker::Braille); - /// let mut painter = Painter::from(&mut ctx); - /// let cell = painter.paint(1, 3, Color::Red); - /// ``` - pub fn paint(&mut self, x: usize, y: usize, color: Color) { - self.context.grid.paint(x, y, color); - } -} - -impl<'a, 'b> From<&'a mut Context<'b>> for Painter<'a, 'b> { - fn from(context: &'a mut Context<'b>) -> Painter<'a, 'b> { - let resolution = context.grid.resolution(); - Painter { - context, - resolution, - } - } -} - -/// Holds the state of the Canvas when painting to it. -#[derive(Debug)] -pub struct Context<'a> { - x_bounds: [f64; 2], - y_bounds: [f64; 2], - grid: Box, - dirty: bool, - labels: Vec>, -} - -impl<'a> Context<'a> { - pub fn new( - width: u16, height: u16, x_bounds: [f64; 2], y_bounds: [f64; 2], marker: symbols::Marker, - ) -> Context<'a> { - let grid: Box = match marker { - symbols::Marker::Dot => Box::new(CharGrid::new(width, height, '•')), - symbols::Marker::Block => Box::new(CharGrid::new(width, height, '█')), - symbols::Marker::Bar => Box::new(CharGrid::new(width, height, '▄')), - symbols::Marker::Braille => Box::new(BrailleGrid::new(width, height)), - symbols::Marker::HalfBlock => Box::new(HalfBlockGrid::new(width, height)), - }; - Context { - x_bounds, - y_bounds, - grid, - dirty: false, - labels: Vec::new(), - } - } - - /// Draw any object that may implement the Shape trait - pub fn draw(&mut self, shape: &S) - where - S: Shape, - { - self.dirty = true; - let mut painter = Painter::from(self); - shape.draw(&mut painter); - } -} - -/// The Canvas widget may be used to draw more detailed figures using braille -/// patterns (each cell can have a braille character in 8 different positions). -pub struct Canvas<'a, F> -where - F: Fn(&mut Context<'_>), -{ - block: Option>, - x_bounds: [f64; 2], - y_bounds: [f64; 2], - painter: Option, - background_color: Color, - marker: symbols::Marker, -} - -impl<'a, F> Default for Canvas<'a, F> -where - F: Fn(&mut Context<'_>), -{ - fn default() -> Canvas<'a, F> { - Canvas { - block: None, - x_bounds: [0.0, 0.0], - y_bounds: [0.0, 0.0], - painter: None, - background_color: Color::Reset, - marker: symbols::Marker::Braille, - } - } -} - -impl<'a, F> Canvas<'a, F> -where - F: Fn(&mut Context<'_>), -{ - pub fn x_bounds(mut self, bounds: [f64; 2]) -> Canvas<'a, F> { - self.x_bounds = bounds; - self - } - - pub fn y_bounds(mut self, bounds: [f64; 2]) -> Canvas<'a, F> { - self.y_bounds = bounds; - self - } - - /// Store the closure that will be used to draw to the Canvas - pub fn paint(mut self, f: F) -> Canvas<'a, F> { - self.painter = Some(f); - self - } - - pub fn background_color(mut self, color: Color) -> Canvas<'a, F> { - self.background_color = color; - self - } - - /// Change the type of points used to draw the shapes. By default the - /// braille patterns are used as they provide a more fine grained result - /// but you might want to use the simple dot or block instead if the - /// targeted terminal does not support those symbols. - /// - /// # Examples - /// - /// ``` - /// # use tui::widgets::canvas::Canvas; - /// # use tui::symbols; - /// Canvas::default() - /// .marker(symbols::Marker::Braille) - /// .paint(|ctx| {}); - /// - /// Canvas::default() - /// .marker(symbols::Marker::Dot) - /// .paint(|ctx| {}); - /// - /// Canvas::default() - /// .marker(symbols::Marker::Block) - /// .paint(|ctx| {}); - /// ``` - pub fn marker(mut self, marker: symbols::Marker) -> Canvas<'a, F> { - self.marker = marker; - self - } -} - -impl<'a, F> Widget for Canvas<'a, F> -where - F: Fn(&mut Context<'_>), -{ - fn render(mut self, area: Rect, buf: &mut Buffer) { - let canvas_area = match self.block.take() { - Some(b) => { - let inner_area = b.inner(area); - b.render(area, buf); - inner_area - } - None => area, - }; - - buf.set_style(canvas_area, Style::default().bg(self.background_color)); - - let width = canvas_area.width as usize; - - let painter = match self.painter { - Some(ref p) => p, - None => return, - }; - - // Create a blank context that match the size of the canvas - let mut ctx = Context::new( - canvas_area.width, - canvas_area.height, - self.x_bounds, - self.y_bounds, - self.marker, - ); - // Paint to this context - painter(&mut ctx); - - // Paint whatever is in the ctx. - let layer = ctx.grid.save(); - - for (i, (ch, (fg, bg))) in layer - .string - .chars() - .zip(layer.colors.into_iter()) - .enumerate() - { - if ch != ' ' && ch != '\u{2800}' { - let (x, y) = (i % width, i / width); - buf.get_mut(x as u16 + canvas_area.left(), y as u16 + canvas_area.top()) - .set_char(ch) - .set_fg(fg) - .set_bg(bg); - } - } - - // Reset the grid and mark as non-dirty. - ctx.grid.reset(); - ctx.dirty = false; - - // Finally draw the labels - let left = self.x_bounds[0]; - let right = self.x_bounds[1]; - let top = self.y_bounds[1]; - let bottom = self.y_bounds[0]; - let width = (self.x_bounds[1] - self.x_bounds[0]).abs(); - let height = (self.y_bounds[1] - self.y_bounds[0]).abs(); - let resolution = { - let width = f64::from(canvas_area.width - 1); - let height = f64::from(canvas_area.height - 1); - (width, height) - }; - for label in ctx - .labels - .iter() - .filter(|l| l.x >= left && l.x <= right && l.y <= top && l.y >= bottom) - { - let x = ((label.x - left) * resolution.0 / width) as u16 + canvas_area.left(); - let y = ((top - label.y) * resolution.1 / height) as u16 + canvas_area.top(); - buf.set_line(x, y, &label.spans, canvas_area.right() - x); - } - } -} diff --git a/src/canvas/components/tui_widget/time_chart/points.rs b/src/canvas/components/tui_widget/time_chart/points.rs deleted file mode 100644 index 702835008..000000000 --- a/src/canvas/components/tui_widget/time_chart/points.rs +++ /dev/null @@ -1,219 +0,0 @@ -use tui::{ - style::Color, - widgets::{ - canvas::{Line as CanvasLine, Points}, - GraphType, - }, -}; - -use super::{Context, Dataset, Point, TimeChart}; -use crate::utils::general::partial_ordering; - -impl TimeChart<'_> { - pub(crate) fn draw_points(&self, ctx: &mut Context<'_>) { - // Idea is to: - // - Go over all datasets, determine *where* a point will be drawn. - // - Last point wins for what gets drawn. - // - We set _all_ points for all datasets before actually rendering. - // - // By doing this, it's a bit more efficient from my experience than looping - // over each dataset and rendering a new layer each time. - // - // See and - // for the original motivation. - // - // We also additionally do some interpolation logic because we may get caught - // missing some points when drawing, but we generally want to avoid - // jarring gaps between the edges when there's a point that is off - // screen and so a line isn't drawn (right edge generally won't have this issue - // issue but it can happen in some cases). - - for dataset in &self.datasets { - let color = dataset.style.fg.unwrap_or(Color::Reset); - - let start_bound = self.x_axis.bounds[0]; - let end_bound = self.x_axis.bounds[1]; - - let (start_index, interpolate_start) = get_start(dataset, start_bound); - let (end_index, interpolate_end) = get_end(dataset, end_bound); - - let data_slice = &dataset.data[start_index..end_index]; - - if let Some(interpolate_start) = interpolate_start { - if let (Some(older_point), Some(newer_point)) = ( - dataset.data.get(interpolate_start), - dataset.data.get(interpolate_start + 1), - ) { - let interpolated_point = ( - self.x_axis.bounds[0], - interpolate_point(older_point, newer_point, self.x_axis.bounds[0]), - ); - - if let GraphType::Line = dataset.graph_type { - ctx.draw(&CanvasLine { - x1: interpolated_point.0, - y1: interpolated_point.1, - x2: newer_point.0, - y2: newer_point.1, - color, - }); - } else { - ctx.draw(&Points { - coords: &[interpolated_point], - color, - }); - } - } - } - - if let GraphType::Line = dataset.graph_type { - for data in data_slice.windows(2) { - ctx.draw(&CanvasLine { - x1: data[0].0, - y1: data[0].1, - x2: data[1].0, - y2: data[1].1, - color, - }); - } - } else { - ctx.draw(&Points { - coords: data_slice, - color, - }); - } - - if let Some(interpolate_end) = interpolate_end { - if let (Some(older_point), Some(newer_point)) = ( - dataset.data.get(interpolate_end - 1), - dataset.data.get(interpolate_end), - ) { - let interpolated_point = ( - self.x_axis.bounds[1], - interpolate_point(older_point, newer_point, self.x_axis.bounds[1]), - ); - - if let GraphType::Line = dataset.graph_type { - ctx.draw(&CanvasLine { - x1: older_point.0, - y1: older_point.1, - x2: interpolated_point.0, - y2: interpolated_point.1, - color, - }); - } else { - ctx.draw(&Points { - coords: &[interpolated_point], - color, - }); - } - } - } - } - } -} - -/// Returns the start index and potential interpolation index given the start -/// time and the dataset. -fn get_start(dataset: &Dataset<'_>, start_bound: f64) -> (usize, Option) { - match dataset - .data - .binary_search_by(|(x, _y)| partial_ordering(x, &start_bound)) - { - Ok(index) => (index, None), - Err(index) => (index, index.checked_sub(1)), - } -} - -/// Returns the end position and potential interpolation index given the end -/// time and the dataset. -fn get_end(dataset: &Dataset<'_>, end_bound: f64) -> (usize, Option) { - match dataset - .data - .binary_search_by(|(x, _y)| partial_ordering(x, &end_bound)) - { - // In the success case, this means we found an index. Add one since we want to include this - // index and we expect to use the returned index as part of a (m..n) range. - Ok(index) => (index.saturating_add(1), None), - // In the fail case, this means we did not find an index, and the returned index is where - // one would *insert* the location. This index is where one would insert to fit - // inside the dataset - and since this is an end bound, index is, in a sense, - // already "+1" for our range later. - Err(index) => (index, { - let sum = index.checked_add(1); - match sum { - Some(s) if s < dataset.data.len() => sum, - _ => None, - } - }), - } -} - -/// Returns the y-axis value for a given `x`, given two points to draw a line -/// between. -fn interpolate_point(older_point: &Point, newer_point: &Point, x: f64) -> f64 { - let delta_x = newer_point.0 - older_point.0; - let delta_y = newer_point.1 - older_point.1; - let slope = delta_y / delta_x; - - (older_point.1 + (x - older_point.0) * slope).max(0.0) -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn time_chart_test_interpolation() { - let data = [(-3.0, 8.0), (-1.0, 6.0), (0.0, 5.0)]; - - assert_eq!(interpolate_point(&data[1], &data[2], 0.0), 5.0); - assert_eq!(interpolate_point(&data[1], &data[2], -0.25), 5.25); - assert_eq!(interpolate_point(&data[1], &data[2], -0.5), 5.5); - assert_eq!(interpolate_point(&data[0], &data[1], -1.0), 6.0); - assert_eq!(interpolate_point(&data[0], &data[1], -1.5), 6.5); - assert_eq!(interpolate_point(&data[0], &data[1], -2.0), 7.0); - assert_eq!(interpolate_point(&data[0], &data[1], -2.5), 7.5); - assert_eq!(interpolate_point(&data[0], &data[1], -3.0), 8.0); - } - - #[test] - fn time_chart_empty_dataset() { - let data = []; - let dataset = Dataset::default().data(&data); - - assert_eq!(get_start(&dataset, -100.0), (0, None)); - assert_eq!(get_start(&dataset, -3.0), (0, None)); - - assert_eq!(get_end(&dataset, 0.0), (0, None)); - assert_eq!(get_end(&dataset, 100.0), (0, None)); - } - - #[test] - fn time_chart_test_data_trimming() { - let data = [ - (-3.0, 8.0), - (-2.5, 15.0), - (-2.0, 9.0), - (-1.0, 6.0), - (0.0, 5.0), - ]; - let dataset = Dataset::default().data(&data); - - // Test start point cases (miss and hit) - assert_eq!(get_start(&dataset, -100.0), (0, None)); - assert_eq!(get_start(&dataset, -3.0), (0, None)); - assert_eq!(get_start(&dataset, -2.8), (1, Some(0))); - assert_eq!(get_start(&dataset, -2.5), (1, None)); - assert_eq!(get_start(&dataset, -2.4), (2, Some(1))); - - // Test end point cases (miss and hit) - assert_eq!(get_end(&dataset, -2.5), (2, None)); - assert_eq!(get_end(&dataset, -2.4), (2, Some(3))); - assert_eq!(get_end(&dataset, -1.4), (3, Some(4))); - assert_eq!(get_end(&dataset, -1.0), (4, None)); - assert_eq!(get_end(&dataset, 0.0), (5, None)); - assert_eq!(get_end(&dataset, 1.0), (5, None)); - assert_eq!(get_end(&dataset, 100.0), (5, None)); - } -} diff --git a/src/canvas/components/widget_carousel.rs b/src/canvas/components/widget_carousel.rs index fd5b7a4cd..b5b07e506 100644 --- a/src/canvas/components/widget_carousel.rs +++ b/src/canvas/components/widget_carousel.rs @@ -1,12 +1,12 @@ use tui::{ + Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, - terminal::Frame, text::{Line, Span}, widgets::{Block, Paragraph}, }; use crate::{ - app::{layout_manager::BottomWidgetType, App}, + app::{App, layout_manager::BottomWidgetType}, canvas::Painter, }; @@ -84,26 +84,25 @@ impl Painter { }, ); + // TODO: I can do this text effect as just a border now! let left_name = left_table.get_pretty_name(); let right_name = right_table.get_pretty_name(); - let num_spaces = usize::from(draw_loc.width).saturating_sub(6 + left_name.len() + right_name.len()); + let carousel_text_style = if widget_id == app_state.current_widget.widget_id { + self.styles.highlighted_border_style + } else { + self.styles.text_style + }; let left_arrow_text = vec![ Line::default(), - Line::from(Span::styled( - format!("◄ {left_name}"), - self.colours.text_style, - )), + Line::from(Span::styled(format!("◄ {left_name}"), carousel_text_style)), ]; let right_arrow_text = vec![ Line::default(), - Line::from(Span::styled( - format!("{right_name} ►"), - self.colours.text_style, - )), + Line::from(Span::styled(format!("{right_name} ►"), carousel_text_style)), ]; let margined_draw_loc = Layout::default() diff --git a/src/canvas/dialogs.rs b/src/canvas/dialogs.rs deleted file mode 100644 index fe40156eb..000000000 --- a/src/canvas/dialogs.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod dd_dialog; -pub mod help_dialog; diff --git a/src/canvas/dialogs/dd_dialog.rs b/src/canvas/dialogs/dd_dialog.rs deleted file mode 100644 index f4b54a8b8..000000000 --- a/src/canvas/dialogs/dd_dialog.rs +++ /dev/null @@ -1,442 +0,0 @@ -#[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "macos"))] -use std::cmp::min; - -use tui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - terminal::Frame, - text::{Line, Span, Text}, - widgets::{Block, Borders, Paragraph, Wrap}, -}; - -use crate::{ - app::{App, KillSignal, MAX_PROCESS_SIGNAL}, - canvas::Painter, - widgets::ProcWidgetMode, -}; - -const DD_BASE: &str = " Confirm Kill Process ── Esc to close "; -const DD_ERROR_BASE: &str = " Error ── Esc to close "; - -cfg_if::cfg_if! { - if #[cfg(target_os = "linux")] { - const SIGNAL_TEXT: [&str; 63] = [ - "0: Cancel", - "1: HUP", - "2: INT", - "3: QUIT", - "4: ILL", - "5: TRAP", - "6: ABRT", - "7: BUS", - "8: FPE", - "9: KILL", - "10: USR1", - "11: SEGV", - "12: USR2", - "13: PIPE", - "14: ALRM", - "15: TERM", - "16: STKFLT", - "17: CHLD", - "18: CONT", - "19: STOP", - "20: TSTP", - "21: TTIN", - "22: TTOU", - "23: URG", - "24: XCPU", - "25: XFSZ", - "26: VTALRM", - "27: PROF", - "28: WINCH", - "29: IO", - "30: PWR", - "31: SYS", - "34: RTMIN", - "35: RTMIN+1", - "36: RTMIN+2", - "37: RTMIN+3", - "38: RTMIN+4", - "39: RTMIN+5", - "40: RTMIN+6", - "41: RTMIN+7", - "42: RTMIN+8", - "43: RTMIN+9", - "44: RTMIN+10", - "45: RTMIN+11", - "46: RTMIN+12", - "47: RTMIN+13", - "48: RTMIN+14", - "49: RTMIN+15", - "50: RTMAX-14", - "51: RTMAX-13", - "52: RTMAX-12", - "53: RTMAX-11", - "54: RTMAX-10", - "55: RTMAX-9", - "56: RTMAX-8", - "57: RTMAX-7", - "58: RTMAX-6", - "59: RTMAX-5", - "60: RTMAX-4", - "61: RTMAX-3", - "62: RTMAX-2", - "63: RTMAX-1", - "64: RTMAX", - ]; - } else if #[cfg(target_os = "macos")] { - const SIGNAL_TEXT: [&str; 32] = [ - "0: Cancel", - "1: HUP", - "2: INT", - "3: QUIT", - "4: ILL", - "5: TRAP", - "6: ABRT", - "7: EMT", - "8: FPE", - "9: KILL", - "10: BUS", - "11: SEGV", - "12: SYS", - "13: PIPE", - "14: ALRM", - "15: TERM", - "16: URG", - "17: STOP", - "18: TSTP", - "19: CONT", - "20: CHLD", - "21: TTIN", - "22: TTOU", - "23: IO", - "24: XCPU", - "25: XFSZ", - "26: VTALRM", - "27: PROF", - "28: WINCH", - "29: INFO", - "30: USR1", - "31: USR2", - ]; - } else if #[cfg(target_os = "freebsd")] { - const SIGNAL_TEXT: [&str; 34] = [ - "0: Cancel", - "1: HUP", - "2: INT", - "3: QUIT", - "4: ILL", - "5: TRAP", - "6: ABRT", - "7: EMT", - "8: FPE", - "9: KILL", - "10: BUS", - "11: SEGV", - "12: SYS", - "13: PIPE", - "14: ALRM", - "15: TERM", - "16: URG", - "17: STOP", - "18: TSTP", - "19: CONT", - "20: CHLD", - "21: TTIN", - "22: TTOU", - "23: IO", - "24: XCPU", - "25: XFSZ", - "26: VTALRM", - "27: PROF", - "28: WINCH", - "29: INFO", - "30: USR1", - "31: USR2", - "32: THR", - "33: LIBRT", - ]; - } -} - -impl Painter { - pub fn get_dd_spans(&self, app_state: &App) -> Option> { - if let Some(dd_err) = &app_state.dd_err { - return Some(Text::from(vec![ - Line::default(), - Line::from("Failed to kill process."), - Line::from(dd_err.clone()), - Line::from("Please press ENTER or ESC to close this dialog."), - ])); - } else if let Some(to_kill_processes) = app_state.get_to_delete_processes() { - if let Some(first_pid) = to_kill_processes.1.first() { - return Some(Text::from(vec![ - Line::from(""), - if app_state - .states - .proc_state - .widget_states - .get(&app_state.current_widget.widget_id) - .map(|p| matches!(p.mode, ProcWidgetMode::Grouped)) - .unwrap_or(false) - { - if to_kill_processes.1.len() != 1 { - Line::from(format!( - "Kill {} processes with the name '{}'? Press ENTER to confirm.", - to_kill_processes.1.len(), - to_kill_processes.0 - )) - } else { - Line::from(format!( - "Kill 1 process with the name '{}'? Press ENTER to confirm.", - to_kill_processes.0 - )) - } - } else { - Line::from(format!( - "Kill process '{}' with PID {}? Press ENTER to confirm.", - to_kill_processes.0, first_pid - )) - }, - ])); - } - } - - None - } - - fn draw_dd_confirm_buttons( - &self, f: &mut Frame<'_>, button_draw_loc: &Rect, app_state: &mut App, - ) { - if MAX_PROCESS_SIGNAL == 1 || !app_state.app_config_fields.is_advanced_kill { - let (yes_button, no_button) = match app_state.delete_dialog_state.selected_signal { - KillSignal::Kill(_) => ( - Span::styled("Yes", self.colours.selected_text_style), - Span::styled("No", self.colours.text_style), - ), - KillSignal::Cancel => ( - Span::styled("Yes", self.colours.text_style), - Span::styled("No", self.colours.selected_text_style), - ), - }; - - let button_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Percentage(35), - Constraint::Percentage(30), - Constraint::Percentage(35), - ] - .as_ref(), - ) - .split(*button_draw_loc); - - f.render_widget( - Paragraph::new(yes_button) - .block(Block::default()) - .alignment(Alignment::Right), - button_layout[0], - ); - f.render_widget( - Paragraph::new(no_button) - .block(Block::default()) - .alignment(Alignment::Left), - button_layout[2], - ); - - if app_state.should_get_widget_bounds() { - const SIGNAL: usize = if cfg!(target_os = "windows") { 1 } else { 15 }; - - // This is kinda weird, but the gist is: - // - We have three sections; we put our mouse bounding box for the "yes" button - // at the very right edge of the left section and 3 characters back. We then - // give it a buffer size of 1 on the x-coordinate. - // - Same for the "no" button, except it is the right section and we do it from - // the start of the right section. - // - // Lastly, note that mouse detection for the dd buttons assume correct widths. - // As such, we correct them here and check with >= and <= mouse - // bound checks, as opposed to how we do it elsewhere with >= and <. See https://github.com/ClementTsang/bottom/pull/459 for details. - app_state.delete_dialog_state.button_positions = vec![ - // Yes - ( - button_layout[0].x + button_layout[0].width - 4, - button_layout[0].y, - button_layout[0].x + button_layout[0].width, - button_layout[0].y, - SIGNAL, - ), - // No - ( - button_layout[2].x - 1, - button_layout[2].y, - button_layout[2].x + 2, - button_layout[2].y, - 0, - ), - ]; - } - } else { - #[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "macos"))] - { - let button_rect = Layout::default() - .direction(Direction::Horizontal) - .margin(1) - .constraints( - [ - Constraint::Length((button_draw_loc.width - 14) / 2), - Constraint::Min(0), - Constraint::Length((button_draw_loc.width - 14) / 2), - ] - .as_ref(), - ) - .split(*button_draw_loc)[1]; - - let mut selected = match app_state.delete_dialog_state.selected_signal { - KillSignal::Cancel => 0, - KillSignal::Kill(signal) => signal, - }; - // 32+33 are skipped - if selected > 31 { - selected -= 2; - } - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints(vec![Constraint::Min(1); button_rect.height.into()]) - .split(button_rect); - - let prev_offset: usize = app_state.delete_dialog_state.scroll_pos; - app_state.delete_dialog_state.scroll_pos = if selected == 0 { - 0 - } else if selected < prev_offset + 1 { - selected - 1 - } else if selected > prev_offset + layout.len() - 1 { - selected - layout.len() + 1 - } else { - prev_offset - }; - let scroll_offset: usize = app_state.delete_dialog_state.scroll_pos; - - let mut buttons = SIGNAL_TEXT - [scroll_offset + 1..min((layout.len()) + scroll_offset, SIGNAL_TEXT.len())] - .iter() - .map(|text| Span::styled(*text, self.colours.text_style)) - .collect::>>(); - buttons.insert(0, Span::styled(SIGNAL_TEXT[0], self.colours.text_style)); - buttons[selected - scroll_offset] = - Span::styled(SIGNAL_TEXT[selected], self.colours.selected_text_style); - - app_state.delete_dialog_state.button_positions = layout - .iter() - .enumerate() - .map(|(i, pos)| { - ( - pos.x, - pos.y, - pos.x + pos.width - 1, - pos.y + pos.height - 1, - if i == 0 { 0 } else { scroll_offset } + i, - ) - }) - .collect::>(); - - for (btn, pos) in buttons.into_iter().zip(layout.iter()) { - f.render_widget(Paragraph::new(btn).alignment(Alignment::Left), *pos); - } - } - } - } - - pub fn draw_dd_dialog( - &self, f: &mut Frame<'_>, dd_text: Option>, app_state: &mut App, draw_loc: Rect, - ) -> bool { - if let Some(dd_text) = dd_text { - let dd_title = if app_state.dd_err.is_some() { - Line::from(vec![ - Span::styled(" Error ", self.colours.widget_title_style), - Span::styled( - format!( - "─{}─ Esc to close ", - "─".repeat( - usize::from(draw_loc.width) - .saturating_sub(DD_ERROR_BASE.chars().count() + 2) - ) - ), - self.colours.border_style, - ), - ]) - } else { - Line::from(vec![ - Span::styled(" Confirm Kill Process ", self.colours.widget_title_style), - Span::styled( - format!( - "─{}─ Esc to close ", - "─".repeat( - usize::from(draw_loc.width) - .saturating_sub(DD_BASE.chars().count() + 2) - ) - ), - self.colours.border_style, - ), - ]) - }; - - f.render_widget( - Paragraph::new(dd_text) - .block( - Block::default() - .title(dd_title) - .style(self.colours.border_style) - .borders(Borders::ALL) - .border_style(self.colours.border_style), - ) - .style(self.colours.text_style) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), - draw_loc, - ); - - let btn_height = { - cfg_if::cfg_if! { - if #[cfg(target_os = "windows")] { - 3 - } else { - if !app_state.app_config_fields.is_advanced_kill { - 3 - } else { - 20 - } - } - } - }; - - // Now draw buttons if needed... - let split_draw_loc = Layout::default() - .direction(Direction::Vertical) - .constraints(if app_state.dd_err.is_some() { - vec![Constraint::Percentage(100)] - } else { - vec![Constraint::Min(3), Constraint::Length(btn_height)] - }) - .split(draw_loc); - - // This being true implies that dd_err is none. - if let Some(button_draw_loc) = split_draw_loc.get(1) { - self.draw_dd_confirm_buttons(f, button_draw_loc, app_state); - } - - if app_state.dd_err.is_some() { - return app_state.delete_dialog_state.is_showing_dd; - } else { - return true; - } - } - - // Currently we just return "false" if things go wrong finding - // the process or a first PID (if an error arises it should be caught). - // I don't really like this, and I find it ugly, but it works for now. - false - } -} diff --git a/src/canvas/dialogs/help_dialog.rs b/src/canvas/dialogs/help_dialog.rs index eac3fbc47..6f2aad853 100644 --- a/src/canvas/dialogs/help_dialog.rs +++ b/src/canvas/dialogs/help_dialog.rs @@ -1,21 +1,19 @@ use std::cmp::{max, min}; use tui::{ + Frame, layout::{Alignment, Rect}, - terminal::Frame, text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, + widgets::{Paragraph, Wrap}, }; use unicode_width::UnicodeWidthStr; use crate::{ app::App, - canvas::Painter, + canvas::{Painter, drawing_utils::dialog_block}, constants::{self, HELP_TEXT}, }; -const HELP_BASE: &str = " Help ── Esc to close "; - // TODO: [REFACTOR] Make generic dialog boxes to build off of instead? impl Painter { fn help_text_lines(&self) -> Vec> { @@ -28,12 +26,12 @@ impl Painter { if itx > 0 { if let Some(header) = section.next() { styled_help_spans.push(Span::default()); - styled_help_spans.push(Span::styled(*header, self.colours.table_header_style)); + styled_help_spans.push(Span::styled(*header, self.styles.table_header_style)); } } section.for_each(|&text| { - styled_help_spans.push(Span::styled(text, self.colours.text_style)) + styled_help_spans.push(Span::styled(text, self.styles.text_style)) }); }); @@ -43,24 +41,12 @@ impl Painter { pub fn draw_help_dialog(&self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect) { let styled_help_text = self.help_text_lines(); - let help_title = Line::from(vec![ - Span::styled(" Help ", self.colours.widget_title_style), - Span::styled( - format!( - "─{}─ Esc to close ", - "─".repeat( - usize::from(draw_loc.width).saturating_sub(HELP_BASE.chars().count() + 2) - ) - ), - self.colours.border_style, - ), - ]); - - let block = Block::default() - .title(help_title) - .style(self.colours.border_style) - .borders(Borders::ALL) - .border_style(self.colours.border_style); + let block = dialog_block(self.styles.border_type) + .border_style(self.styles.border_style) + .title_top(Line::styled(" Help ", self.styles.widget_title_style)) + .title_top( + Line::styled(" Esc to close ", self.styles.widget_title_style).right_aligned(), + ); if app_state.should_get_widget_bounds() { // We must also recalculate how many lines are wrapping to properly get @@ -116,7 +102,7 @@ impl Painter { f.render_widget( Paragraph::new(styled_help_text.clone()) .block(block) - .style(self.colours.text_style) + .style(self.styles.text_style) .alignment(Alignment::Left) .wrap(Wrap { trim: true }) .scroll(( diff --git a/src/canvas/dialogs/mod.rs b/src/canvas/dialogs/mod.rs new file mode 100644 index 000000000..d940d7781 --- /dev/null +++ b/src/canvas/dialogs/mod.rs @@ -0,0 +1,2 @@ +pub mod help_dialog; +pub mod process_kill_dialog; diff --git a/src/canvas/dialogs/process_kill_dialog.rs b/src/canvas/dialogs/process_kill_dialog.rs new file mode 100644 index 000000000..cef6c6695 --- /dev/null +++ b/src/canvas/dialogs/process_kill_dialog.rs @@ -0,0 +1,881 @@ +//! A dialog box to handle killing processes. + +use std::time::Instant; + +use cfg_if::cfg_if; +use tui::{ + Frame, + layout::{Alignment, Constraint, Flex, Layout, Position, Rect}, + text::{Line, Span, Text}, + widgets::{Paragraph, Wrap}, +}; + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] +use tui::widgets::ListState; + +use crate::{ + canvas::drawing_utils::dialog_block, collection::processes::Pid, options::config::style::Styles, +}; + +// Configure signal text based on the target OS. +cfg_if! { + if #[cfg(target_os = "linux")] { + const DEFAULT_KILL_SIGNAL: usize = 15; + const SIGNAL_TEXT: [&str; 63] = [ + "0: Cancel", + "1: HUP", + "2: INT", + "3: QUIT", + "4: ILL", + "5: TRAP", + "6: ABRT", + "7: BUS", + "8: FPE", + "9: KILL", + "10: USR1", + "11: SEGV", + "12: USR2", + "13: PIPE", + "14: ALRM", + "15: TERM", + "16: STKFLT", + "17: CHLD", + "18: CONT", + "19: STOP", + "20: TSTP", + "21: TTIN", + "22: TTOU", + "23: URG", + "24: XCPU", + "25: XFSZ", + "26: VTALRM", + "27: PROF", + "28: WINCH", + "29: IO", + "30: PWR", + "31: SYS", + "34: RTMIN", + "35: RTMIN+1", + "36: RTMIN+2", + "37: RTMIN+3", + "38: RTMIN+4", + "39: RTMIN+5", + "40: RTMIN+6", + "41: RTMIN+7", + "42: RTMIN+8", + "43: RTMIN+9", + "44: RTMIN+10", + "45: RTMIN+11", + "46: RTMIN+12", + "47: RTMIN+13", + "48: RTMIN+14", + "49: RTMIN+15", + "50: RTMAX-14", + "51: RTMAX-13", + "52: RTMAX-12", + "53: RTMAX-11", + "54: RTMAX-10", + "55: RTMAX-9", + "56: RTMAX-8", + "57: RTMAX-7", + "58: RTMAX-6", + "59: RTMAX-5", + "60: RTMAX-4", + "61: RTMAX-3", + "62: RTMAX-2", + "63: RTMAX-1", + "64: RTMAX", + ]; + } else if #[cfg(target_os = "macos")] { + const DEFAULT_KILL_SIGNAL: usize = 15; + const SIGNAL_TEXT: [&str; 32] = [ + "0: Cancel", + "1: HUP", + "2: INT", + "3: QUIT", + "4: ILL", + "5: TRAP", + "6: ABRT", + "7: EMT", + "8: FPE", + "9: KILL", + "10: BUS", + "11: SEGV", + "12: SYS", + "13: PIPE", + "14: ALRM", + "15: TERM", + "16: URG", + "17: STOP", + "18: TSTP", + "19: CONT", + "20: CHLD", + "21: TTIN", + "22: TTOU", + "23: IO", + "24: XCPU", + "25: XFSZ", + "26: VTALRM", + "27: PROF", + "28: WINCH", + "29: INFO", + "30: USR1", + "31: USR2", + ]; + } else if #[cfg(target_os = "freebsd")] { + const DEFAULT_KILL_SIGNAL: usize = 15; + const SIGNAL_TEXT: [&str; 34] = [ + "0: Cancel", + "1: HUP", + "2: INT", + "3: QUIT", + "4: ILL", + "5: TRAP", + "6: ABRT", + "7: EMT", + "8: FPE", + "9: KILL", + "10: BUS", + "11: SEGV", + "12: SYS", + "13: PIPE", + "14: ALRM", + "15: TERM", + "16: URG", + "17: STOP", + "18: TSTP", + "19: CONT", + "20: CHLD", + "21: TTIN", + "22: TTOU", + "23: IO", + "24: XCPU", + "25: XFSZ", + "26: VTALRM", + "27: PROF", + "28: WINCH", + "29: INFO", + "30: USR1", + "31: USR2", + "32: THR", + "33: LIBRT", + ]; + } +} + +/// Button state type for a [`ProcessKillDialog`]. +/// +/// Simple only has two buttons (yes/no), while signals (AKA advanced) are +/// a list of signals to send. +/// +/// Note that signals are not available for Windows. +#[derive(Debug)] +pub(crate) enum ButtonState { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + Signals { + state: ListState, + last_button_draw_area: Rect, + }, + Simple { + yes: bool, + last_yes_button_area: Rect, + last_no_button_area: Rect, + }, +} + +#[derive(Debug)] +struct ProcessKillSelectingInner { + process_name: String, + pids: Vec, + button_state: ButtonState, +} + +/// The current state of the process kill dialog. +#[derive(Default, Debug)] +enum ProcessKillDialogState { + #[default] + NotEnabled, + Selecting(ProcessKillSelectingInner), + Error { + process_name: String, + pid: Option, + err: String, + }, +} + +/// Process kill dialog. +#[derive(Default, Debug)] +pub(crate) struct ProcessKillDialog { + state: ProcessKillDialogState, + last_char: Option<(char, Instant)>, +} + +impl ProcessKillDialog { + pub fn reset(&mut self) { + *self = Self::default(); + } + + #[inline] + pub fn is_open(&self) -> bool { + !(matches!(self.state, ProcessKillDialogState::NotEnabled)) + } + + pub fn on_esc(&mut self) { + self.reset(); + } + + pub fn on_enter(&mut self) { + // We do this to get around borrow issues. + let mut current = ProcessKillDialogState::NotEnabled; + std::mem::swap(&mut self.state, &mut current); + + if let ProcessKillDialogState::Selecting(state) = current { + let process_name = state.process_name; + let button_state = state.button_state; + let pids = state.pids; + + match button_state { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + ButtonState::Signals { state, .. } => { + use crate::utils::process_killer; + + if let Some(selected) = state.selected() { + if selected != 0 { + // On Linux, we need to skip 32 and 33. + let signal = if cfg!(target_os = "linux") + && (selected == 32 || selected == 33) + { + selected + 2 + } else { + selected + }; + + for pid in pids { + if let Err(err) = + process_killer::kill_process_given_pid(pid, signal) + { + self.state = ProcessKillDialogState::Error { + process_name, + pid: Some(pid), + err: err.to_string(), + }; + return; + } + } + } + } + } + ButtonState::Simple { yes, .. } => { + if yes { + cfg_if! { + if #[cfg(target_os = "windows")] { + use crate::utils::process_killer; + + for pid in pids { + if let Err(err) = process_killer::kill_process_given_pid(pid) { + self.state = ProcessKillDialogState::Error { process_name, pid: Some(pid), err: err.to_string() }; + break; + } + } + } else if #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] { + use crate::utils::process_killer; + + for pid in pids { + // Send a SIGTERM by default. + if let Err(err) = process_killer::kill_process_given_pid(pid, DEFAULT_KILL_SIGNAL) { + self.state = ProcessKillDialogState::Error { process_name, pid: Some(pid), err: err.to_string() }; + break; + } + } + } else { + self.state = ProcessKillDialogState::Error { process_name, pid: None, err: "Killing processes is not supported on this platform.".into() }; + + } + } + } + } + } + } + + // Fall through behaviour is just to close the dialog. + self.last_char = None; + } + + pub fn on_char(&mut self, c: char) { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + const MAX_KEY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); + + match c { + 'h' => self.on_left_key(), + 'j' => self.on_down_key(), + 'k' => self.on_up_key(), + 'l' => self.on_right_key(), + '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + if let Some(value) = c.to_digit(10) { + if let ProcessKillDialogState::Selecting(ProcessKillSelectingInner { + button_state: ButtonState::Signals { state, .. }, + .. + }) = &mut self.state + { + if let Some((prev, last_press)) = self.last_char { + if prev.is_ascii_digit() && last_press.elapsed() <= MAX_KEY_TIMEOUT { + let current = state.selected().unwrap_or(0); + let new = { + let new = current * 10 + value as usize; + + // Note that 32 and 33 are skipped on linux. + if cfg!(target_os = "linux") { + if new == 32 || new == 33 { + value as usize + } else if new >= 34 { + new - 2 + } else { + new + } + } else { + new + } + }; + + if new >= SIGNAL_TEXT.len() { + // If the new value is too large, then just assume we instead want the value itself. + state.select(Some(value as usize)); + self.last_char = Some((c, Instant::now())); + } else { + state.select(Some(new)); + self.last_char = None; + } + } else { + state.select(Some(value as usize)); + self.last_char = Some((c, Instant::now())); + } + } else { + state.select(Some(value as usize)); + self.last_char = Some((c, Instant::now())); + } + + return; // Needed to avoid accidentally clearing last_char. + } + } + } + 'g' => { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + { + if let Some(('g', last_press)) = self.last_char { + if last_press.elapsed() <= MAX_KEY_TIMEOUT { + self.go_to_first(); + self.last_char = None; + } else { + self.last_char = Some(('g', Instant::now())); + } + } else { + self.last_char = Some(('g', Instant::now())); + } + return; + } + } + 'G' => { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + self.go_to_last(); + } + _ => {} + } + + self.last_char = None; + } + + /// Handle a click at the given coordinates. Returns true if the click was + /// handled, false otherwise. + pub fn on_click(&mut self, x: u16, y: u16) -> bool { + if let ProcessKillDialogState::Selecting(state) = &mut self.state { + match &mut state.button_state { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + ButtonState::Signals { + state, + last_button_draw_area, + } => { + if last_button_draw_area.contains(Position { x, y }) { + let relative_y = + y.saturating_sub(last_button_draw_area.y) as usize + state.offset(); + if relative_y < SIGNAL_TEXT.len() { + state.select(Some(relative_y)); + } + } + } + ButtonState::Simple { + yes, + last_yes_button_area, + last_no_button_area, + } => { + if last_yes_button_area.contains(Position { x, y }) { + *yes = true; + } else if last_no_button_area.contains(Position { x, y }) { + *yes = false; + } + } + } + } + + false + } + + /// Scroll up in the signal list. + pub fn on_scroll_up(&mut self) { + self.on_up_key(); + } + + /// Scroll down in the signal list. + pub fn on_scroll_down(&mut self) { + self.on_down_key(); + } + + /// Handle a left key press. + pub fn on_left_key(&mut self) { + self.last_char = None; + + if let ProcessKillDialogState::Selecting(ProcessKillSelectingInner { + button_state: ButtonState::Simple { yes, .. }, + .. + }) = &mut self.state + { + *yes = true; + } + } + + /// Handle a right key press. + pub fn on_right_key(&mut self) { + self.last_char = None; + + if let ProcessKillDialogState::Selecting(ProcessKillSelectingInner { + button_state: ButtonState::Simple { yes, .. }, + .. + }) = &mut self.state + { + *yes = false; + } + } + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + fn scroll_up_by(state: &mut ListState, amount: usize) { + if let Some(selected) = state.selected() { + if let Some(new_position) = selected.checked_sub(amount) { + state.select(Some(new_position)); + } else { + state.select(Some(0)); + } + } + } + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + fn scroll_down_by(state: &mut ListState, amount: usize) { + if let Some(selected) = state.selected() { + let new_position = selected + amount; + if new_position < SIGNAL_TEXT.len() { + state.select(Some(new_position)); + } else { + state.select(Some(SIGNAL_TEXT.len() - 1)); + } + } + } + + /// Handle an up key press. + pub fn on_up_key(&mut self) { + self.last_char = None; + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + if let ProcessKillDialogState::Selecting(ProcessKillSelectingInner { + button_state: ButtonState::Signals { state, .. }, + .. + }) = &mut self.state + { + Self::scroll_up_by(state, 1); + } + } + + /// Handle a down key press. + pub fn on_down_key(&mut self) { + self.last_char = None; + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + if let ProcessKillDialogState::Selecting(ProcessKillSelectingInner { + button_state: ButtonState::Signals { state, .. }, + .. + }) = &mut self.state + { + Self::scroll_down_by(state, 1); + } + } + + // Handle page up. + pub fn on_page_up(&mut self) { + self.last_char = None; + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + if let ProcessKillDialogState::Selecting(ProcessKillSelectingInner { + button_state: + ButtonState::Signals { + state, + last_button_draw_area, + .. + }, + .. + }) = &mut self.state + { + Self::scroll_up_by(state, last_button_draw_area.height as usize); + } + } + + /// Handle page down. + pub fn on_page_down(&mut self) { + self.last_char = None; + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + if let ProcessKillDialogState::Selecting(ProcessKillSelectingInner { + button_state: + ButtonState::Signals { + state, + last_button_draw_area, + .. + }, + .. + }) = &mut self.state + { + Self::scroll_down_by(state, last_button_draw_area.height as usize); + } + } + + pub fn go_to_first(&mut self) { + self.last_char = None; + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + if let ProcessKillDialogState::Selecting(ProcessKillSelectingInner { + button_state: ButtonState::Signals { state, .. }, + .. + }) = &mut self.state + { + state.select(Some(0)); + } + } + + pub fn go_to_last(&mut self) { + self.last_char = None; + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + if let ProcessKillDialogState::Selecting(ProcessKillSelectingInner { + button_state: ButtonState::Signals { state, .. }, + .. + }) = &mut self.state + { + state.select(Some(SIGNAL_TEXT.len() - 1)); + } + } + + /// Enable the process kill process. + pub fn start_process_kill( + &mut self, process_name: String, pids: Vec, use_simple_selection: bool, + ) { + let button_state = if use_simple_selection { + ButtonState::Simple { + yes: false, + last_yes_button_area: Rect::default(), + last_no_button_area: Rect::default(), + } + } else { + cfg_if! { + if #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] { + ButtonState::Signals { state: ListState::default().with_selected(Some(DEFAULT_KILL_SIGNAL)), last_button_draw_area: Rect::default() } + } else { + ButtonState::Simple { yes: false, last_yes_button_area: Rect::default(), last_no_button_area: Rect::default()} + } + } + }; + + if pids.is_empty() { + self.state = ProcessKillDialogState::Error { + process_name, + pid: None, + err: "No PIDs found for the given process name.".into(), + }; + return; + } + + self.state = ProcessKillDialogState::Selecting(ProcessKillSelectingInner { + process_name, + pids, + button_state, + }); + } + + pub fn handle_redraw(&mut self) { + // FIXME: Not sure if we need this. We can probably handle this better in the draw function later. + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + { + if let ProcessKillDialogState::Selecting(ProcessKillSelectingInner { + button_state: ButtonState::Signals { state, .. }, + .. + }) = &mut self.state + { + // Fix the button offset state when we do things like resize. + *state.offset_mut() = 0; + } + } + } + + #[inline] + fn draw_selecting( + f: &mut Frame<'_>, draw_area: Rect, styles: &Styles, state: &mut ProcessKillSelectingInner, + ) { + let ProcessKillSelectingInner { + process_name, + pids, + button_state, + .. + } = state; + + // FIXME: Add some colour to this! + let text = { + const MAX_PROCESS_NAME_WIDTH: usize = 20; + + if let Some(first_pid) = pids.first() { + let truncated_process_name = + unicode_ellipsis::truncate_str(process_name, MAX_PROCESS_NAME_WIDTH); + + let text = if pids.len() > 1 { + Line::from(format!( + "Kill {} processes with the name '{}'? Press ENTER to confirm.", + pids.len(), + truncated_process_name + )) + } else { + Line::from(format!( + "Kill process '{truncated_process_name}' with PID {first_pid}? Press ENTER to confirm." + )) + }; + + Text::from(vec![text]) + } else { + Text::from(vec![ + "Could not find process to kill.".into(), + "Please press ENTER or ESC to close this dialog.".into(), + ]) + } + }; + + let text: Paragraph<'_> = Paragraph::new(text) + .style(styles.text_style) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + let title = match button_state { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + ButtonState::Signals { .. } => { + Line::styled(" Select Signal ", styles.widget_title_style) + } + ButtonState::Simple { .. } => { + Line::styled(" Confirm Kill Process ", styles.widget_title_style) + } + }; + + let block = dialog_block(styles.border_type) + .title_top(title) + .title_top(Line::styled(" Esc to close ", styles.widget_title_style).right_aligned()) + .style(styles.border_style) + .border_style(styles.border_style); + + let num_lines = text.line_count(block.inner(draw_area).width) as u16; + + match button_state { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + ButtonState::Signals { + state, + last_button_draw_area, + } => { + use tui::widgets::List; + + // A list of options, displayed vertically. + const SIGNAL_TEXT_LEN: u16 = SIGNAL_TEXT.len() as u16; + + // Make the rect only as big as it needs to be, which is the height of the text, + // the buttons, and up to 2 spaces (margin and space between), and the size of the block. + let [draw_area] = + Layout::vertical([Constraint::Max(num_lines + SIGNAL_TEXT_LEN + 2 + 3)]) + .flex(Flex::Center) + .areas(draw_area); + + // Now we need to divide the block into one area for the paragraph, + // and one for the buttons. + let [text_draw_area, button_draw_area] = Layout::vertical([ + Constraint::Max(num_lines), + Constraint::Max(SIGNAL_TEXT_LEN), + ]) + .flex(Flex::SpaceAround) + .areas(block.inner(draw_area)); + + // Render the block. + f.render_widget(block, draw_area); + + // Now render the text. + f.render_widget(text, text_draw_area); + + // And the tricky part, rendering the buttons. + let selected = state + .selected() + .expect("the list state should always be initialized with a selection!"); + + let buttons = List::new(SIGNAL_TEXT.iter().enumerate().map(|(index, &signal)| { + let style = if index == selected { + styles.selected_text_style + } else { + styles.text_style + }; + + Span::styled(signal, style) + })); + + // This is kinda dumb how you have to set the constraint, but ok. + const LONGEST_SIGNAL_TEXT_LENGTH: u16 = const { + let mut i = 0; + let mut max = 0; + while i < SIGNAL_TEXT.len() { + if SIGNAL_TEXT[i].len() > max { + max = SIGNAL_TEXT[i].len(); + } + i += 1; + } + + max as u16 + }; + let [button_draw_area] = + Layout::horizontal([Constraint::Length(LONGEST_SIGNAL_TEXT_LENGTH)]) + .flex(Flex::Center) + .areas(button_draw_area); + + *last_button_draw_area = button_draw_area; + f.render_stateful_widget(buttons, button_draw_area, state); + } + ButtonState::Simple { + yes, + last_yes_button_area, + last_no_button_area, + } => { + // Make the rect only as big as it needs to be, which is the height of the text, + // the buttons, and up to 3 spaces (margin and space between) + 2 for block. + let [draw_area] = Layout::vertical([Constraint::Max(num_lines + 1 + 3 + 2)]) + .flex(Flex::Center) + .areas(draw_area); + + // Now we need to divide the block into one area for the paragraph, + // and one for the buttons. + let [text_area, button_area] = + Layout::vertical([Constraint::Max(num_lines), Constraint::Length(1)]) + .flex(Flex::SpaceAround) + .areas(block.inner(draw_area)); + + // Render things, starting from the block. + f.render_widget(block, draw_area); + f.render_widget(text, text_area); + + let (yes, no) = { + let (yes_style, no_style) = if *yes { + (styles.selected_text_style, styles.text_style) + } else { + (styles.text_style, styles.selected_text_style) + }; + + ( + Paragraph::new(Span::styled("Yes", yes_style)), + Paragraph::new(Span::styled("No", no_style)), + ) + }; + + let [yes_area, no_area] = Layout::horizontal([Constraint::Length(3); 2]) + .flex(Flex::SpaceAround) + .areas(button_area); + + *last_yes_button_area = yes_area; + *last_no_button_area = no_area; + + f.render_widget(yes, yes_area); + f.render_widget(no, no_area); + } + } + } + + #[inline] + fn draw_no_button_dialog( + &self, f: &mut Frame<'_>, draw_area: Rect, styles: &Styles, text: Text<'_>, title: Line<'_>, + ) { + let text = Paragraph::new(text) + .style(styles.text_style) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + let block = dialog_block(styles.border_type) + .title_top(title) + .title_top(Line::styled(" Esc to close ", styles.widget_title_style).right_aligned()) + .style(styles.border_style) + .border_style(styles.border_style); + + let num_lines = text.line_count(block.inner(draw_area).width) as u16; + + // Also calculate how big of a draw loc we actually need. For this + // one, we want it to be shorter if possible. + // + // Note the +2 is for the margin, and another +2 for border. + let [draw_area] = Layout::vertical([Constraint::Max(num_lines + 2 + 2)]) + .flex(Flex::Center) + .areas(draw_area); + + let [text_draw_area] = Layout::vertical([Constraint::Length(num_lines)]) + .flex(Flex::Center) + .areas(block.inner(draw_area)); + + f.render_widget(block, draw_area); + f.render_widget(text, text_draw_area); + } + + /// Draw the [`ProcessKillDialog`]. + pub fn draw(&mut self, f: &mut Frame<'_>, draw_area: Rect, styles: &Styles) { + // The idea is: + // - Use as big of a dialog box as needed (within the maximal draw loc) + // - So the non-button ones are going to be smaller... probably + // whatever the height of the text is. + // - Meanwhile for the button one, it'll likely be full height if it's + // "advanced" kill. + + const MAX_DIALOG_WIDTH: u16 = 100; + let [draw_area] = Layout::horizontal([Constraint::Max(MAX_DIALOG_WIDTH)]) + .flex(Flex::Center) + .areas(draw_area); + + // FIXME: Add some colour to this! + match &mut self.state { + ProcessKillDialogState::NotEnabled => {} + ProcessKillDialogState::Selecting(state) => { + // Draw a text box. If buttons are yes/no, fit it, otherwise, use max space. + Self::draw_selecting(f, draw_area, styles, state); + } + ProcessKillDialogState::Error { + process_name, + pid, + err, + } => { + let text = Text::from(vec![ + if let Some(pid) = pid { + format!("Failed to kill process {process_name} ({pid}):").into() + } else { + format!("Failed to kill process '{process_name}':").into() + }, + err.to_owned().into(), + "Please press ENTER or ESC to close this dialog.".into(), + ]) + .alignment(Alignment::Center); + let title = Line::styled(" Error ", styles.widget_title_style); + + self.draw_no_button_dialog(f, draw_area, styles, text, title); + } + } + } +} diff --git a/src/canvas/drawing_utils.rs b/src/canvas/drawing_utils.rs index 8a6589294..ff7ccd235 100644 --- a/src/canvas/drawing_utils.rs +++ b/src/canvas/drawing_utils.rs @@ -1,20 +1,18 @@ -use std::{cmp::min, time::Instant}; +use std::time::Instant; -use tui::layout::Rect; +use tui::{ + layout::Rect, + widgets::{Block, BorderType, Borders}, +}; -/// Calculate how many bars are to be drawn within basic mode's components. -pub fn calculate_basic_use_bars(use_percentage: f64, num_bars_available: usize) -> usize { - min( - (num_bars_available as f64 * use_percentage / 100.0).round() as usize, - num_bars_available, - ) -} +pub const SIDE_BORDERS: Borders = Borders::LEFT.union(Borders::RIGHT); +pub const AUTOHIDE_TIMEOUT_MILLISECONDS: u64 = 5000; // 5 seconds to autohide /// Determine whether a graph x-label should be hidden. pub fn should_hide_x_label( always_hide_time: bool, autohide_time: bool, timer: &mut Option, draw_loc: Rect, ) -> bool { - use crate::constants::*; + const TIME_LABEL_HEIGHT_LIMIT: u16 = 7; if always_hide_time || (autohide_time && timer.is_none()) { true @@ -30,33 +28,41 @@ pub fn should_hide_x_label( } } +/// Return a widget block. +pub fn widget_block(is_basic: bool, is_selected: bool, border_type: BorderType) -> Block<'static> { + let mut block = Block::default().border_type(border_type); + + if is_basic { + if is_selected { + block = block.borders(SIDE_BORDERS); + } else { + block = block.borders(Borders::empty()); + } + } else { + block = block.borders(Borders::all()); + } + + block +} + +/// Return a dialog block. +pub fn dialog_block(border_type: BorderType) -> Block<'static> { + Block::default() + .border_type(border_type) + .borders(Borders::all()) +} + #[cfg(test)] mod test { use super::*; - #[test] - fn test_calculate_basic_use_bars() { - // Testing various breakpoints and edge cases. - assert_eq!(calculate_basic_use_bars(0.0, 15), 0); - assert_eq!(calculate_basic_use_bars(1.0, 15), 0); - assert_eq!(calculate_basic_use_bars(5.0, 15), 1); - assert_eq!(calculate_basic_use_bars(10.0, 15), 2); - assert_eq!(calculate_basic_use_bars(40.0, 15), 6); - assert_eq!(calculate_basic_use_bars(45.0, 15), 7); - assert_eq!(calculate_basic_use_bars(50.0, 15), 8); - assert_eq!(calculate_basic_use_bars(100.0, 15), 15); - assert_eq!(calculate_basic_use_bars(150.0, 15), 15); - } - #[test] fn test_should_hide_x_label() { use std::time::{Duration, Instant}; use tui::layout::Rect; - use crate::constants::*; - let rect = Rect::new(0, 0, 10, 10); let small_rect = Rect::new(0, 0, 10, 6); @@ -84,4 +90,14 @@ mod test { )); assert!(over_timer.is_none()); } + + /// This test exists because previously, [`SIDE_BORDERS`] was set + /// incorrectly after I moved from tui-rs to ratatui. + #[test] + fn assert_side_border_bits_match() { + assert_eq!( + SIDE_BORDERS, + Borders::ALL.difference(Borders::TOP.union(Borders::BOTTOM)) + ) + } } diff --git a/src/canvas/widgets/battery_display.rs b/src/canvas/widgets/battery_display.rs index 3bfc8fb14..8cfa0f52a 100644 --- a/src/canvas/widgets/battery_display.rs +++ b/src/canvas/widgets/battery_display.rs @@ -1,23 +1,31 @@ +use std::cmp::min; + use tui::{ + Frame, layout::{Constraint, Direction, Layout, Rect}, - terminal::Frame, text::{Line, Span}, - widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs}, + widgets::{Cell, Paragraph, Row, Table, Tabs}, }; -use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::{ app::App, - canvas::{drawing_utils::calculate_basic_use_bars, Painter}, + canvas::{Painter, drawing_utils::widget_block}, + collection::batteries::BatteryState, constants::*, - data_conversion::BatteryDuration, }; +/// Calculate how many bars are to be drawn within basic mode's components. +fn calculate_basic_use_bars(use_percentage: f64, num_bars_available: usize) -> usize { + min( + (num_bars_available as f64 * use_percentage / 100.0).round() as usize, + num_bars_available, + ) +} + impl Painter { pub fn draw_battery( - &self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, draw_border: bool, - widget_id: u64, + &self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, widget_id: u64, ) { let should_get_widget_bounds = app_state.should_get_widget_bounds(); if let Some(battery_widget_state) = app_state @@ -26,11 +34,11 @@ impl Painter { .widget_states .get_mut(&widget_id) { - let is_on_widget = widget_id == app_state.current_widget.widget_id; - let border_style = if is_on_widget { - self.colours.highlighted_border_style + let is_selected = widget_id == app_state.current_widget.widget_id; + let border_style = if is_selected { + self.styles.highlighted_border_style } else { - self.colours.border_style + self.styles.border_style }; let table_gap = if draw_loc.height < TABLE_GAP_HEIGHT_LIMIT { 0 @@ -38,41 +46,28 @@ impl Painter { app_state.app_config_fields.table_gap }; - let title = if app_state.is_expanded { - const TITLE_BASE: &str = " Battery ── Esc to go back "; - Line::from(vec![ - Span::styled(" Battery ", self.colours.widget_title_style), - Span::styled( - format!( - "─{}─ Esc to go back ", - "─".repeat(usize::from(draw_loc.width).saturating_sub( - UnicodeSegmentation::graphemes(TITLE_BASE, true).count() + 2 - )) - ), - border_style, - ), - ]) - } else { - Line::from(Span::styled(" Battery ", self.colours.widget_title_style)) - }; + let block = { + let mut block = widget_block( + app_state.app_config_fields.use_basic_mode, + is_selected, + self.styles.border_type, + ) + .border_style(border_style) + .title_top(Line::styled(" Battery ", self.styles.widget_title_style)); + + if app_state.is_expanded { + block = block.title_top( + Line::styled(" Esc to go back ", self.styles.widget_title_style) + .right_aligned(), + ) + } - let battery_block = if draw_border { - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style) - } else if is_on_widget { - Block::default() - .borders(SIDE_BORDERS) - .border_style(self.colours.highlighted_border_style) - } else { - Block::default().borders(Borders::NONE) + block }; - if app_state.converted_data.battery_data.len() > 1 { - let battery_names = app_state - .converted_data - .battery_data + let battery_harvest = &(app_state.data_store.get_data().battery_harvest); + if battery_harvest.len() > 1 { + let battery_names = battery_harvest .iter() .enumerate() .map(|(itx, _)| format!("Battery {itx}")) @@ -95,8 +90,8 @@ impl Painter { .collect::>(), ) .divider(tui::symbols::line::VERTICAL) - .style(self.colours.text_style) - .highlight_style(self.colours.selected_text_style) + .style(self.styles.text_style) + .highlight_style(self.styles.selected_text_style) .select(battery_widget_state.currently_selected_battery_index), tab_draw_loc, ); @@ -120,88 +115,91 @@ impl Painter { } } + let is_basic = app_state.app_config_fields.use_basic_mode; + let margined_draw_loc = Layout::default() .constraints([Constraint::Percentage(100)]) - .horizontal_margin(u16::from(!(is_on_widget || draw_border))) + .horizontal_margin(u16::from(is_basic && !is_selected)) .direction(Direction::Horizontal) .split(draw_loc)[0]; - if let Some(battery_details) = app_state - .converted_data - .battery_data - .get(battery_widget_state.currently_selected_battery_index) + if let Some(battery_details) = + battery_harvest.get(battery_widget_state.currently_selected_battery_index) { let full_width = draw_loc.width.saturating_sub(2); let bar_length = usize::from(full_width.saturating_sub(6)); - let charge_percentage = battery_details.charge_percentage; - let num_bars = calculate_basic_use_bars(charge_percentage, bar_length); + let charge_percent = battery_details.charge_percent; + + let num_bars = calculate_basic_use_bars(charge_percent, bar_length); let bars = format!( "[{}{}{:3.0}%]", "|".repeat(num_bars), " ".repeat(bar_length - num_bars), - charge_percentage, + charge_percent, ); let mut battery_charge_rows = Vec::with_capacity(2); battery_charge_rows.push(Row::new([ - Cell::from("Charge").style(self.colours.text_style) + Cell::from("Charge").style(self.styles.text_style) ])); battery_charge_rows.push(Row::new([Cell::from(bars).style( - if charge_percentage < 10.0 { - self.colours.low_battery - } else if charge_percentage < 50.0 { - self.colours.medium_battery + if charge_percent < 10.0 { + self.styles.low_battery + } else if charge_percent < 50.0 { + self.styles.medium_battery } else { - self.colours.high_battery + self.styles.high_battery }, )])); let mut battery_rows = Vec::with_capacity(3); + let watt_consumption = battery_details.watt_consumption(); + let health = battery_details.health(); + battery_rows.push(Row::new([""]).bottom_margin(table_gap + 1)); - battery_rows.push( - Row::new(["Rate", &battery_details.watt_consumption]) - .style(self.colours.text_style), - ); + battery_rows + .push(Row::new(["Rate", &watt_consumption]).style(self.styles.text_style)); battery_rows.push( - Row::new(["State", &battery_details.state]).style(self.colours.text_style), + Row::new(["State", battery_details.state.as_str()]) + .style(self.styles.text_style), ); let mut time: String; // Keep string lifetime in scope. { - let style = self.colours.text_style; - match &battery_details.battery_duration { - BatteryDuration::ToEmpty(secs) => { + let style = self.styles.text_style; + match &battery_details.state { + BatteryState::Charging { + time_to_full: Some(secs), + } => { time = long_time(*secs); if full_width as usize > time.len() { - battery_rows.push(Row::new(["Time to empty", &time]).style(style)); + battery_rows.push(Row::new(["Time to full", &time]).style(style)); } else { time = short_time(*secs); - battery_rows.push(Row::new(["To empty", &time]).style(style)); + battery_rows.push(Row::new(["To full", &time]).style(style)); } } - BatteryDuration::ToFull(secs) => { + BatteryState::Discharging { + time_to_empty: Some(secs), + } => { time = long_time(*secs); if full_width as usize > time.len() { - battery_rows.push(Row::new(["Time to full", &time]).style(style)); + battery_rows.push(Row::new(["Time to empty", &time]).style(style)); } else { time = short_time(*secs); - battery_rows.push(Row::new(["To full", &time]).style(style)); + battery_rows.push(Row::new(["To empty", &time]).style(style)); } } - BatteryDuration::Empty - | BatteryDuration::Full - | BatteryDuration::Unknown => {} + _ => {} } } - battery_rows.push( - Row::new(["Health", &battery_details.health]).style(self.colours.text_style), - ); + battery_rows.push(Row::new(["Health", &health]).style(self.styles.text_style)); - let header = if app_state.converted_data.battery_data.len() > 1 { + let header = if battery_harvest.len() > 1 { Row::new([""]).bottom_margin(table_gap) } else { Row::default() @@ -210,7 +208,7 @@ impl Painter { // Draw bar f.render_widget( Table::new(battery_charge_rows, [Constraint::Percentage(100)]) - .block(battery_block.clone()) + .block(block.clone()) .header(header.clone()), margined_draw_loc, ); @@ -221,7 +219,7 @@ impl Painter { battery_rows, [Constraint::Percentage(50), Constraint::Percentage(50)], ) - .block(battery_block) + .block(block) .header(header), margined_draw_loc, ); @@ -230,13 +228,10 @@ impl Painter { contents.push(Line::from(Span::styled( "No data found for this battery", - self.colours.text_style, + self.styles.text_style, ))); - f.render_widget( - Paragraph::new(contents).block(battery_block), - margined_draw_loc, - ); + f.render_widget(Paragraph::new(contents).block(block), margined_draw_loc); } if should_get_widget_bounds { @@ -253,8 +248,7 @@ impl Painter { } } -#[inline] -fn get_hms(secs: i64) -> (i64, i64, i64) { +fn get_hms(secs: u32) -> (u32, u32, u32) { let hours = secs / (60 * 60); let minutes = (secs / 60) - hours * 60; let seconds = secs - minutes * 60 - hours * 60 * 60; @@ -262,31 +256,24 @@ fn get_hms(secs: i64) -> (i64, i64, i64) { (hours, minutes, seconds) } -fn long_time(secs: i64) -> String { +fn long_time(secs: u32) -> String { let (hours, minutes, seconds) = get_hms(secs); if hours > 0 { - format!( - "{} hour{}, {} minute{}, {} second{}", - hours, - if hours == 1 { "" } else { "s" }, - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" }, - ) + let h = if hours == 1 { "hour" } else { "hours" }; + let m = if minutes == 1 { "minute" } else { "minutes" }; + let s = if seconds == 1 { "second" } else { "seconds" }; + + format!("{hours} {h}, {minutes} {m}, {seconds} {s}") } else { - format!( - "{} minute{}, {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" }, - ) + let m = if minutes == 1 { "minute" } else { "minutes" }; + let s = if seconds == 1 { "second" } else { "seconds" }; + + format!("{minutes} {m}, {seconds} {s}") } } -fn short_time(secs: i64) -> String { +fn short_time(secs: u32) -> String { let (hours, minutes, seconds) = get_hms(secs); if hours > 0 { @@ -331,4 +318,18 @@ mod tests { assert_eq!(short_time(3601), "1h 0m 1s".to_string()); assert_eq!(short_time(3661), "1h 1m 1s".to_string()); } + + #[test] + fn test_calculate_basic_use_bars() { + // Testing various breakpoints and edge cases. + assert_eq!(calculate_basic_use_bars(0.0, 15), 0); + assert_eq!(calculate_basic_use_bars(1.0, 15), 0); + assert_eq!(calculate_basic_use_bars(5.0, 15), 1); + assert_eq!(calculate_basic_use_bars(10.0, 15), 2); + assert_eq!(calculate_basic_use_bars(40.0, 15), 6); + assert_eq!(calculate_basic_use_bars(45.0, 15), 7); + assert_eq!(calculate_basic_use_bars(50.0, 15), 8); + assert_eq!(calculate_basic_use_bars(100.0, 15), 15); + assert_eq!(calculate_basic_use_bars(150.0, 15), 15); + } } diff --git a/src/canvas/widgets/cpu_basic.rs b/src/canvas/widgets/cpu_basic.rs index b96520178..7fedca943 100644 --- a/src/canvas/widgets/cpu_basic.rs +++ b/src/canvas/widgets/cpu_basic.rs @@ -1,20 +1,19 @@ use std::cmp::min; +use itertools::{Either, Itertools}; use tui::{ + Frame, layout::{Constraint, Direction, Layout, Rect}, - terminal::Frame, - widgets::Block, }; use crate::{ app::App, canvas::{ - components::pipe_gauge::{LabelLimit, PipeGauge}, Painter, + components::pipe_gauge::{LabelLimit, PipeGauge}, + drawing_utils::widget_block, }, - constants::*, - data_collection::cpu::CpuDataType, - data_conversion::CpuWidgetData, + collection::cpu::{CpuData, CpuDataType}, }; impl Painter { @@ -22,34 +21,34 @@ impl Painter { pub fn draw_basic_cpu( &self, f: &mut Frame<'_>, app_state: &mut App, mut draw_loc: Rect, widget_id: u64, ) { - // Skip the first element, it's the "all" element - if app_state.converted_data.cpu_data.len() > 1 { - let cpu_data: &[CpuWidgetData] = &app_state.converted_data.cpu_data[1..]; - - // This is a bit complicated, but basically, we want to draw SOME number - // of columns to draw all CPUs. Ideally, as well, we want to not have - // to ever scroll. - // **General logic** - count number of elements in cpu_data. Then see how - // many rows and columns we have in draw_loc (-2 on both sides for border?). - // I think what we can do is try to fit in as many in one column as possible. - // If not, then add a new column. - // Then, from this, split the row space across ALL columns. From there, - // generate the desired lengths. - - if app_state.current_widget.widget_id == widget_id { - f.render_widget( - Block::default() - .borders(SIDE_BORDERS) - .border_style(self.colours.highlighted_border_style), - draw_loc, - ); - } - - let (cpu_data, avg_data) = - maybe_split_avg(cpu_data, app_state.app_config_fields.dedicated_average_row); + let cpu_data = &app_state.data_store.get_data().cpu_harvest; + + // This is a bit complicated, but basically, we want to draw SOME number + // of columns to draw all CPUs. Ideally, as well, we want to not have + // to ever scroll. + // + // **General logic** - count number of elements in cpu_data. Then see how + // many rows and columns we have in draw_loc (-2 on both sides for border?). + // I think what we can do is try to fit in as many in one column as possible. + // If not, then add a new column. Then, from this, split the row space across ALL columns. + // From there, generate the desired lengths. + + if app_state.current_widget.widget_id == widget_id { + f.render_widget( + widget_block(true, true, self.styles.border_type) + .border_style(self.styles.highlighted_border_style), + draw_loc, + ); + } - if let Some(avg) = avg_data { - let (outer, inner, ratio, style) = self.cpu_info(&avg); + // TODO: This is pretty ugly. Is there a better way of doing it? + let mut cpu_iter = Either::Right(cpu_data.iter()); + if app_state.app_config_fields.dedicated_average_row { + if let Some((index, avg)) = cpu_data + .iter() + .find_position(|&datum| matches!(datum.data_type, CpuDataType::Avg)) + { + let (outer, inner, ratio, style) = self.cpu_info(avg); let [cores_loc, mut avg_loc] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(draw_loc); @@ -64,72 +63,71 @@ impl Painter { .label_style(style) .inner_label(inner) .start_label(outer) - .ratio(ratio), + .ratio(ratio.into()), avg_loc, ); draw_loc = cores_loc; + cpu_iter = Either::Left(cpu_data.iter().skip(index)); } + } - if draw_loc.height > 0 { - let remaining_height = usize::from(draw_loc.height); - const REQUIRED_COLUMNS: usize = 4; - - let col_constraints = - vec![Constraint::Percentage((100 / REQUIRED_COLUMNS) as u16); REQUIRED_COLUMNS]; - let columns = Layout::default() - .constraints(col_constraints) - .direction(Direction::Horizontal) - .split(draw_loc); - - let mut gauge_info = cpu_data.iter().map(|cpu| self.cpu_info(cpu)); - - // Very ugly way to sync the gauge limit across all gauges. - let hide_parts = columns - .first() - .map(|col| { - if col.width >= 12 { - LabelLimit::None - } else if col.width >= 10 { - LabelLimit::Bars - } else { - LabelLimit::StartLabel - } - }) - .unwrap_or_default(); - - let num_entries = cpu_data.len(); - let mut row_counter = num_entries; - for (itx, column) in columns.iter().enumerate() { - if REQUIRED_COLUMNS > itx { - let to_divide = REQUIRED_COLUMNS - itx; - let num_taken = min( - remaining_height, - (row_counter / to_divide) + usize::from(row_counter % to_divide != 0), + if draw_loc.height > 0 { + let remaining_height = usize::from(draw_loc.height); + const REQUIRED_COLUMNS: usize = 4; + + let col_constraints = + vec![Constraint::Percentage((100 / REQUIRED_COLUMNS) as u16); REQUIRED_COLUMNS]; + let columns = Layout::default() + .constraints(col_constraints) + .direction(Direction::Horizontal) + .split(draw_loc); + + let mut gauge_info = cpu_iter.map(|cpu| self.cpu_info(cpu)); + + // Very ugly way to sync the gauge limit across all gauges. + let hide_parts = columns + .first() + .map(|col| { + if col.width >= 12 { + LabelLimit::None + } else if col.width >= 10 { + LabelLimit::Bars + } else { + LabelLimit::StartLabel + } + }) + .unwrap_or_default(); + + let num_entries = cpu_data.len(); + let mut row_counter = num_entries; + for (itx, column) in columns.iter().enumerate() { + if REQUIRED_COLUMNS > itx { + let to_divide = REQUIRED_COLUMNS - itx; + let num_taken = min( + remaining_height, + (row_counter / to_divide) + usize::from(row_counter % to_divide != 0), + ); + row_counter -= num_taken; + let chunk = (&mut gauge_info).take(num_taken); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Length(1); remaining_height]) + .horizontal_margin(1) + .split(*column); + + for ((start_label, inner_label, ratio, style), row) in chunk.zip(rows.iter()) { + f.render_widget( + PipeGauge::default() + .gauge_style(style) + .label_style(style) + .inner_label(inner_label) + .start_label(start_label) + .ratio(ratio.into()) + .hide_parts(hide_parts), + *row, ); - row_counter -= num_taken; - let chunk = (&mut gauge_info).take(num_taken); - - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints(vec![Constraint::Length(1); remaining_height]) - .horizontal_margin(1) - .split(*column); - - for ((start_label, inner_label, ratio, style), row) in - chunk.zip(rows.iter()) - { - f.render_widget( - PipeGauge::default() - .gauge_style(style) - .label_style(style) - .inner_label(inner_label) - .start_label(start_label) - .ratio(ratio) - .hide_parts(hide_parts), - *row, - ); - } } } } @@ -145,63 +143,19 @@ impl Painter { } } - fn cpu_info(&self, cpu: &CpuWidgetData) -> (String, String, f64, tui::style::Style) { - let CpuWidgetData::Entry { - data_type, - last_entry, - .. - } = cpu - else { - unreachable!() - }; - - let (outer, style) = match data_type { - CpuDataType::Avg => ("AVG".to_string(), self.colours.avg_cpu_colour), + #[inline] + fn cpu_info(&self, data: &CpuData) -> (String, String, f32, tui::style::Style) { + let (outer, style) = match data.data_type { + CpuDataType::Avg => ("AVG".to_string(), self.styles.avg_cpu_colour), CpuDataType::Cpu(index) => ( format!("{index:<3}",), - self.colours.cpu_colour_styles[index % self.colours.cpu_colour_styles.len()], + self.styles.cpu_colour_styles[index % self.styles.cpu_colour_styles.len()], ), }; - let inner = format!("{:>3.0}%", last_entry.round()); - let ratio = last_entry / 100.0; - (outer, inner, ratio, style) - } -} - -fn maybe_split_avg( - data: &[CpuWidgetData], separate_avg: bool, -) -> (Vec, Option) { - let mut cpu_data = vec![]; - let mut avg_data = None; - - for cpu in data { - let CpuWidgetData::Entry { - data_type, - data, - last_entry, - } = cpu - else { - unreachable!() - }; + let inner = format!("{:>3.0}%", data.usage.round()); + let ratio = data.usage / 100.0; - match data_type { - CpuDataType::Avg if separate_avg => { - avg_data = Some(CpuWidgetData::Entry { - data_type: *data_type, - data: data.clone(), - last_entry: *last_entry, - }); - } - _ => { - cpu_data.push(CpuWidgetData::Entry { - data_type: *data_type, - data: data.clone(), - last_entry: *last_entry, - }); - } - } + (outer, inner, ratio, style) } - - (cpu_data, avg_data) } diff --git a/src/canvas/widgets/cpu_graph.rs b/src/canvas/widgets/cpu_graph.rs index a4a62f651..86c92d3ad 100644 --- a/src/canvas/widgets/cpu_graph.rs +++ b/src/canvas/widgets/cpu_graph.rs @@ -1,22 +1,19 @@ -use std::borrow::Cow; - use tui::{ + Frame, layout::{Constraint, Direction, Layout, Rect}, - symbols::Marker, - terminal::Frame, }; use crate::{ - app::{layout_manager::WidgetDirection, App}, + app::{App, data::StoredData, layout_manager::WidgetDirection}, canvas::{ + Painter, components::{ data_table::{DrawInfo, SelectionState}, - time_graph::{GraphData, TimeGraph}, + time_graph::{GraphData, variants::percent::PercentTimeGraph}, }, drawing_utils::should_hide_x_label, - Painter, }, - data_conversion::CpuWidgetData, + collection::cpu::CpuData, widgets::CpuWidgetState, }; @@ -120,56 +117,50 @@ impl Painter { } fn generate_points<'a>( - &self, cpu_widget_state: &CpuWidgetState, cpu_data: &'a [CpuWidgetData], show_avg_cpu: bool, + &self, cpu_widget_state: &'a CpuWidgetState, data: &'a StoredData, show_avg_cpu: bool, ) -> Vec> { let show_avg_offset = if show_avg_cpu { AVG_POSITION } else { 0 }; - let current_scroll_position = cpu_widget_state.table.state.current_index; + let cpu_entries = &data.cpu_harvest; + let cpu_points = &data.timeseries_data.cpu; + let time = &data.timeseries_data.time; + if current_scroll_position == ALL_POSITION { // This case ensures the other cases cannot have the position be equal to 0. - cpu_data + + cpu_points .iter() .enumerate() - .rev() - .filter_map(|(itx, cpu)| { - match &cpu { - CpuWidgetData::All => None, - CpuWidgetData::Entry { data, .. } => { - let style = if show_avg_cpu && itx == AVG_POSITION { - self.colours.avg_cpu_colour - } else if itx == ALL_POSITION { - self.colours.all_cpu_colour - } else { - let offset_position = itx - 1; // Because of the all position - self.colours.cpu_colour_styles[(offset_position - show_avg_offset) - % self.colours.cpu_colour_styles.len()] - }; - - Some(GraphData { - points: &data[..], - style, - name: None, - }) - } - } + .map(|(itx, values)| { + let style = if show_avg_cpu && itx == AVG_POSITION { + self.styles.avg_cpu_colour + } else if itx == ALL_POSITION { + self.styles.all_cpu_colour + } else { + self.styles.cpu_colour_styles + [(itx - show_avg_offset) % self.styles.cpu_colour_styles.len()] + }; + + GraphData::default().style(style).time(time).values(values) }) - .collect::>() - } else if let Some(CpuWidgetData::Entry { data, .. }) = - cpu_data.get(current_scroll_position) - { + .collect() + } else if let Some(CpuData { .. }) = cpu_entries.get(current_scroll_position - 1) { + // We generally subtract one from current scroll position because of the all entry. + let style = if show_avg_cpu && current_scroll_position == AVG_POSITION { - self.colours.avg_cpu_colour + self.styles.avg_cpu_colour } else { - let offset_position = current_scroll_position - 1; // Because of the all position - self.colours.cpu_colour_styles - [(offset_position - show_avg_offset) % self.colours.cpu_colour_styles.len()] + let offset_position = current_scroll_position - 1; + self.styles.cpu_colour_styles + [(offset_position - show_avg_offset) % self.styles.cpu_colour_styles.len()] }; - vec![GraphData { - points: &data[..], - style, - name: None, - }] + vec![ + GraphData::default() + .style(style) + .time(time) + .values(&cpu_points[current_scroll_position - 1]), + ] } else { vec![] } @@ -178,14 +169,10 @@ impl Painter { fn draw_cpu_graph( &self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, widget_id: u64, ) { - const Y_BOUNDS: [f64; 2] = [0.0, 100.5]; - const Y_LABELS: [Cow<'static, str>; 2] = [Cow::Borrowed(" 0%"), Cow::Borrowed("100%")]; - if let Some(cpu_widget_state) = app_state.states.cpu_state.widget_states.get_mut(&widget_id) { - let cpu_data = &app_state.converted_data.cpu_data; - let border_style = self.get_border_style(widget_id, app_state.current_widget.widget_id); - let x_bounds = [0, cpu_widget_state.current_display_time]; + let data = app_state.data_store.get_data(); + let hide_x_labels = should_hide_x_label( app_state.app_config_fields.hide_time, app_state.app_config_fields.autohide_time, @@ -193,9 +180,9 @@ impl Painter { draw_loc, ); - let points = self.generate_points( + let graph_data = self.generate_points( cpu_widget_state, - cpu_data, + data, app_state.app_config_fields.show_average_cpu, ); @@ -203,7 +190,7 @@ impl Painter { let title = { #[cfg(target_family = "unix")] { - let load_avg = app_state.converted_data.load_avg_data; + let load_avg = &data.load_avg_harvest; let load_avg_str = format!( "─ {:.2} {:.2} {:.2} ", load_avg[0], load_avg[1], load_avg[2] @@ -217,27 +204,20 @@ impl Painter { } }; - let marker = if app_state.app_config_fields.use_dot { - Marker::Dot - } else { - Marker::Braille - }; - - TimeGraph { - x_bounds, + PercentTimeGraph { + display_range: cpu_widget_state.current_display_time, hide_x_labels, - y_bounds: Y_BOUNDS, - y_labels: &Y_LABELS, - graph_style: self.colours.graph_style, - border_style, - title, + app_config_fields: &app_state.app_config_fields, + current_widget: app_state.current_widget.widget_id, is_expanded: app_state.is_expanded, - title_style: self.colours.widget_title_style, + title, + styles: &self.styles, + widget_id, legend_position: None, legend_constraints: None, - marker, } - .draw_time_graph(f, draw_loc, &points); + .build() + .draw(f, draw_loc, graph_data); } } diff --git a/src/canvas/widgets/disk_table.rs b/src/canvas/widgets/disk_table.rs index 04a626f8f..dbc16dfcc 100644 --- a/src/canvas/widgets/disk_table.rs +++ b/src/canvas/widgets/disk_table.rs @@ -1,10 +1,10 @@ -use tui::{layout::Rect, terminal::Frame}; +use tui::{Frame, layout::Rect}; use crate::{ app, canvas::{ - components::data_table::{DrawInfo, SelectionState}, Painter, + components::data_table::{DrawInfo, SelectionState}, }, }; diff --git a/src/canvas/widgets/mem_basic.rs b/src/canvas/widgets/mem_basic.rs index f3eccc191..a2f66d44d 100644 --- a/src/canvas/widgets/mem_basic.rs +++ b/src/canvas/widgets/mem_basic.rs @@ -1,176 +1,164 @@ +use std::borrow::Cow; + use tui::{ + Frame, layout::{Constraint, Direction, Layout, Rect}, - terminal::Frame, - widgets::Block, }; use crate::{ app::App, - canvas::{components::pipe_gauge::PipeGauge, Painter}, - constants::*, + canvas::{Painter, components::pipe_gauge::PipeGauge, drawing_utils::widget_block}, + collection::memory::MemData, + get_binary_unit_and_denominator, }; +/// Convert memory info into a string representing a fraction. +#[inline] +fn memory_fraction_label(data: &MemData) -> Cow<'static, str> { + let total_bytes = data.total_bytes.get(); + let (unit, denominator) = get_binary_unit_and_denominator(total_bytes); + let used = data.used_bytes as f64 / denominator; + let total = total_bytes as f64 / denominator; + + format!("{used:.1}{unit}/{total:.1}{unit}").into() +} + +/// Convert memory info into a string representing a percentage. +#[inline] +fn memory_percentage_label(data: &MemData) -> Cow<'static, str> { + let total_bytes = data.total_bytes.get(); + let percentage = data.used_bytes as f64 / total_bytes as f64 * 100.0; + format!("{percentage:3.0}%").into() +} + +#[inline] +fn memory_label(data: &MemData, is_percentage: bool) -> Cow<'static, str> { + if is_percentage { + memory_percentage_label(data) + } else { + memory_fraction_label(data) + } +} + impl Painter { pub fn draw_basic_memory( &self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, widget_id: u64, ) { - let mem_data = &app_state.converted_data.mem_data; let mut draw_widgets: Vec> = Vec::new(); if app_state.current_widget.widget_id == widget_id { f.render_widget( - Block::default() - .borders(SIDE_BORDERS) - .border_style(self.colours.highlighted_border_style), + widget_block(true, true, self.styles.border_type) + .border_style(self.styles.highlighted_border_style), draw_loc, ); } - let ram_percentage = if let Some(mem) = mem_data.last() { - mem.1 - } else { - 0.0 - }; - - const EMPTY_MEMORY_FRAC_STRING: &str = "0.0B/0.0B"; + let data = app_state.data_store.get_data(); - let memory_fraction_label = - if let Some((_, label_frac)) = &app_state.converted_data.mem_labels { + let (ram_percentage, ram_label) = if let Some(ram_harvest) = &data.ram_harvest { + ( + ram_harvest.percentage(), + memory_label(ram_harvest, app_state.basic_mode_use_percent), + ) + } else { + ( + 0.0, if app_state.basic_mode_use_percent { - format!("{:3.0}%", ram_percentage.round()) + "0.0B/0.0B".into() } else { - label_frac.trim().to_string() - } - } else { - EMPTY_MEMORY_FRAC_STRING.to_string() - }; + " 0%".into() + }, + ) + }; draw_widgets.push( PipeGauge::default() .ratio(ram_percentage / 100.0) .start_label("RAM") - .inner_label(memory_fraction_label) - .label_style(self.colours.ram_style) - .gauge_style(self.colours.ram_style), + .inner_label(ram_label) + .label_style(self.styles.ram_style) + .gauge_style(self.styles.ram_style), ); + if let Some(swap_harvest) = &data.swap_harvest { + let swap_percentage = swap_harvest.percentage(); + let swap_label = memory_label(swap_harvest, app_state.basic_mode_use_percent); + + draw_widgets.push( + PipeGauge::default() + .ratio(swap_percentage / 100.0) + .start_label("SWP") + .inner_label(swap_label) + .label_style(self.styles.swap_style) + .gauge_style(self.styles.swap_style), + ); + } + #[cfg(not(target_os = "windows"))] { - if let Some((_, label_frac)) = &app_state.converted_data.cache_labels { - let cache_data = &app_state.converted_data.cache_data; - - let cache_percentage = if let Some(cache) = cache_data.last() { - cache.1 - } else { - 0.0 - }; + if let Some(cache_harvest) = &data.cache_harvest { + let cache_percentage = cache_harvest.percentage(); + let cache_fraction_label = + memory_label(cache_harvest, app_state.basic_mode_use_percent); - let cache_fraction_label = if app_state.basic_mode_use_percent { - format!("{:3.0}%", cache_percentage.round()) - } else { - label_frac.trim().to_string() - }; draw_widgets.push( PipeGauge::default() .ratio(cache_percentage / 100.0) .start_label("CHE") .inner_label(cache_fraction_label) - .label_style(self.colours.cache_style) - .gauge_style(self.colours.cache_style), + .label_style(self.styles.cache_style) + .gauge_style(self.styles.cache_style), ); } } - let swap_data = &app_state.converted_data.swap_data; - - let swap_percentage = if let Some(swap) = swap_data.last() { - swap.1 - } else { - 0.0 - }; - - if let Some((_, label_frac)) = &app_state.converted_data.swap_labels { - let swap_fraction_label = if app_state.basic_mode_use_percent { - format!("{:3.0}%", swap_percentage.round()) - } else { - label_frac.trim().to_string() - }; - draw_widgets.push( - PipeGauge::default() - .ratio(swap_percentage / 100.0) - .start_label("SWP") - .inner_label(swap_fraction_label) - .label_style(self.colours.swap_style) - .gauge_style(self.colours.swap_style), - ); - } - #[cfg(feature = "zfs")] { - let arc_data = &app_state.converted_data.arc_data; - let arc_percentage = if let Some(arc) = arc_data.last() { - arc.1 - } else { - 0.0 - }; - if let Some((_, label_frac)) = &app_state.converted_data.arc_labels { - let arc_fraction_label = if app_state.basic_mode_use_percent { - format!("{:3.0}%", arc_percentage.round()) - } else { - label_frac.trim().to_string() - }; + if let Some(arc_harvest) = &data.arc_harvest { + let arc_percentage = arc_harvest.percentage(); + let arc_fraction_label = + memory_label(arc_harvest, app_state.basic_mode_use_percent); + draw_widgets.push( PipeGauge::default() .ratio(arc_percentage / 100.0) .start_label("ARC") .inner_label(arc_fraction_label) - .label_style(self.colours.arc_style) - .gauge_style(self.colours.arc_style), + .label_style(self.styles.arc_style) + .gauge_style(self.styles.arc_style), ); } } #[cfg(feature = "gpu")] { - if let Some(gpu_data) = &app_state.converted_data.gpu_data { - let gpu_styles = &self.colours.gpu_colours; - let mut color_index = 0; - - gpu_data.iter().for_each(|gpu_data_vec| { - let gpu_data = gpu_data_vec.points.as_slice(); - let gpu_percentage = if let Some(gpu) = gpu_data.last() { - gpu.1 + let gpu_styles = &self.styles.gpu_colours; + let mut colour_index = 0; + + for (_, harvest) in data.gpu_harvest.iter() { + let percentage = harvest.percentage(); + let label = memory_label(harvest, app_state.basic_mode_use_percent); + + let style = { + if gpu_styles.is_empty() { + tui::style::Style::default() } else { - 0.0 - }; - let trimmed_gpu_frac = { - if app_state.basic_mode_use_percent { - format!("{:3.0}%", gpu_percentage.round()) - } else { - gpu_data_vec.mem_total.trim().to_string() - } - }; - let style = { - if gpu_styles.is_empty() { - tui::style::Style::default() - } else if color_index >= gpu_styles.len() { - // cycle styles - color_index = 1; - gpu_styles[color_index - 1] - } else { - color_index += 1; - gpu_styles[color_index - 1] - } - }; - draw_widgets.push( - PipeGauge::default() - .ratio(gpu_percentage / 100.0) - .start_label("GPU") - .inner_label(trimmed_gpu_frac) - .label_style(style) - .gauge_style(style), - ); - }); + let colour = gpu_styles[colour_index % gpu_styles.len()]; + colour_index += 1; + + colour + } + }; + + draw_widgets.push( + PipeGauge::default() + .ratio(percentage / 100.0) + .start_label("GPU") + .inner_label(label) + .label_style(style) + .gauge_style(style), + ); } } diff --git a/src/canvas/widgets/mem_graph.rs b/src/canvas/widgets/mem_graph.rs index 062f5ca6b..a3475ef00 100644 --- a/src/canvas/widgets/mem_graph.rs +++ b/src/canvas/widgets/mem_graph.rs @@ -1,143 +1,183 @@ -use std::borrow::Cow; +use std::time::Instant; use tui::{ + Frame, layout::{Constraint, Rect}, - symbols::Marker, - terminal::Frame, + style::Style, }; use crate::{ - app::App, + app::{App, data::Values}, canvas::{ - components::time_graph::{GraphData, TimeGraph}, - drawing_utils::should_hide_x_label, Painter, + components::time_graph::{GraphData, variants::percent::PercentTimeGraph}, + drawing_utils::should_hide_x_label, }, + collection::memory::MemData, + get_binary_unit_and_denominator, }; +/// Convert memory info into a combined memory label. +#[inline] +fn memory_legend_label(name: &str, data: Option<&MemData>) -> String { + if let Some(data) = data { + let total_bytes = data.total_bytes.get(); + let percentage = data.used_bytes as f64 / total_bytes as f64 * 100.0; + let (unit, denominator) = get_binary_unit_and_denominator(total_bytes); + let used = data.used_bytes as f64 / denominator; + let total = total_bytes as f64 / denominator; + + format!("{name}:{percentage:3.0}% {used:.1}{unit}/{total:.1}{unit}") + } else { + format!("{name}: 0% 0.0B/0.0B") + } +} + +/// Get graph data. +#[inline] +fn graph_data<'a>( + out: &mut Vec>, name: &str, last_harvest: Option<&'a MemData>, + time: &'a [Instant], values: &'a Values, style: Style, +) { + if !values.no_elements() { + let label = memory_legend_label(name, last_harvest).into(); + + out.push( + GraphData::default() + .name(label) + .time(time) + .values(values) + .style(style), + ); + } +} + impl Painter { pub fn draw_memory_graph( &self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, widget_id: u64, ) { - const Y_BOUNDS: [f64; 2] = [0.0, 100.5]; - const Y_LABELS: [Cow<'static, str>; 2] = [Cow::Borrowed(" 0%"), Cow::Borrowed("100%")]; - - if let Some(mem_widget_state) = app_state.states.mem_state.widget_states.get_mut(&widget_id) - { - let border_style = self.get_border_style(widget_id, app_state.current_widget.widget_id); - let x_bounds = [0, mem_widget_state.current_display_time]; + if let Some(mem_state) = app_state.states.mem_state.widget_states.get_mut(&widget_id) { let hide_x_labels = should_hide_x_label( app_state.app_config_fields.hide_time, app_state.app_config_fields.autohide_time, - &mut mem_widget_state.autohide_timer, + &mut mem_state.autohide_timer, draw_loc, ); - let points = { + let graph_data = { let mut size = 1; - if app_state.converted_data.swap_labels.is_some() { + let data = app_state.data_store.get_data(); + + // TODO: is this optimization really needed...? This just pre-allocates a vec, but it'll probably never + // be that big... + + if data.swap_harvest.is_some() { size += 1; // add capacity for SWAP } #[cfg(feature = "zfs")] { - if app_state.converted_data.arc_labels.is_some() { + if data.arc_harvest.is_some() { size += 1; // add capacity for ARC } } #[cfg(feature = "gpu")] { - if let Some(gpu_data) = &app_state.converted_data.gpu_data { - size += gpu_data.len(); // add row(s) for gpu - } + size += data.gpu_harvest.len(); // add row(s) for gpu } let mut points = Vec::with_capacity(size); - if let Some((label_percent, label_frac)) = &app_state.converted_data.mem_labels { - let mem_label = format!("RAM:{label_percent}{label_frac}"); - points.push(GraphData { - points: &app_state.converted_data.mem_data, - style: self.colours.ram_style, - name: Some(mem_label.into()), - }); - } + let timeseries = &data.timeseries_data; + let time = ×eries.time; + + // TODO: Add a "no data" option here/to time graph if there is no entries + graph_data( + &mut points, + "RAM", + data.ram_harvest.as_ref(), + time, + ×eries.ram, + self.styles.ram_style, + ); + + graph_data( + &mut points, + "SWP", + data.swap_harvest.as_ref(), + time, + ×eries.swap, + self.styles.swap_style, + ); + #[cfg(not(target_os = "windows"))] - if let Some((label_percent, label_frac)) = &app_state.converted_data.cache_labels { - let cache_label = format!("CHE:{label_percent}{label_frac}"); - points.push(GraphData { - points: &app_state.converted_data.cache_data, - style: self.colours.cache_style, - name: Some(cache_label.into()), - }); - } - if let Some((label_percent, label_frac)) = &app_state.converted_data.swap_labels { - let swap_label = format!("SWP:{label_percent}{label_frac}"); - points.push(GraphData { - points: &app_state.converted_data.swap_data, - style: self.colours.swap_style, - name: Some(swap_label.into()), - }); + { + graph_data( + &mut points, + "CACHE", // TODO: Figure out how to line this up better + data.cache_harvest.as_ref(), + time, + ×eries.cache_mem, + self.styles.cache_style, + ); } + #[cfg(feature = "zfs")] - if let Some((label_percent, label_frac)) = &app_state.converted_data.arc_labels { - let arc_label = format!("ARC:{label_percent}{label_frac}"); - points.push(GraphData { - points: &app_state.converted_data.arc_data, - style: self.colours.arc_style, - name: Some(arc_label.into()), - }); + { + graph_data( + &mut points, + "ARC", + data.arc_harvest.as_ref(), + time, + ×eries.arc_mem, + self.styles.arc_style, + ); } + #[cfg(feature = "gpu")] { - if let Some(gpu_data) = &app_state.converted_data.gpu_data { - let mut color_index = 0; - let gpu_styles = &self.colours.gpu_colours; - gpu_data.iter().for_each(|gpu| { - let gpu_label = - format!("{}:{}{}", gpu.name, gpu.mem_percent, gpu.mem_total); + let mut colour_index = 0; + let gpu_styles = &self.styles.gpu_colours; + + for (name, harvest) in &data.gpu_harvest { + if let Some(gpu_data) = data.timeseries_data.gpu_mem.get(name) { let style = { if gpu_styles.is_empty() { - tui::style::Style::default() - } else if color_index >= gpu_styles.len() { - // cycle styles - color_index = 1; - gpu_styles[color_index - 1] + Style::default() } else { - color_index += 1; - gpu_styles[color_index - 1] + let colour = gpu_styles[colour_index % gpu_styles.len()]; + colour_index += 1; + + colour } }; - points.push(GraphData { - points: gpu.points.as_slice(), + + graph_data( + &mut points, + name, // TODO: REALLY figure out how to line this up better + Some(harvest), + time, + gpu_data, style, - name: Some(gpu_label.into()), - }); - }); + ); + } } } points }; - let marker = if app_state.app_config_fields.use_dot { - Marker::Dot - } else { - Marker::Braille - }; - - TimeGraph { - x_bounds, + PercentTimeGraph { + display_range: mem_state.current_display_time, hide_x_labels, - y_bounds: Y_BOUNDS, - y_labels: &Y_LABELS, - graph_style: self.colours.graph_style, - border_style, - title: " Memory ".into(), + app_config_fields: &app_state.app_config_fields, + current_widget: app_state.current_widget.widget_id, is_expanded: app_state.is_expanded, - title_style: self.colours.widget_title_style, + title: " Memory ".into(), + styles: &self.styles, + widget_id, legend_position: app_state.app_config_fields.memory_legend_position, legend_constraints: Some((Constraint::Ratio(3, 4), Constraint::Ratio(3, 4))), - marker, } - .draw_time_graph(f, draw_loc, &points); + .build() + .draw(f, draw_loc, graph_data); } if app_state.should_get_widget_bounds() { diff --git a/src/canvas/widgets.rs b/src/canvas/widgets/mod.rs similarity index 88% rename from src/canvas/widgets.rs rename to src/canvas/widgets/mod.rs index 17651416f..25ec595bd 100644 --- a/src/canvas/widgets.rs +++ b/src/canvas/widgets/mod.rs @@ -1,4 +1,3 @@ -pub mod battery_display; pub mod cpu_basic; pub mod cpu_graph; pub mod disk_table; @@ -8,3 +7,6 @@ pub mod network_basic; pub mod network_graph; pub mod process_table; pub mod temperature_table; + +#[cfg(feature = "battery")] +pub mod battery_display; diff --git a/src/canvas/widgets/network_basic.rs b/src/canvas/widgets/network_basic.rs index 63e0e52ff..caf82903b 100644 --- a/src/canvas/widgets/network_basic.rs +++ b/src/canvas/widgets/network_basic.rs @@ -1,11 +1,15 @@ use tui::{ + Frame, layout::{Constraint, Direction, Layout, Rect}, - terminal::Frame, text::{Line, Span}, widgets::{Block, Paragraph}, }; -use crate::{app::App, canvas::Painter, constants::*}; +use crate::{ + app::App, + canvas::{Painter, drawing_utils::widget_block}, + utils::data_units::{convert_bits, get_unit_prefix}, +}; impl Painter { pub fn draw_basic_network( @@ -30,26 +34,32 @@ impl Painter { if app_state.current_widget.widget_id == widget_id { f.render_widget( - Block::default() - .borders(SIDE_BORDERS) - .border_style(self.colours.highlighted_border_style), + widget_block(true, true, self.styles.border_type) + .border_style(self.styles.highlighted_border_style), draw_loc, ); } - let rx_label = format!("RX: {}", app_state.converted_data.rx_display); - let tx_label = format!("TX: {}", app_state.converted_data.tx_display); - let total_rx_label = format!("Total RX: {}", app_state.converted_data.total_rx_display); - let total_tx_label = format!("Total TX: {}", app_state.converted_data.total_tx_display); + let use_binary_prefix = app_state.app_config_fields.network_use_binary_prefix; + let network_data = &(app_state.data_store.get_data().network_harvest); + let rx = get_unit_prefix(network_data.rx, use_binary_prefix); + let tx = get_unit_prefix(network_data.tx, use_binary_prefix); + let total_rx = convert_bits(network_data.total_rx, use_binary_prefix); + let total_tx = convert_bits(network_data.total_tx, use_binary_prefix); + + let rx_label = format!("RX: {:.1}{}", rx.0, rx.1); + let tx_label = format!("TX: {:.1}{}", tx.0, tx.1); + let total_rx_label = format!("Total RX: {:.1}{}", total_rx.0, total_rx.1); + let total_tx_label = format!("Total TX: {:.1}{}", total_tx.0, total_tx.1); let net_text = vec![ - Line::from(Span::styled(rx_label, self.colours.rx_style)), - Line::from(Span::styled(tx_label, self.colours.tx_style)), + Line::from(Span::styled(rx_label, self.styles.rx_style)), + Line::from(Span::styled(tx_label, self.styles.tx_style)), ]; let total_net_text = vec![ - Line::from(Span::styled(total_rx_label, self.colours.total_rx_style)), - Line::from(Span::styled(total_tx_label, self.colours.total_tx_style)), + Line::from(Span::styled(total_rx_label, self.styles.total_rx_style)), + Line::from(Span::styled(total_tx_label, self.styles.total_tx_style)), ]; f.render_widget(Paragraph::new(net_text).block(Block::default()), net_loc[0]); diff --git a/src/canvas/widgets/network_graph.rs b/src/canvas/widgets/network_graph.rs index 227c18ba9..77d369007 100644 --- a/src/canvas/widgets/network_graph.rs +++ b/src/canvas/widgets/network_graph.rs @@ -1,22 +1,25 @@ +use std::time::Duration; + use tui::{ + Frame, layout::{Constraint, Direction, Layout, Rect}, symbols::Marker, - terminal::Frame, text::Text, widgets::{Block, Borders, Row, Table}, }; use crate::{ - app::{App, AxisScaling}, + app::{App, AppConfigFields, AxisScaling}, canvas::{ - components::{ - time_chart::Point, - time_graph::{GraphData, TimeGraph}, - }, - drawing_utils::should_hide_x_label, Painter, + components::time_graph::{AxisBound, ChartScaling, GraphData, TimeGraph}, + drawing_utils::should_hide_x_label, }, - utils::{data_prefixes::*, data_units::DataUnit, general::partial_ordering}, + utils::{ + data_units::*, + general::{saturating_log2, saturating_log10}, + }, + widgets::NetWidgetHeightCache, }; impl Painter { @@ -54,16 +57,19 @@ impl Painter { pub fn draw_network_graph( &self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, widget_id: u64, - hide_legend: bool, + full_screen: bool, ) { if let Some(network_widget_state) = app_state.states.net_state.widget_states.get_mut(&widget_id) { - let network_data_rx = &app_state.converted_data.network_data_rx; - let network_data_tx = &app_state.converted_data.network_data_tx; + let shared_data = app_state.data_store.get_data(); + let network_latest_data = &(shared_data.network_harvest); + let rx_points = &(shared_data.timeseries_data.rx); + let tx_points = &(shared_data.timeseries_data.tx); + let time = &(shared_data.timeseries_data.time); let time_start = -(network_widget_state.current_display_time as f64); + let border_style = self.get_border_style(widget_id, app_state.current_widget.widget_id); - let x_bounds = [0, network_widget_state.current_display_time]; let hide_x_labels = should_hide_x_label( app_state.app_config_fields.hide_time, app_state.app_config_fields.autohide_time, @@ -71,79 +77,131 @@ impl Painter { draw_loc, ); - // TODO: Cache network results: Only update if: - // - Force update (includes time interval change) - // - Old max time is off screen - // - A new time interval is better and does not fit (check from end of vector to - // last checked; we only want to update if it is TOO big!) - - // Find the maximal rx/tx so we know how to scale, and return it. - let (_best_time, max_entry) = get_max_entry( - network_data_rx, - network_data_tx, - time_start, - &app_state.app_config_fields.network_scale_type, - app_state.app_config_fields.network_use_binary_prefix, - ); + let y_max = { + if let Some(last_time) = time.last() { + // For now, just do it each time. Might want to cache this later though. + + let (mut biggest, mut biggest_time, first_time) = { + let initial_first_time = *last_time + - Duration::from_millis(network_widget_state.current_display_time); + + match &network_widget_state.height_cache { + Some(NetWidgetHeightCache { + best_point, + right_edge, + period, + }) => { + if *period != network_widget_state.current_display_time + || best_point.0 < initial_first_time + { + (0.0, initial_first_time, initial_first_time) + } else { + (best_point.1, best_point.0, *right_edge) + } + } + None => (0.0, initial_first_time, initial_first_time), + } + }; + + for (&time, &v) in rx_points + .iter_along_base(time) + .rev() + .take_while(|&(&time, _)| time >= first_time) + { + if v > biggest { + biggest = v; + biggest_time = time; + } + } - let (max_range, labels) = adjust_network_data_point( - max_entry, - &app_state.app_config_fields.network_scale_type, - &app_state.app_config_fields.network_unit_type, - app_state.app_config_fields.network_use_binary_prefix, - ); + for (&time, &v) in tx_points + .iter_along_base(time) + .rev() + .take_while(|&(&time, _)| time >= first_time) + { + if v > biggest { + biggest = v; + biggest_time = time; + } + } - let y_labels = labels.iter().map(|label| label.into()).collect::>(); - let y_bounds = [0.0, max_range]; + network_widget_state.height_cache = Some(NetWidgetHeightCache { + best_point: (biggest_time, biggest), + right_edge: *last_time, + period: network_widget_state.current_display_time, + }); - let legend_constraints = if hide_legend { + biggest + } else { + 0.0 + } + }; + let (y_max, y_labels) = adjust_network_data_point(y_max, &app_state.app_config_fields); + let y_bounds = AxisBound::Max(y_max); + + let legend_constraints = if full_screen { (Constraint::Ratio(0, 1), Constraint::Ratio(0, 1)) } else { (Constraint::Ratio(1, 1), Constraint::Ratio(3, 4)) }; // TODO: Add support for clicking on legend to only show that value on chart. - let points = if app_state.app_config_fields.use_old_network_legend && !hide_legend { + + let use_binary_prefix = app_state.app_config_fields.network_use_binary_prefix; + let unit_type = app_state.app_config_fields.network_unit_type; + let unit = match unit_type { + DataUnit::Byte => "B/s", + DataUnit::Bit => "b/s", + }; + + let rx = get_unit_prefix(network_latest_data.rx, use_binary_prefix); + let tx = get_unit_prefix(network_latest_data.tx, use_binary_prefix); + let total_rx = convert_bits(network_latest_data.total_rx, use_binary_prefix); + let total_tx = convert_bits(network_latest_data.total_tx, use_binary_prefix); + + // TODO: This behaviour is pretty weird, we should probably just make it so if you use old network legend + // you don't do whatever this is... + let graph_data = if app_state.app_config_fields.use_old_network_legend && !full_screen { + let rx_label = format!("RX: {:.1}{}{}", rx.0, rx.1, unit); + let tx_label = format!("TX: {:.1}{}{}", tx.0, tx.1, unit); + let total_rx_label = format!("Total RX: {:.1}{}", total_rx.0, total_rx.1); + let total_tx_label = format!("Total TX: {:.1}{}", total_tx.0, total_tx.1); + vec![ - GraphData { - points: network_data_rx, - style: self.colours.rx_style, - name: Some(format!("RX: {:7}", app_state.converted_data.rx_display).into()), - }, - GraphData { - points: network_data_tx, - style: self.colours.tx_style, - name: Some(format!("TX: {:7}", app_state.converted_data.tx_display).into()), - }, - GraphData { - points: &[], - style: self.colours.total_rx_style, - name: Some( - format!("Total RX: {:7}", app_state.converted_data.total_rx_display) - .into(), - ), - }, - GraphData { - points: &[], - style: self.colours.total_tx_style, - name: Some( - format!("Total TX: {:7}", app_state.converted_data.total_tx_display) - .into(), - ), - }, + GraphData::default() + .name(rx_label.into()) + .time(time) + .values(rx_points) + .style(self.styles.rx_style), + GraphData::default() + .name(tx_label.into()) + .time(time) + .values(tx_points) + .style(self.styles.tx_style), + GraphData::default() + .style(self.styles.total_rx_style) + .name(total_rx_label.into()), + GraphData::default() + .style(self.styles.total_tx_style) + .name(total_tx_label.into()), ] } else { + let rx_label = format!("{:.1}{}{}", rx.0, rx.1, unit); + let tx_label = format!("{:.1}{}{}", tx.0, tx.1, unit); + let total_rx_label = format!("{:.1}{}", total_rx.0, total_rx.1); + let total_tx_label = format!("{:.1}{}", total_tx.0, total_tx.1); + vec![ - GraphData { - points: network_data_rx, - style: self.colours.rx_style, - name: Some((&app_state.converted_data.rx_display).into()), - }, - GraphData { - points: network_data_tx, - style: self.colours.tx_style, - name: Some((&app_state.converted_data.tx_display).into()), - }, + GraphData::default() + .name(format!("RX: {rx_label:<10} All: {total_rx_label}").into()) + .time(time) + .values(rx_points) + .style(self.styles.rx_style), + GraphData::default() + .name(format!("TX: {tx_label:<10} All: {total_tx_label}").into()) + .time(time) + .values(tx_points) + .style(self.styles.tx_style), ] }; @@ -153,21 +211,36 @@ impl Painter { Marker::Braille }; + let scaling = match app_state.app_config_fields.network_scale_type { + AxisScaling::Log => { + // TODO: I might change this behaviour later. + if app_state.app_config_fields.network_use_binary_prefix { + ChartScaling::Log2 + } else { + ChartScaling::Log10 + } + } + AxisScaling::Linear => ChartScaling::Linear, + }; + TimeGraph { - x_bounds, + x_min: time_start, hide_x_labels, y_bounds, - y_labels: &y_labels, - graph_style: self.colours.graph_style, + y_labels: &(y_labels.into_iter().map(Into::into).collect::>()), + graph_style: self.styles.graph_style, border_style, + border_type: self.styles.border_type, title: " Network ".into(), + is_selected: app_state.current_widget.widget_id == widget_id, is_expanded: app_state.is_expanded, - title_style: self.colours.widget_title_style, + title_style: self.styles.widget_title_style, legend_position: app_state.app_config_fields.network_legend_position, legend_constraints: Some(legend_constraints), marker, + scaling, } - .draw_time_graph(f, draw_loc, &points); + .draw(f, draw_loc, graph_data); } } @@ -176,17 +249,31 @@ impl Painter { ) { const NETWORK_HEADERS: [&str; 4] = ["RX", "TX", "Total RX", "Total TX"]; - let rx_display = &app_state.converted_data.rx_display; - let tx_display = &app_state.converted_data.tx_display; - let total_rx_display = &app_state.converted_data.total_rx_display; - let total_tx_display = &app_state.converted_data.total_tx_display; + let network_latest_data = &(app_state.data_store.get_data().network_harvest); + let use_binary_prefix = app_state.app_config_fields.network_use_binary_prefix; + let unit_type = app_state.app_config_fields.network_unit_type; + let unit = match unit_type { + DataUnit::Byte => "B/s", + DataUnit::Bit => "b/s", + }; + + let rx = get_unit_prefix(network_latest_data.rx, use_binary_prefix); + let tx = get_unit_prefix(network_latest_data.tx, use_binary_prefix); + + let rx_label = format!("{:.1}{}{}", rx.0, rx.1, unit); + let tx_label = format!("{:.1}{}{}", tx.0, tx.1, unit); + + let total_rx = convert_bits(network_latest_data.total_rx, use_binary_prefix); + let total_tx = convert_bits(network_latest_data.total_tx, use_binary_prefix); + let total_rx_label = format!("{:.1}{}", total_rx.0, total_rx.1); + let total_tx_label = format!("{:.1}{}", total_tx.0, total_tx.1); // Gross but I need it to work... let total_network = vec![Row::new([ - Text::styled(rx_display, self.colours.rx_style), - Text::styled(tx_display, self.colours.tx_style), - Text::styled(total_rx_display, self.colours.total_rx_style), - Text::styled(total_tx_display, self.colours.total_tx_style), + Text::styled(rx_label, self.styles.rx_style), + Text::styled(tx_label, self.styles.tx_style), + Text::styled(total_rx_label, self.styles.total_rx_style), + Text::styled(total_tx_label, self.styles.total_tx_style), ])]; // Draw @@ -198,147 +285,25 @@ impl Painter { .map(Constraint::Length) .collect::>()), ) - .header(Row::new(NETWORK_HEADERS).style(self.colours.table_header_style)) + .header(Row::new(NETWORK_HEADERS).style(self.styles.table_header_style)) .block(Block::default().borders(Borders::ALL).border_style( if app_state.current_widget.widget_id == widget_id { - self.colours.highlighted_border_style + self.styles.highlighted_border_style } else { - self.colours.border_style + self.styles.border_style }, )) - .style(self.colours.text_style), + .style(self.styles.text_style), draw_loc, ); } } -/// Returns the max data point and time given a time. -fn get_max_entry( - rx: &[Point], tx: &[Point], time_start: f64, network_scale_type: &AxisScaling, - network_use_binary_prefix: bool, -) -> Point { - /// Determines a "fake" max value in circumstances where we couldn't find - /// one from the data. - fn calculate_missing_max( - network_scale_type: &AxisScaling, network_use_binary_prefix: bool, - ) -> f64 { - match network_scale_type { - AxisScaling::Log => { - if network_use_binary_prefix { - LOG_KIBI_LIMIT - } else { - LOG_KILO_LIMIT - } - } - AxisScaling::Linear => { - if network_use_binary_prefix { - KIBI_LIMIT_F64 - } else { - KILO_LIMIT_F64 - } - } - } - } - - // First, let's shorten our ranges to actually look. We can abuse the fact that - // our rx and tx arrays are sorted, so we can short-circuit our search to - // filter out only the relevant data points... - let filtered_rx = if let (Some(rx_start), Some(rx_end)) = ( - rx.iter().position(|(time, _data)| *time >= time_start), - rx.iter().rposition(|(time, _data)| *time <= 0.0), - ) { - Some(&rx[rx_start..=rx_end]) - } else { - None - }; - - let filtered_tx = if let (Some(tx_start), Some(tx_end)) = ( - tx.iter().position(|(time, _data)| *time >= time_start), - tx.iter().rposition(|(time, _data)| *time <= 0.0), - ) { - Some(&tx[tx_start..=tx_end]) - } else { - None - }; - - // Then, find the maximal rx/tx so we know how to scale, and return it. - match (filtered_rx, filtered_tx) { - (None, None) => ( - time_start, - calculate_missing_max(network_scale_type, network_use_binary_prefix), - ), - (None, Some(filtered_tx)) => { - match filtered_tx - .iter() - .max_by(|(_, data_a), (_, data_b)| partial_ordering(data_a, data_b)) - { - Some((best_time, max_val)) => { - if *max_val == 0.0 { - ( - time_start, - calculate_missing_max(network_scale_type, network_use_binary_prefix), - ) - } else { - (*best_time, *max_val) - } - } - None => ( - time_start, - calculate_missing_max(network_scale_type, network_use_binary_prefix), - ), - } - } - (Some(filtered_rx), None) => { - match filtered_rx - .iter() - .max_by(|(_, data_a), (_, data_b)| partial_ordering(data_a, data_b)) - { - Some((best_time, max_val)) => { - if *max_val == 0.0 { - ( - time_start, - calculate_missing_max(network_scale_type, network_use_binary_prefix), - ) - } else { - (*best_time, *max_val) - } - } - None => ( - time_start, - calculate_missing_max(network_scale_type, network_use_binary_prefix), - ), - } - } - (Some(filtered_rx), Some(filtered_tx)) => { - match filtered_rx - .iter() - .chain(filtered_tx) - .max_by(|(_, data_a), (_, data_b)| partial_ordering(data_a, data_b)) - { - Some((best_time, max_val)) => { - if *max_val == 0.0 { - ( - *best_time, - calculate_missing_max(network_scale_type, network_use_binary_prefix), - ) - } else { - (*best_time, *max_val) - } - } - None => ( - time_start, - calculate_missing_max(network_scale_type, network_use_binary_prefix), - ), - } - } - } -} - -/// Returns the required max data point and labels. -fn adjust_network_data_point( - max_entry: f64, network_scale_type: &AxisScaling, network_unit_type: &DataUnit, - network_use_binary_prefix: bool, -) -> (f64, Vec) { +/// Returns the required labels. +/// +/// TODO: This is _really_ ugly... also there might be a bug with certain heights and too many labels. +/// We may need to take draw height into account, either here, or in the time graph itself. +fn adjust_network_data_point(max_entry: f64, config: &AppConfigFields) -> (f64, Vec) { // So, we're going with an approach like this for linear data: // - Main goal is to maximize the amount of information displayed given a // specific height. We don't want to drown out some data if the ranges are too @@ -351,9 +316,9 @@ fn adjust_network_data_point( // drew 4 segments, it would be 97.5, 195, 292.5, 390, and // probably something like 438.75? // - // So, how do we do this in ratatui? Well, if we are using intervals that tie + // So, how do we do this in ratatui? Well, if we are using intervals that tie // in perfectly to the max value we want... then it's actually not that - // hard. Since ratatui accepts a vector as labels and will properly space + // hard. Since ratatui accepts a vector as labels and will properly space // them all out... we just work with that and space it out properly. // // Dynamic chart idea based off of FreeNAS's chart design. @@ -366,14 +331,18 @@ fn adjust_network_data_point( // Now just check the largest unit we correspond to... then proceed to build // some entries from there! + let scale_type = config.network_scale_type; + let use_binary_prefix = config.network_use_binary_prefix; + let network_unit_type = config.network_unit_type; + let unit_char = match network_unit_type { DataUnit::Byte => "B", DataUnit::Bit => "b", }; - match network_scale_type { + match scale_type { AxisScaling::Linear => { - let (k_limit, m_limit, g_limit, t_limit) = if network_use_binary_prefix { + let (k_limit, m_limit, g_limit, t_limit) = if use_binary_prefix { ( KIBI_LIMIT_F64, MEBI_LIMIT_F64, @@ -389,32 +358,32 @@ fn adjust_network_data_point( ) }; - let bumped_max_entry = max_entry * 1.5; // We use the bumped up version to calculate our unit type. + let max_entry_upper = max_entry * 1.5; // We use the bumped up version to calculate our unit type. let (max_value_scaled, unit_prefix, unit_type): (f64, &str, &str) = - if bumped_max_entry < k_limit { + if max_entry_upper < k_limit { (max_entry, "", unit_char) - } else if bumped_max_entry < m_limit { + } else if max_entry_upper < m_limit { ( max_entry / k_limit, - if network_use_binary_prefix { "Ki" } else { "K" }, + if use_binary_prefix { "Ki" } else { "K" }, unit_char, ) - } else if bumped_max_entry < g_limit { + } else if max_entry_upper < g_limit { ( max_entry / m_limit, - if network_use_binary_prefix { "Mi" } else { "M" }, + if use_binary_prefix { "Mi" } else { "M" }, unit_char, ) - } else if bumped_max_entry < t_limit { + } else if max_entry_upper < t_limit { ( max_entry / g_limit, - if network_use_binary_prefix { "Gi" } else { "G" }, + if use_binary_prefix { "Gi" } else { "G" }, unit_char, ) } else { ( max_entry / t_limit, - if network_use_binary_prefix { "Ti" } else { "T" }, + if use_binary_prefix { "Ti" } else { "T" }, unit_char, ) }; @@ -422,7 +391,6 @@ fn adjust_network_data_point( // Finally, build an acceptable range starting from there, using the given // height! Note we try to put more of a weight on the bottom section // vs. the top, since the top has less data. - let base_unit = max_value_scaled; let labels: Vec = vec![ format!("0{unit_prefix}{unit_type}"), @@ -431,19 +399,29 @@ fn adjust_network_data_point( format!("{:.1}", base_unit * 1.5), ] .into_iter() - .map(|s| format!("{s:>5}")) // Pull 5 as the longest legend value is generally going to be 5 digits (if they somehow - // hit over 5 terabits per second) + .map(|s| { + // Pull 5 as the longest legend value is generally going to be 5 digits (if they somehow hit over 5 terabits per second) + format!("{s:>5}") + }) .collect(); - (bumped_max_entry, labels) + (max_entry_upper, labels) } AxisScaling::Log => { - let (m_limit, g_limit, t_limit) = if network_use_binary_prefix { + let (m_limit, g_limit, t_limit) = if use_binary_prefix { (LOG_MEBI_LIMIT, LOG_GIBI_LIMIT, LOG_TEBI_LIMIT) } else { (LOG_MEGA_LIMIT, LOG_GIGA_LIMIT, LOG_TERA_LIMIT) }; + // Remember to do saturating log checks as otherwise 0.0 becomes inf, and you get + // gaps! + let max_entry = if use_binary_prefix { + saturating_log2(max_entry) + } else { + saturating_log10(max_entry) + }; + fn get_zero(network_use_binary_prefix: bool, unit_char: &str) -> String { format!( "{}0{}", @@ -496,47 +474,47 @@ fn adjust_network_data_point( ( m_limit, vec![ - get_zero(network_use_binary_prefix, unit_char), - get_k(network_use_binary_prefix, unit_char), - get_m(network_use_binary_prefix, unit_char), + get_zero(use_binary_prefix, unit_char), + get_k(use_binary_prefix, unit_char), + get_m(use_binary_prefix, unit_char), ], ) } else if max_entry < g_limit { ( g_limit, vec![ - get_zero(network_use_binary_prefix, unit_char), - get_k(network_use_binary_prefix, unit_char), - get_m(network_use_binary_prefix, unit_char), - get_g(network_use_binary_prefix, unit_char), + get_zero(use_binary_prefix, unit_char), + get_k(use_binary_prefix, unit_char), + get_m(use_binary_prefix, unit_char), + get_g(use_binary_prefix, unit_char), ], ) } else if max_entry < t_limit { ( t_limit, vec![ - get_zero(network_use_binary_prefix, unit_char), - get_k(network_use_binary_prefix, unit_char), - get_m(network_use_binary_prefix, unit_char), - get_g(network_use_binary_prefix, unit_char), - get_t(network_use_binary_prefix, unit_char), + get_zero(use_binary_prefix, unit_char), + get_k(use_binary_prefix, unit_char), + get_m(use_binary_prefix, unit_char), + get_g(use_binary_prefix, unit_char), + get_t(use_binary_prefix, unit_char), ], ) } else { // I really doubt anyone's transferring beyond petabyte speeds... ( - if network_use_binary_prefix { + if use_binary_prefix { LOG_PEBI_LIMIT } else { LOG_PETA_LIMIT }, vec![ - get_zero(network_use_binary_prefix, unit_char), - get_k(network_use_binary_prefix, unit_char), - get_m(network_use_binary_prefix, unit_char), - get_g(network_use_binary_prefix, unit_char), - get_t(network_use_binary_prefix, unit_char), - get_p(network_use_binary_prefix, unit_char), + get_zero(use_binary_prefix, unit_char), + get_k(use_binary_prefix, unit_char), + get_m(use_binary_prefix, unit_char), + get_g(use_binary_prefix, unit_char), + get_t(use_binary_prefix, unit_char), + get_p(use_binary_prefix, unit_char), ], ) } diff --git a/src/canvas/widgets/process_table.rs b/src/canvas/widgets/process_table.rs index c952cd3f0..22614508a 100644 --- a/src/canvas/widgets/process_table.rs +++ b/src/canvas/widgets/process_table.rs @@ -1,19 +1,19 @@ use tui::{ + Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::Style, - terminal::Frame, text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, + widgets::Paragraph, }; use unicode_segmentation::UnicodeSegmentation; use crate::{ app::{App, AppSearchState}, canvas::{ - components::data_table::{DrawInfo, SelectionState}, Painter, + components::data_table::{DrawInfo, SelectionState}, + drawing_utils::widget_block, }, - constants::*, }; const SORT_MENU_WIDTH: u16 = 7; @@ -23,11 +23,11 @@ impl Painter { /// - `widget_id` here represents the widget ID of the process widget /// itself! pub fn draw_process( - &self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, draw_border: bool, - widget_id: u64, + &self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, widget_id: u64, ) { if let Some(proc_widget_state) = app_state.states.proc_state.widget_states.get(&widget_id) { - let search_height = if draw_border { 5 } else { 3 }; + let is_basic = app_state.app_config_fields.use_basic_mode; + let search_height = if !is_basic { 5 } else { 3 }; let is_sort_open = proc_widget_state.is_sort_open; let mut proc_draw_loc = draw_loc; @@ -38,13 +38,7 @@ impl Painter { .split(draw_loc); proc_draw_loc = processes_chunk[0]; - self.draw_search_field( - f, - app_state, - processes_chunk[1], - draw_border, - widget_id + 1, - ); + self.draw_search_field(f, app_state, processes_chunk[1], widget_id + 1); } if is_sort_open { @@ -110,8 +104,7 @@ impl Painter { /// - `widget_id` represents the widget ID of the search box itself --- NOT /// the process widget state that is stored. fn draw_search_field( - &self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, draw_border: bool, - widget_id: u64, + &self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, widget_id: u64, ) { fn build_query_span( search_state: &AppSearchState, available_width: usize, is_on_widget: bool, @@ -157,16 +150,18 @@ impl Painter { } } + let is_basic = app_state.app_config_fields.use_basic_mode; + if let Some(proc_widget_state) = app_state .states .proc_state .widget_states .get_mut(&(widget_id - 1)) { - let is_on_widget = widget_id == app_state.current_widget.widget_id; + let is_selected = widget_id == app_state.current_widget.widget_id; let num_columns = usize::from(draw_loc.width); const SEARCH_TITLE: &str = "> "; - let offset = if draw_border { 4 } else { 2 }; // width of 3 removed for >_| + let offset = 4; let available_width = if num_columns > (offset + 3) { num_columns - offset } else { @@ -182,18 +177,18 @@ impl Painter { let query_with_cursor = build_query_span( &proc_widget_state.proc_search.search_state, available_width, - is_on_widget, - self.colours.selected_text_style, - self.colours.text_style, + is_selected, + self.styles.selected_text_style, + self.styles.text_style, ); let mut search_text = vec![Line::from({ let mut search_vec = vec![Span::styled( SEARCH_TITLE, - if is_on_widget { - self.colours.table_header_style + if is_selected { + self.styles.table_header_style } else { - self.colours.text_style + self.styles.text_style }, )]; search_vec.extend(query_with_cursor); @@ -203,21 +198,21 @@ impl Painter { // Text options shamelessly stolen from VS Code. let case_style = if !proc_widget_state.proc_search.is_ignoring_case { - self.colours.selected_text_style + self.styles.selected_text_style } else { - self.colours.text_style + self.styles.text_style }; let whole_word_style = if proc_widget_state.proc_search.is_searching_whole_word { - self.colours.selected_text_style + self.styles.selected_text_style } else { - self.colours.text_style + self.styles.text_style }; let regex_style = if proc_widget_state.proc_search.is_searching_with_regex { - self.colours.selected_text_style + self.styles.selected_text_style } else { - self.colours.text_style + self.styles.text_style }; // TODO: [MOUSE] Mouse support for these in search @@ -245,54 +240,42 @@ impl Painter { } else { "" }, - self.colours.invalid_query_style, + self.styles.invalid_query_style, ))); search_text.push(option_text); let current_border_style = if proc_widget_state.proc_search.search_state.is_invalid_search { - self.colours.invalid_query_style - } else if is_on_widget { - self.colours.highlighted_border_style + self.styles.invalid_query_style + } else if is_selected { + self.styles.highlighted_border_style } else { - self.colours.border_style + self.styles.border_style }; - let title = Span::styled( - if draw_border { - const TITLE_BASE: &str = " Esc to close "; - let repeat_num = - usize::from(draw_loc.width).saturating_sub(TITLE_BASE.chars().count() + 2); - format!("{} Esc to close ", "─".repeat(repeat_num)) - } else { - String::new() - }, - current_border_style, - ); + let process_search_block = { + let mut block = widget_block(is_basic, is_selected, self.styles.border_type) + .border_style(current_border_style); - let process_search_block = if draw_border { - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(current_border_style) - } else if is_on_widget { - Block::default() - .borders(SIDE_BORDERS) - .border_style(current_border_style) - } else { - Block::default().borders(Borders::NONE) + if !is_basic { + block = block.title_top( + Line::styled(" Esc to close ", current_border_style).right_aligned(), + ) + } + + block }; let margined_draw_loc = Layout::default() .constraints([Constraint::Percentage(100)]) - .horizontal_margin(u16::from(!(is_on_widget || draw_border))) + .horizontal_margin(u16::from(is_basic && !is_selected)) .direction(Direction::Horizontal) .split(draw_loc)[0]; f.render_widget( Paragraph::new(search_text) .block(process_search_block) - .style(self.colours.text_style) + .style(self.styles.text_style) .alignment(Alignment::Left), margined_draw_loc, ); diff --git a/src/canvas/widgets/temperature_table.rs b/src/canvas/widgets/temperature_table.rs index 4389ff246..96bda698b 100644 --- a/src/canvas/widgets/temperature_table.rs +++ b/src/canvas/widgets/temperature_table.rs @@ -1,10 +1,10 @@ -use tui::{layout::Rect, terminal::Frame}; +use tui::{Frame, layout::Rect}; use crate::{ app, canvas::{ - components::data_table::{DrawInfo, SelectionState}, Painter, + components::data_table::{DrawInfo, SelectionState}, }, }; diff --git a/src/data_collection.rs b/src/collection.rs similarity index 82% rename from src/data_collection.rs rename to src/collection.rs index a95b54ba4..60bc0824a 100644 --- a/src/data_collection.rs +++ b/src/collection.rs @@ -1,11 +1,20 @@ //! This is the main file to house data collection functions. +//! +//! TODO: Rename this to intake? Collection? #[cfg(feature = "nvidia")] pub mod nvidia; +#[cfg(all(target_os = "linux", feature = "gpu"))] +pub mod amd; + +#[cfg(target_os = "linux")] +mod linux { + pub mod utils; +} + #[cfg(feature = "battery")] pub mod batteries; - pub mod cpu; pub mod disks; pub mod error; @@ -23,30 +32,30 @@ use processes::Pid; #[cfg(feature = "battery")] use starship_battery::{Battery, Manager}; -use self::temperature::TemperatureType; use super::DataFilters; use crate::app::layout_manager::UsedWidgets; +// TODO: We can possibly re-use an internal buffer for this to reduce allocs. #[derive(Clone, Debug)] pub struct Data { pub collection_time: Instant, pub cpu: Option, pub load_avg: Option, - pub memory: Option, + pub memory: Option, #[cfg(not(target_os = "windows"))] - pub cache: Option, - pub swap: Option, - pub temperature_sensors: Option>, + pub cache: Option, + pub swap: Option, + pub temperature_sensors: Option>, pub network: Option, pub list_of_processes: Option>, pub disks: Option>, pub io: Option, #[cfg(feature = "battery")] - pub list_of_batteries: Option>, + pub list_of_batteries: Option>, #[cfg(feature = "zfs")] - pub arc: Option, + pub arc: Option, #[cfg(feature = "gpu")] - pub gpu: Option>, + pub gpu: Option>, } impl Default for Data { @@ -125,7 +134,7 @@ impl Default for SysinfoSource { use sysinfo::*; Self { - system: System::new_with_specifics(RefreshKind::new()), + system: System::new(), network: Networks::new(), #[cfg(not(target_os = "linux"))] temps: Components::new(), @@ -141,7 +150,6 @@ impl Default for SysinfoSource { pub struct DataCollector { pub data: Data, sys: SysinfoSource, - temperature_type: TemperatureType, use_current_cpu_total: bool, unnormalized_cpu: bool, last_collection_time: Instant, @@ -187,7 +195,6 @@ impl DataCollector { prev_idle: 0_f64, #[cfg(target_os = "linux")] prev_non_idle: 0_f64, - temperature_type: TemperatureType::Celsius, use_current_cpu_total: false, unnormalized_cpu: false, last_collection_time, @@ -234,14 +241,10 @@ impl DataCollector { self.data.cleanup(); } - pub fn set_data_collection(&mut self, used_widgets: UsedWidgets) { + pub fn set_collection(&mut self, used_widgets: UsedWidgets) { self.widgets_to_harvest = used_widgets; } - pub fn set_temperature_type(&mut self, temperature_type: TemperatureType) { - self.temperature_type = temperature_type; - } - pub fn set_use_current_cpu_total(&mut self, use_current_cpu_total: bool) { self.use_current_cpu_total = use_current_cpu_total; } @@ -264,11 +267,9 @@ impl DataCollector { fn refresh_sysinfo_data(&mut self) { // Refresh the list of objects once every minute. If it's too frequent it can // cause segfaults. - const LIST_REFRESH_TIME: Duration = Duration::from_secs(60); - let refresh_start = Instant::now(); if self.widgets_to_harvest.use_cpu || self.widgets_to_harvest.use_proc { - self.sys.system.refresh_cpu(); + self.sys.system.refresh_cpu_all(); } if self.widgets_to_harvest.use_mem || self.widgets_to_harvest.use_proc { @@ -276,10 +277,7 @@ impl DataCollector { } if self.widgets_to_harvest.use_net { - if refresh_start.duration_since(self.last_collection_time) > LIST_REFRESH_TIME { - self.sys.network.refresh_list(); - } - self.sys.network.refresh(); + self.sys.network.refresh(true); } // sysinfo is used on non-Linux systems for the following: @@ -288,8 +286,13 @@ impl DataCollector { // - Temperatures and temperature components list. #[cfg(not(target_os = "linux"))] { + const LIST_REFRESH_TIME: Duration = Duration::from_secs(60); + let refresh_start = Instant::now(); + if self.widgets_to_harvest.use_proc { self.sys.system.refresh_processes_specifics( + sysinfo::ProcessesToUpdate::All, + true, sysinfo::ProcessRefreshKind::everything() .without_environ() .without_cwd() @@ -299,24 +302,30 @@ impl DataCollector { // For Windows, sysinfo also handles the users list. #[cfg(target_os = "windows")] if refresh_start.duration_since(self.last_collection_time) > LIST_REFRESH_TIME { - self.sys.users.refresh_list(); + self.sys.users.refresh(); } } if self.widgets_to_harvest.use_temp { if refresh_start.duration_since(self.last_collection_time) > LIST_REFRESH_TIME { - self.sys.temps.refresh_list(); + self.sys.temps.refresh(true); + } + + for component in self.sys.temps.iter_mut() { + component.refresh(); } - self.sys.temps.refresh(); } - } - #[cfg(target_os = "windows")] - if self.widgets_to_harvest.use_disk { - if refresh_start.duration_since(self.last_collection_time) > LIST_REFRESH_TIME { - self.sys.disks.refresh_list(); + #[cfg(target_os = "windows")] + if self.widgets_to_harvest.use_disk { + if refresh_start.duration_since(self.last_collection_time) > LIST_REFRESH_TIME { + self.sys.disks.refresh(true); + } + + for disk in self.sys.disks.iter_mut() { + disk.refresh(); + } } - self.sys.disks.refresh(); } } @@ -347,12 +356,14 @@ impl DataCollector { #[inline] fn update_gpus(&mut self) { if self.widgets_to_harvest.use_gpu { + let mut local_gpu: Vec<(String, memory::MemData)> = Vec::new(); + let mut local_gpu_pids: Vec> = Vec::new(); + let mut local_gpu_total_mem: u64 = 0; + #[cfg(feature = "nvidia")] - if let Some(data) = nvidia::get_nvidia_vecs( - &self.temperature_type, - &self.filters.temp_filter, - &self.widgets_to_harvest, - ) { + if let Some(data) = + nvidia::get_nvidia_vecs(&self.filters.temp_filter, &self.widgets_to_harvest) + { if let Some(mut temp) = data.temperature { if let Some(sensors) = &mut self.data.temperature_sensors { sensors.append(&mut temp); @@ -360,14 +371,31 @@ impl DataCollector { self.data.temperature_sensors = Some(temp); } } - if let Some(mem) = data.memory { - self.data.gpu = Some(mem); + if let Some(mut mem) = data.memory { + local_gpu.append(&mut mem); } - if let Some(proc) = data.procs { - self.gpu_pids = Some(proc.1); - self.gpus_total_mem = Some(proc.0); + if let Some(mut proc) = data.procs { + local_gpu_pids.append(&mut proc.1); + local_gpu_total_mem += proc.0; } } + + #[cfg(target_os = "linux")] + if let Some(data) = + amd::get_amd_vecs(&self.widgets_to_harvest, self.last_collection_time) + { + if let Some(mut mem) = data.memory { + local_gpu.append(&mut mem); + } + if let Some(mut proc) = data.procs { + local_gpu_pids.append(&mut proc.1); + local_gpu_total_mem += proc.0; + } + } + + self.data.gpu = (!local_gpu.is_empty()).then_some(local_gpu); + self.gpu_pids = (!local_gpu_pids.is_empty()).then_some(local_gpu_pids); + self.gpus_total_mem = (local_gpu_total_mem > 0).then_some(local_gpu_total_mem); } } @@ -400,18 +428,14 @@ impl DataCollector { fn update_temps(&mut self) { if self.widgets_to_harvest.use_temp { #[cfg(not(target_os = "linux"))] - if let Ok(data) = temperature::get_temperature_data( - &self.sys.temps, - &self.temperature_type, - &self.filters.temp_filter, - ) { + if let Ok(data) = + temperature::get_temperature_data(&self.sys.temps, &self.filters.temp_filter) + { self.data.temperature_sensors = data; } #[cfg(target_os = "linux")] - if let Ok(data) = - temperature::get_temperature_data(&self.temperature_type, &self.filters.temp_filter) - { + if let Ok(data) = temperature::get_temperature_data(&self.filters.temp_filter) { self.data.temperature_sensors = data; } } @@ -479,7 +503,7 @@ impl DataCollector { #[inline] fn total_memory(&self) -> u64 { if let Some(memory) = &self.data.memory { - memory.total_bytes + memory.total_bytes.get() } else { self.sys.system.total_memory() } @@ -521,6 +545,6 @@ where value .as_object_mut() .and_then(|map| map.remove(key)) - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "key not found")) + .ok_or_else(|| std::io::Error::other("key not found")) .and_then(|val| serde_json::from_value(val).map_err(|err| err.into())) } diff --git a/src/collection/amd.rs b/src/collection/amd.rs new file mode 100644 index 000000000..18fe5d739 --- /dev/null +++ b/src/collection/amd.rs @@ -0,0 +1,411 @@ +mod amd_gpu_marketing; + +use std::{ + fs::{self, read_to_string}, + num::NonZeroU64, + path::{Path, PathBuf}, + sync::{LazyLock, Mutex}, + time::{Duration, Instant}, +}; + +use hashbrown::{HashMap, HashSet}; + +use super::linux::utils::is_device_awake; +use crate::{app::layout_manager::UsedWidgets, collection::memory::MemData}; + +// TODO: May be able to clean up some of these, Option for example is a bit redundant. +pub struct AmdGpuData { + pub memory: Option>, + pub procs: Option<(u64, Vec>)>, +} + +pub struct AmdGpuMemory { + pub total: u64, + pub used: u64, +} + +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub struct AmdGpuProc { + pub vram_usage: u64, + pub gfx_usage: u64, + pub dma_usage: u64, + pub enc_usage: u64, + pub dec_usage: u64, + pub uvd_usage: u64, + pub vcn_usage: u64, + pub vpe_usage: u64, + pub compute_usage: u64, +} + +// needs previous state for usage calculation +static PROC_DATA: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn get_amd_devs() -> Option> { + let mut devices = Vec::new(); + + // read all PCI devices controlled by the AMDGPU module + let Ok(paths) = fs::read_dir("/sys/module/amdgpu/drivers/pci:amdgpu") else { + return None; + }; + + for path in paths { + let Ok(path) = path else { continue }; + + // test if it has a valid vendor path + let device_path = path.path(); + if !device_path.is_dir() { + continue; + } + + // Skip if asleep to avoid wakeups. + if !is_device_awake(&device_path) { + continue; + } + + // This will exist for GPUs but not others, this is how we find their kernel + // name. + let test_path = device_path.join("drm"); + if test_path.as_path().exists() { + devices.push(device_path); + } + } + + if devices.is_empty() { + None + } else { + Some(devices) + } +} + +pub fn get_amd_name(device_path: &Path) -> Option { + // get revision and device ids from sysfs + let rev_path = device_path.join("revision"); + let dev_path = device_path.join("device"); + + if !rev_path.exists() || !dev_path.exists() { + return None; + } + + // read and remove newlines, 0x0 suffix. + let mut rev_data = read_to_string(rev_path).unwrap_or("0x00".to_string()); + let mut dev_data = read_to_string(dev_path).unwrap_or("0x0000".to_string()); + + rev_data = rev_data.trim_end().to_string(); + dev_data = dev_data.trim_end().to_string(); + + if rev_data.starts_with("0x") { + rev_data = rev_data.strip_prefix("0x").unwrap().to_string(); + } + + if dev_data.starts_with("0x") { + dev_data = dev_data.strip_prefix("0x").unwrap().to_string(); + } + + let revision_id = u32::from_str_radix(&rev_data, 16).unwrap_or(0); + let device_id = u32::from_str_radix(&dev_data, 16).unwrap_or(0); + + if device_id == 0 { + return None; + } + + // if it exists in our local database, use that name + amd_gpu_marketing::AMD_GPU_MARKETING_NAME + .iter() + .find(|(did, rid, _)| (did, rid) == (&device_id, &revision_id)) + .map(|tuple| tuple.2.to_string()) +} + +fn get_amd_vram(device_path: &Path) -> Option { + // get vram memory info from sysfs + let vram_total_path = device_path.join("mem_info_vram_total"); + let vram_used_path = device_path.join("mem_info_vram_used"); + + let Ok(mut vram_total_data) = read_to_string(vram_total_path) else { + return None; + }; + let Ok(mut vram_used_data) = read_to_string(vram_used_path) else { + return None; + }; + + // read and remove newlines + vram_total_data = vram_total_data.trim_end().to_string(); + vram_used_data = vram_used_data.trim_end().to_string(); + + let Ok(vram_total) = vram_total_data.parse::() else { + return None; + }; + let Ok(vram_used) = vram_used_data.parse::() else { + return None; + }; + + Some(AmdGpuMemory { + total: vram_total, + used: vram_used, + }) +} + +// from amdgpu_top: https://github.com/Umio-Yasuno/amdgpu_top/blob/c961cf6625c4b6d63fda7f03348323048563c584/crates/libamdgpu_top/src/stat/fdinfo/proc_info.rs#L114 +fn diff_usage(pre: u64, cur: u64, interval: &Duration) -> u64 { + use std::ops::Mul; + + let diff_ns = if pre == 0 || cur < pre { + return 0; + } else { + cur.saturating_sub(pre) as u128 + }; + + diff_ns + .mul(100) + .checked_div(interval.as_nanos()) + .unwrap_or(0) as u64 +} + +// from amdgpu_top: https://github.com/Umio-Yasuno/amdgpu_top/blob/c961cf6625c4b6d63fda7f03348323048563c584/crates/libamdgpu_top/src/stat/fdinfo/proc_info.rs#L13-L27 +fn get_amdgpu_pid_fds(pid: u32, device_path: Vec) -> Option> { + let Ok(fd_list) = fs::read_dir(format!("/proc/{pid}/fd/")) else { + return None; + }; + + let valid_fds: Vec = fd_list + .filter_map(|fd_link| { + let dir_entry = fd_link.map(|fd_link| fd_link.path()).ok()?; + let link = fs::read_link(&dir_entry).ok()?; + + // e.g. "/dev/dri/renderD128" or "/dev/dri/card0" + if device_path.iter().any(|path| link.starts_with(path)) { + dir_entry.file_name()?.to_str()?.parse::().ok() + } else { + None + } + }) + .collect(); + + if valid_fds.is_empty() { + None + } else { + Some(valid_fds) + } +} + +fn get_amdgpu_drm(device_path: &Path) -> Option> { + let mut drm_devices = Vec::new(); + let drm_root = device_path.join("drm"); + + let Ok(drm_paths) = fs::read_dir(drm_root) else { + return None; + }; + + for drm_dir in drm_paths { + let Ok(drm_dir) = drm_dir else { + continue; + }; + + // attempt to get the device renderer name + let drm_name = drm_dir.file_name(); + let Some(drm_name) = drm_name.to_str() else { + continue; + }; + + // construct driver device path if valid + if !drm_name.starts_with("card") && !drm_name.starts_with("render") { + continue; + } + + drm_devices.push(PathBuf::from(format!("/dev/dri/{drm_name}"))); + } + + if drm_devices.is_empty() { + None + } else { + Some(drm_devices) + } +} + +fn get_amd_fdinfo(device_path: &Path) -> Option> { + let mut fdinfo = HashMap::new(); + + let drm_paths = get_amdgpu_drm(device_path)?; + + let Ok(proc_dir) = fs::read_dir("/proc") else { + return None; + }; + + let pids: Vec = proc_dir + .filter_map(|dir_entry| { + // check if pid is valid + let dir_entry = dir_entry.ok()?; + let metadata = dir_entry.metadata().ok()?; + + if !metadata.is_dir() { + return None; + } + + let pid = dir_entry.file_name().to_str()?.parse::().ok()?; + + // skip init process + if pid == 1 { + return None; + } + + Some(pid) + }) + .collect(); + + for pid in pids { + // collect file descriptors that point to our device renderers + let Some(fds) = get_amdgpu_pid_fds(pid, drm_paths.clone()) else { + continue; + }; + + let mut usage: AmdGpuProc = Default::default(); + + let mut observed_ids: HashSet = HashSet::new(); + + for fd in fds { + let fdinfo_path = format!("/proc/{pid}/fdinfo/{fd}"); + let Ok(fdinfo_data) = read_to_string(fdinfo_path) else { + continue; + }; + + let mut fdinfo_lines = fdinfo_data + .lines() + .skip_while(|l| !l.starts_with("drm-client-id")); + if let Some(id) = fdinfo_lines.next().and_then(|fdinfo_line| { + const LEN: usize = "drm-client-id:\t".len(); + fdinfo_line.get(LEN..)?.parse().ok() + }) { + if !observed_ids.insert(id) { + continue; + } + } else { + continue; + } + + for fdinfo_line in fdinfo_lines { + let Some(fdinfo_separator_index) = fdinfo_line.find(':') else { + continue; + }; + + let (fdinfo_keyword, mut fdinfo_value) = + fdinfo_line.split_at(fdinfo_separator_index); + fdinfo_value = &fdinfo_value[1..]; + + fdinfo_value = fdinfo_value.trim(); + if let Some(fdinfo_value_space_index) = fdinfo_value.find(' ') { + fdinfo_value = &fdinfo_value[..fdinfo_value_space_index]; + }; + + let Ok(fdinfo_value_num) = fdinfo_value.parse::() else { + continue; + }; + + match fdinfo_keyword { + "drm-engine-gfx" => usage.gfx_usage += fdinfo_value_num, + "drm-engine-dma" => usage.dma_usage += fdinfo_value_num, + "drm-engine-dec" => usage.dec_usage += fdinfo_value_num, + "drm-engine-enc" => usage.enc_usage += fdinfo_value_num, + "drm-engine-enc_1" => usage.uvd_usage += fdinfo_value_num, + "drm-engine-jpeg" => usage.vcn_usage += fdinfo_value_num, + "drm-engine-vpe" => usage.vpe_usage += fdinfo_value_num, + "drm-engine-compute" => usage.compute_usage += fdinfo_value_num, + "drm-memory-vram" => usage.vram_usage += fdinfo_value_num << 10, // KiB -> B + _ => {} + }; + } + } + + if usage != Default::default() { + fdinfo.insert(pid, usage); + } + } + + Some(fdinfo) +} + +pub fn get_amd_vecs(widgets_to_harvest: &UsedWidgets, prev_time: Instant) -> Option { + let device_path_list = get_amd_devs()?; + let interval = Instant::now().duration_since(prev_time); + let num_gpu = device_path_list.len(); + let mut mem_vec = Vec::with_capacity(num_gpu); + let mut proc_vec = Vec::with_capacity(num_gpu); + let mut total_mem = 0; + + for device_path in device_path_list { + let device_name = get_amd_name(&device_path) + .unwrap_or(amd_gpu_marketing::AMDGPU_DEFAULT_NAME.to_string()); + + if let Some(mem) = get_amd_vram(&device_path) { + if widgets_to_harvest.use_mem { + if let Some(total_bytes) = NonZeroU64::new(mem.total) { + mem_vec.push(( + device_name.clone(), + MemData { + total_bytes, + used_bytes: mem.used, + }, + )); + } + } + + total_mem += mem.total + } + + if widgets_to_harvest.use_proc { + if let Some(procs) = get_amd_fdinfo(&device_path) { + let mut proc_info = PROC_DATA.lock().unwrap(); + let _ = proc_info.try_insert(device_path.clone(), HashMap::new()); + let prev_fdinfo = proc_info.get_mut(&device_path).unwrap(); + + let mut procs_map = HashMap::new(); + for (proc_pid, proc_usage) in procs { + if let Some(prev_usage) = prev_fdinfo.get_mut(&proc_pid) { + // calculate deltas + let gfx_usage = + diff_usage(prev_usage.gfx_usage, proc_usage.gfx_usage, &interval); + let dma_usage = + diff_usage(prev_usage.dma_usage, proc_usage.dma_usage, &interval); + let enc_usage = + diff_usage(prev_usage.enc_usage, proc_usage.enc_usage, &interval); + let dec_usage = + diff_usage(prev_usage.dec_usage, proc_usage.dec_usage, &interval); + let uvd_usage = + diff_usage(prev_usage.uvd_usage, proc_usage.uvd_usage, &interval); + let vcn_usage = + diff_usage(prev_usage.vcn_usage, proc_usage.vcn_usage, &interval); + let vpe_usage = + diff_usage(prev_usage.vpe_usage, proc_usage.vpe_usage, &interval); + + // combined usage + let gpu_util_wide = gfx_usage + + dma_usage + + enc_usage + + dec_usage + + uvd_usage + + vcn_usage + + vpe_usage; + + let gpu_util: u32 = gpu_util_wide.try_into().unwrap_or(0); + + if gpu_util > 0 || proc_usage.vram_usage > 0 { + procs_map.insert(proc_pid, (proc_usage.vram_usage, gpu_util)); + } + + *prev_usage = proc_usage; + } else { + prev_fdinfo.insert(proc_pid, proc_usage); + } + } + + if !procs_map.is_empty() { + proc_vec.push(procs_map); + } + } + } + } + + Some(AmdGpuData { + memory: (!mem_vec.is_empty()).then_some(mem_vec), + procs: (!proc_vec.is_empty()).then_some((total_mem, proc_vec)), + }) +} diff --git a/src/collection/amd/amd_gpu_marketing.rs b/src/collection/amd/amd_gpu_marketing.rs new file mode 100644 index 000000000..fc06f0def --- /dev/null +++ b/src/collection/amd/amd_gpu_marketing.rs @@ -0,0 +1,667 @@ +// from https://github.com/GPUOpen-Tools/device_info/blob/master/DeviceInfo.cpp + +pub const AMDGPU_DEFAULT_NAME: &str = "AMD Radeon Graphics"; + +pub const AMD_GPU_MARKETING_NAME: &[(u32, u32, &str)] = &[ + (0x6798, 0x00, "AMD Radeon R9 200 / HD 7900"), + (0x6799, 0x00, "AMD Radeon HD 7900"), + (0x679A, 0x00, "AMD Radeon HD 7900"), + (0x679B, 0x00, "AMD Radeon HD 7900"), + (0x679E, 0x00, "AMD Radeon HD 7800"), + (0x6780, 0x00, "AMD FirePro W9000"), + (0x6784, 0x00, "ATI FirePro V"), + (0x6788, 0x00, "ATI FirePro V"), + (0x678A, 0x00, "AMD FirePro W8000"), + (0x6818, 0x00, "AMD Radeon HD 7800"), + (0x6819, 0x00, "AMD Radeon HD 7800"), + (0x6808, 0x00, "AMD FirePro W7000"), + (0x6809, 0x00, "ATI FirePro W5000"), + (0x684C, 0x00, "ATI FirePro V"), + (0x6800, 0x00, "AMD Radeon HD 7970M"), + (0x6801, 0x00, "AMD Radeon HD8970M"), + (0x6806, 0x00, "AMD Radeon R9 M290X"), + (0x6810, 0x00, "AMD Radeon R9 200"), + (0x6810, 0x81, "AMD Radeon R9 370"), + (0x6811, 0x00, "AMD Radeon R9 200"), + (0x6811, 0x81, "AMD Radeon R7 370"), + (0x6820, 0x00, "AMD Radeon R9 M275X"), + (0x6820, 0x81, "AMD Radeon R9 M375"), + (0x6820, 0x83, "AMD Radeon R9 M375X"), + (0x6821, 0x00, "AMD Radeon R9 M200X"), + (0x6821, 0x83, "AMD Radeon R9 M370X"), + (0x6821, 0x87, "AMD Radeon R7 M380"), + (0x6822, 0x00, "AMD Radeon E8860"), + (0x6823, 0x00, "AMD Radeon R9 M200X"), + (0x6825, 0x00, "AMD Radeon HD 7800M"), + (0x6826, 0x00, "AMD Radeon HD 7700M"), + (0x6827, 0x00, "AMD Radeon HD 7800M"), + (0x682B, 0x00, "AMD Radeon HD 8800M"), + (0x682B, 0x87, "AMD Radeon R9 M360"), + (0x682D, 0x00, "AMD Radeon HD 7700M"), + (0x682F, 0x00, "AMD Radeon HD 7700M"), + (0x6828, 0x00, "AMD FirePro W600"), + (0x682C, 0x00, "AMD FirePro W4100"), + (0x6830, 0x00, "AMD Radeon 7800M"), + (0x6831, 0x00, "AMD Radeon 7700M"), + (0x6835, 0x00, "AMD Radeon R7 Series / HD 9000"), + (0x6837, 0x00, "AMD Radeon HD 7700"), + (0x683D, 0x00, "AMD Radeon HD 7700"), + (0x683F, 0x00, "AMD Radeon HD 7700"), + (0x6608, 0x00, "AMD FirePro W2100"), + (0x6610, 0x00, "AMD Radeon R7 200"), + (0x6610, 0x81, "AMD Radeon R7 350"), + (0x6610, 0x83, "AMD Radeon R5 340"), + (0x6610, 0x87, "AMD Radeon R7 200"), + (0x6611, 0x00, "AMD Radeon R7 200"), + (0x6611, 0x87, "AMD Radeon R7 200"), + (0x6613, 0x00, "AMD Radeon R7 200"), + (0x6617, 0x00, "AMD Radeon R7 240"), + (0x6617, 0x87, "AMD Radeon R7 200"), + (0x6617, 0xC7, "AMD Radeon R7 240"), + (0x6600, 0x00, "AMD Radeon HD 8600/8700M"), + (0x6600, 0x81, "AMD Radeon R7 M370"), + (0x6601, 0x00, "AMD Radeon HD 8500M/8700M"), + (0x6604, 0x00, "AMD Radeon R7 M265"), + (0x6604, 0x81, "AMD Radeon R7 M350"), + (0x6605, 0x00, "AMD Radeon R7 M260"), + (0x6605, 0x81, "AMD Radeon R7 M340"), + (0x6606, 0x00, "AMD Radeon HD 8790M"), + (0x6607, 0x00, "AMD Radeon R5 M240"), + (0x6660, 0x00, "AMD Radeon HD 8600M"), + (0x6660, 0x81, "AMD Radeon R5 M335"), + (0x6660, 0x83, "AMD Radeon R5 M330"), + (0x6663, 0x00, "AMD Radeon HD 8500M"), + (0x6663, 0x83, "AMD Radeon R5 M320"), + (0x6664, 0x00, "AMD Radeon R5 M200"), + (0x6665, 0x00, "AMD Radeon R5 M230"), + (0x6665, 0x83, "AMD Radeon R5 M320"), + (0x6665, 0xC3, "AMD Radeon R5 M435"), + (0x6666, 0x00, "AMD Radeon R5 M200"), + (0x6667, 0x00, "AMD Radeon R5 M200"), + (0x666F, 0x00, "AMD Radeon HD 8500M"), + (0x6649, 0x00, "AMD FirePro W5100"), + (0x6658, 0x00, "AMD Radeon R7 200"), + (0x665C, 0x00, "AMD Radeon HD 7700"), + (0x665D, 0x00, "AMD Radeon R7 200"), + (0x665F, 0x81, "AMD Radeon R7 360"), + (0x665F, 0x81, "AMD Radeon R7 360"), + (0x6640, 0x00, "AMD Radeon HD 8950"), + (0x6640, 0x80, "AMD Radeon R9 M380"), + (0x6646, 0x00, "AMD Radeon R9 M280X"), + (0x6646, 0x80, "AMD Radeon R9 M385"), + (0x6647, 0x00, "AMD Radeon R9 M200X"), + (0x6647, 0x80, "AMD Radeon R9 M380"), + (0x67A0, 0x00, "AMD FirePro W9100"), + (0x67A1, 0x00, "AMD FirePro W8100"), + (0x67B0, 0x00, "AMD Radeon R9 200"), + (0x67B0, 0x80, "AMD Radeon R9 390"), + (0x67B1, 0x00, "AMD Radeon R9 200"), + (0x67B1, 0x80, "AMD Radeon R9 390"), + (0x67B9, 0x00, "AMD Radeon R9 200"), + (0x1309, 0x00, "AMD Radeon R7"), + (0x130A, 0x00, "AMD Radeon R6"), + (0x130C, 0x00, "AMD Radeon R7"), + (0x130D, 0x00, "AMD Radeon R6"), + (0x130E, 0x00, "AMD Radeon R5"), + (0x130F, 0x00, "AMD Radeon R7"), + (0x130F, 0xD4, "AMD Radeon R7"), + (0x130F, 0xD5, "AMD Radeon R7"), + (0x130F, 0xD6, "AMD Radeon R7"), + (0x130F, 0xD7, "AMD Radeon R7"), + (0x1313, 0x00, "AMD Radeon R7"), + (0x1313, 0xD4, "AMD Radeon R7"), + (0x1313, 0xD5, "AMD Radeon R7"), + (0x1313, 0xD6, "AMD Radeon R7"), + (0x1315, 0x00, "AMD Radeon R5"), + (0x1315, 0xD4, "AMD Radeon R5"), + (0x1315, 0xD5, "AMD Radeon R5"), + (0x1315, 0xD6, "AMD Radeon R5"), + (0x1315, 0xD7, "AMD Radeon R5"), + (0x1318, 0x00, "AMD Radeon R5"), + (0x131C, 0x00, "AMD Radeon R7"), + (0x131D, 0x00, "AMD Radeon R6"), + (0x130B, 0x00, "AMD Radeon R4"), + (0x1316, 0x00, "AMD Radeon R5"), + (0x131B, 0x00, "AMD Radeon R4"), + (0x9830, 0x00, "AMD Radeon HD 8400 / R3"), + (0x9831, 0x00, "AMD Radeon HD 8400E"), + (0x9832, 0x00, "AMD Radeon HD 8330"), + (0x9833, 0x00, "AMD Radeon HD 8330E"), + (0x9834, 0x00, "AMD Radeon HD 8210"), + (0x9835, 0x00, "AMD Radeon HD 8210E"), + (0x9836, 0x00, "AMD Radeon HD 8200 / R3"), + (0x9837, 0x00, "AMD Radeon HD 8280E"), + (0x9838, 0x00, "AMD Radeon HD 8200 / R3"), + (0x9839, 0x00, "AMD Radeon HD 8180"), + (0x983D, 0x00, "AMD Radeon HD 8250"), + (0x9850, 0x00, "AMD Radeon R3"), + (0x9850, 0x03, "AMD Radeon R3"), + (0x9850, 0x40, "AMD Radeon R2"), + (0x9850, 0x45, "AMD Radeon R3"), + (0x9851, 0x00, "AMD Radeon R4"), + (0x9851, 0x01, "AMD Radeon R5E"), + (0x9851, 0x05, "AMD Radeon R5"), + (0x9851, 0x06, "AMD Radeon R5E"), + (0x9851, 0x40, "AMD Radeon R4"), + (0x9851, 0x45, "AMD Radeon R5"), + (0x9852, 0x00, "AMD Radeon R2"), + (0x9852, 0x40, "AMD Radeon E1"), + (0x9853, 0x00, "AMD Radeon R2"), + (0x9853, 0x01, "AMD Radeon R4E"), + (0x9853, 0x03, "AMD Radeon R2"), + (0x9853, 0x05, "AMD Radeon R1E"), + (0x9853, 0x06, "AMD Radeon R1E"), + (0x9853, 0x40, "AMD Radeon R2"), + (0x9853, 0x07, "AMD Radeon R1E"), + (0x9853, 0x08, "AMD Radeon R1E"), + (0x9854, 0x00, "AMD Radeon R3"), + (0x9854, 0x01, "AMD Radeon R3E"), + (0x9854, 0x02, "AMD Radeon R3"), + (0x9854, 0x05, "AMD Radeon R2"), + (0x9854, 0x06, "AMD Radeon R4"), + (0x9854, 0x07, "AMD Radeon R3"), + (0x9855, 0x02, "AMD Radeon R6"), + (0x9855, 0x05, "AMD Radeon R4"), + (0x9856, 0x07, "AMD Radeon R1E"), + (0x9856, 0x00, "AMD Radeon R2"), + (0x9856, 0x01, "AMD Radeon R2E"), + (0x9856, 0x02, "AMD Radeon R2"), + (0x9856, 0x05, "AMD Radeon R1E"), + (0x9856, 0x06, "AMD Radeon R2"), + (0x9856, 0x07, "AMD Radeon R1E"), + (0x9856, 0x08, "AMD Radeon R1E"), + (0x9856, 0x13, "AMD Radeon R1E"), + (0x6900, 0x00, "AMD Radeon R7 M260"), + (0x6900, 0x81, "AMD Radeon R7 M360"), + (0x6900, 0x83, "AMD Radeon R7 M340"), + (0x6900, 0xC1, "AMD Radeon R5 M465"), + (0x6900, 0xC3, "AMD Radeon R5 M445"), + (0x6900, 0xD1, "AMD Radeon 530"), + (0x6900, 0xD3, "AMD Radeon 530"), + (0x6901, 0x00, "AMD Radeon R5 M255"), + (0x6902, 0x00, "AMD Radeon"), + (0x6907, 0x00, "AMD Radeon R5 M255"), + (0x6907, 0x87, "AMD Radeon R5 M315"), + (0x6920, 0x00, "AMD Radeon R9 M395X"), + (0x6920, 0x01, "AMD Radeon R9 M390X"), + (0x6921, 0x00, "AMD Radeon R9 M390X"), + (0x6929, 0x00, "AMD FirePro S7150"), + (0x6929, 0x01, "AMD FirePro S7100X"), + (0x692B, 0x00, "AMD FirePro W7100"), + (0x692F, 0x00, "AMD MxGPU"), + (0x692F, 0x01, "AMD MxGPU"), + (0x6930, 0xF0, "AMD MxGPU"), + (0x6938, 0x00, "AMD Radeon R9 200"), + (0x6938, 0xF1, "AMD Radeon R9 380"), + (0x6938, 0xF0, "AMD Radeon R9 200"), + (0x6939, 0x00, "AMD Radeon R9 200"), + (0x6939, 0xF0, "AMD Radeon R9 200"), + (0x6939, 0xF1, "AMD Radeon R9 380"), + (0x9874, 0xC4, "AMD Radeon R7"), + (0x9874, 0xC5, "AMD Radeon R6"), + (0x9874, 0xC6, "AMD Radeon R6"), + (0x9874, 0xC7, "AMD Radeon R5"), + (0x9874, 0x81, "AMD Radeon R6"), + (0x9874, 0x84, "AMD Radeon R7"), + (0x9874, 0x85, "AMD Radeon R6"), + (0x9874, 0x87, "AMD Radeon R5"), + (0x9874, 0x88, "AMD Radeon R7E"), + (0x9874, 0x89, "AMD Radeon R6E"), + (0x9874, 0xC8, "AMD Radeon R7"), + (0x9874, 0xC9, "AMD Radeon R7"), + (0x9874, 0xCA, "AMD Radeon R5"), + (0x9874, 0xCB, "AMD Radeon R5"), + (0x9874, 0xCC, "AMD Radeon R7"), + (0x9874, 0xCD, "AMD Radeon R7"), + (0x9874, 0xCE, "AMD Radeon R5"), + (0x9874, 0xE1, "AMD Radeon R7"), + (0x9874, 0xE2, "AMD Radeon R7"), + (0x9874, 0xE3, "AMD Radeon R7"), + (0x9874, 0xE4, "AMD Radeon R7"), + (0x9874, 0xE5, "AMD Radeon R5"), + (0x9874, 0xE6, "AMD Radeon R5"), + (0x7300, 0xC1, "AMD FirePro S9300 x2"), + (0x7300, 0xC8, "AMD Radeon R9 Fury"), + (0x7300, 0xC9, "AMD Radeon Pro Duo"), + (0x7300, 0xCA, "AMD Radeon R9 Fury"), + (0x7300, 0xCB, "AMD Radeon R9 Fury"), + (0x730F, 0xC9, "AMD MxGPU"), + (0x98E4, 0x80, "AMD Radeon R5E"), + (0x98E4, 0x81, "AMD Radeon R4E"), + (0x98E4, 0x83, "AMD Radeon R2E"), + (0x98E4, 0x84, "AMD Radeon R2E"), + (0x98E4, 0x86, "AMD Radeon R1E"), + (0x98E4, 0xC0, "AMD Radeon R4"), + (0x98E4, 0xC1, "AMD Radeon R5"), + (0x98E4, 0xC2, "AMD Radeon R4"), + (0x98E4, 0xC4, "AMD Radeon R5"), + (0x98E4, 0xC6, "AMD Radeon R5"), + (0x98E4, 0xC8, "AMD Radeon R4"), + (0x98E4, 0xC9, "AMD Radeon R4"), + (0x98E4, 0xCA, "AMD Radeon R5"), + (0x98E4, 0xD0, "AMD Radeon R2"), + (0x98E4, 0xD1, "AMD Radeon R2"), + (0x98E4, 0xD2, "AMD Radeon R2"), + (0x98E4, 0xD4, "AMD Radeon R2"), + (0x98E4, 0xD9, "AMD Radeon R5"), + (0x98E4, 0xDA, "AMD Radeon R5"), + (0x98E4, 0xDB, "AMD Radeon R3"), + (0x98E4, 0xE1, "AMD Radeon R3"), + (0x98E4, 0xE2, "AMD Radeon R3"), + (0x98E4, 0xE9, "AMD Radeon R4"), + (0x98E4, 0xEA, "AMD Radeon R4"), + (0x98E4, 0xEB, "AMD Radeon R4"), + (0x98E4, 0xEB, "AMD Radeon R3"), + (0x67C0, 0x00, "AMD Radeon Pro WX 7100"), + (0x67C0, 0x80, "AMD Radeon E9550"), + (0x67C2, 0x01, "AMD Radeon Pro V7350x2"), + (0x67C2, 0x02, "AMD Radeon Pro V7300X"), + (0x67C4, 0x00, "AMD Radeon Pro WX 7100"), + (0x67C4, 0x80, "AMD Radeon Embedded E9560"), + (0x67C7, 0x00, "AMD Radeon Pro WX 5100"), + (0x67C7, 0x80, "AMD Radeon Embedded E9390"), + (0x67D0, 0x01, "AMD Radeon Pro V7350x2"), + (0x67FF, 0xE3, "AMD Radeon E9550"), + (0x67FF, 0xF3, "AMD Radeon Pro E9565"), + (0x67FF, 0xF7, "AMD Radeon Pro WX 5100"), + (0x67D0, 0x02, "AMD Radeon Pro V7300X"), + (0x67DF, 0xC4, "AMD Radeon RX 480"), + (0x67DF, 0xC5, "AMD Radeon RX 470"), + (0x67DF, 0xC7, "AMD Radeon RX 480"), + (0x67DF, 0xCF, "AMD Radeon RX 470"), + (0x67DF, 0xFF, "AMD Radeon RX 470"), + (0x67FF, 0xE7, "AMD Radeon Embedded E9390"), + (0x67DF, 0xC0, "AMD Radeon Pro 580X"), + (0x67DF, 0xC1, "AMD Radeon RX 580"), + (0x67DF, 0xC2, "AMD Radeon RX 570"), + (0x67DF, 0xC3, "AMD Radeon RX 580"), + (0x67DF, 0xC6, "AMD Radeon RX 570"), + (0x67DF, 0xC7, "AMD Radeon RX 480"), + (0x67DF, 0xCF, "AMD Radeon RX 470"), + (0x67DF, 0xD7, "AMD Radeon RX 470"), + (0x67DF, 0xE0, "AMD Radeon RX 470"), + (0x67DF, 0xE1, "AMD Radeon RX 590"), + (0x67DF, 0xE3, "AMD Radeon RX"), + (0x67DF, 0xE7, "AMD Radeon RX 580"), + (0x67DF, 0xEB, "AMD Radeon Pro 580X"), + (0x67DF, 0xEF, "AMD Radeon RX 570"), + (0x67DF, 0xF7, "AMD P30PH"), + (0x67DF, 0xFF, "AMD Radeon RX 470"), + (0x6FDF, 0xEF, "AMD Radeon RX 580 2048SP"), + (0x67E0, 0x00, "AMD Radeon Pro WX"), + (0x67E3, 0x00, "AMD Radeon Pro WX 4100"), + (0x67E8, 0x00, "AMD Radeon Pro WX"), + (0x67E8, 0x01, "AMD Radeon Pro WX"), + (0x67E8, 0x80, "AMD Radeon E9260"), + (0x67EB, 0x00, "AMD Radeon Pro V5300X"), + (0x67EF, 0xC0, "AMD Radeon RX 560"), + (0x67EF, 0xC1, "AMD Radeon RX 560"), + (0x67EF, 0xC5, "AMD Radeon RX 560"), + (0x67EF, 0xC7, "AMD Radeon 550"), + (0x67EF, 0xCF, "AMD Radeon RX 460"), + (0x67EF, 0xEF, "AMD Radeon 550"), + (0x67FF, 0xC0, "AMD Radeon Pro 465"), + (0x67FF, 0xC1, "AMD Radeon RX 560"), + (0x67EF, 0xC2, "AMD Radeon Pro"), + (0x67EF, 0xE3, "AMD Radeon Pro"), + (0x67EF, 0xE5, "AMD Radeon RX 560"), + (0x67EF, 0xE7, "AMD Radeon RX 560"), + (0x67EF, 0xE0, "AMD Radeon RX 560"), + (0x67EF, 0xFF, "AMD Radeon RX 460"), + (0x67FF, 0xCF, "AMD Radeon RX 560"), + (0x67FF, 0xEF, "AMD Radeon RX 560"), + (0x67FF, 0xFF, "AMD Radeon RX550/550"), + (0x6980, 0x00, "AMD Radeon Pro WX 3100"), + (0x6981, 0x00, "AMD Radeon Pro WX 3200"), + (0x6981, 0x01, "AMD Radeon Pro WX 3200"), + (0x6981, 0x10, "AMD Radeon Pro WX 3200"), + (0x6985, 0x00, "AMD Radeon Pro WX 3100"), + (0x6986, 0x00, "AMD Radeon Pro WX 2100"), + (0x6987, 0x80, "AMD Embedded Radeon E9171"), + (0x6987, 0xC0, "AMD Radeon 550X"), + (0x6987, 0xC1, "AMD Radeon RX 640"), + (0x6987, 0xC3, "AMD Radeon 540X"), + (0x6987, 0xC7, "AMD Radeon 540"), + (0x6995, 0x00, "AMD Radeon Pro WX 2100"), + (0x6997, 0x00, "AMD Radeon Pro WX 2100"), + (0x699F, 0x81, "AMD Embedded Radeon E9170"), + (0x699F, 0xC0, "AMD Radeon 500"), + (0x699F, 0xC1, "AMD Radeon 540"), + (0x699F, 0xC3, "AMD Radeon 500"), + (0x699F, 0xC7, "AMD Radeon RX550/550"), + (0x699F, 0xC9, "AMD Radeon 540"), + (0x694C, 0xC0, "AMD Radeon RX Vega M GH"), + (0x694E, 0xC0, "AMD Radeon RX Vega M GL"), + (0x6860, 0x00, "AMD Radeon Instinct MI25"), + (0x6860, 0x01, "AMD Radeon Instinct MI25"), + (0x6860, 0x02, "AMD Radeon Instinct MI25"), + (0x6860, 0x03, "AMD Radeon Pro V340"), + (0x6860, 0x04, "AMD Radeon Instinct MI25x2"), + (0x6860, 0x06, "AMD Radeon Instinct MI25"), + (0x6860, 0x07, "AMD Radeon Pro V320"), + (0x6861, 0x00, "AMD Radeon Pro WX 9100"), + (0x6862, 0x00, "AMD Radeon Pro SSG"), + (0x6863, 0x00, "AMD Radeon Vega Frontier Edition"), + (0x6864, 0x03, "AMD Radeon Pro V340"), + (0x6864, 0x04, "AMD Instinct MI25x2"), + (0x6864, 0x05, "AMD Radeon Pro V340"), + (0x6867, 0x00, "AMD Radeon Pro Vega 56"), + (0x6868, 0x00, "AMD Radeon Pro WX 8200"), + (0x686C, 0x00, "AMD Radeon Instinct MI25 MxGPU"), + (0x686C, 0x01, "AMD Radeon Instinct MI25 MxGPU"), + (0x686C, 0x02, "AMD Radeon Instinct MI25 MxGPU"), + (0x686C, 0x03, "AMD Radeon Pro V340 MxGPU"), + (0x686C, 0x04, "AMD Radeon Instinct MI25x2 MxGPU"), + (0x686C, 0x05, "AMD Radeon Pro V340 MxGPU"), + (0x686C, 0x06, "AMD Radeon Instinct MI25 MxGPU"), + (0x687F, 0x01, "AMD Radeon RX Vega"), + (0x687F, 0xC0, "AMD Radeon RX Vega"), + (0x687F, 0xC1, "AMD Radeon RX Vega"), + (0x687F, 0xC3, "AMD Radeon RX Vega"), + (0x687F, 0xC7, "AMD Radeon RX Vega"), + (0x15DD, 0x00, "AMD 15DD"), + (0x15DD, 0x81, "AMD Radeon Vega 11"), + (0x15DD, 0x82, "AMD Radeon Vega 8"), + (0x15DD, 0x83, "AMD Radeon Vega 8"), + (0x15DD, 0x84, "AMD Radeon Vega 6"), + (0x15DD, 0x85, "AMD Radeon Vega 3"), + (0x15DD, 0x86, "AMD Radeon Vega 11"), + (0x15DD, 0x87, "AMD 15DD"), + (0x15DD, 0x88, "AMD Radeon Vega 8"), + (0x15DD, 0xC1, "AMD Radeon RX Vega 11"), + (0x15DD, 0xC2, "AMD Radeon Vega 8"), + (0x15DD, 0xC3, "AMD Radeon RX Vega 10"), + (0x15DD, 0xC4, "AMD Radeon Vega 8"), + (0x15DD, 0xC5, "AMD Radeon Vega 3"), + (0x15DD, 0xC6, "AMD Radeon RX Vega 11"), + (0x15DD, 0xC7, "AMD 15DD"), + (0x15DD, 0xC8, "AMD Radeon Vega 8"), + (0x15DD, 0xC9, "AMD Radeon RX Vega 11"), + (0x15DD, 0xCA, "AMD Radeon Vega 8"), + (0x15DD, 0xCB, "AMD Radeon Vega 3"), + (0x15DD, 0xCC, "AMD Radeon Vega 6"), + (0x15DD, 0xCD, "AMD 15DD"), + (0x15DD, 0xCE, "AMD Radeon Vega 3"), + (0x15DD, 0xCF, "AMD Radeon Vega 3"), + (0x15DD, 0xD0, "AMD Radeon Vega 10"), + (0x15DD, 0xD1, "AMD Radeon Vega 8"), + (0x15DD, 0xD2, "AMD 15DD"), + (0x15DD, 0xD3, "AMD Radeon Vega 11"), + (0x15DD, 0xD4, "AMD 15DD"), + (0x15DD, 0xD5, "AMD Radeon Vega 8"), + (0x15DD, 0xD6, "AMD Radeon Vega 11"), + (0x15DD, 0xD7, "AMD Radeon Vega 8"), + (0x15DD, 0xD8, "AMD Radeon Vega 3"), + (0x15DD, 0xD9, "AMD Radeon Vega 6"), + (0x15DD, 0xE1, "AMD Radeon Vega 3"), + (0x15DD, 0xE2, "AMD Radeon Vega 3"), + (0x15D8, 0x00, "AMD Radeon RX Vega 8 WS"), + (0x15D8, 0x91, "AMD Radeon Vega 3"), + (0x15D8, 0x92, "AMD Radeon Vega 3"), + (0x15D8, 0x93, "AMD Radeon Vega 1"), + (0x15D8, 0xA1, "AMD Radeon RX Vega 10"), + (0x15D8, 0xA2, "AMD Radeon Vega 8"), + (0x15D8, 0xA3, "AMD Radeon Vega 6"), + (0x15D8, 0xA4, "AMD Radeon Vega 3"), + (0x15D8, 0xB1, "AMD Radeon Vega 10"), + (0x15D8, 0xB2, "AMD Radeon Vega 8"), + (0x15D8, 0xB3, "AMD Radeon Vega 6"), + (0x15D8, 0xB4, "AMD Radeon Vega 3"), + (0x15D8, 0xC1, "AMD Radeon RX Vega 10"), + (0x15D8, 0xC2, "AMD Radeon Vega 8"), + (0x15D8, 0xC3, "AMD Radeon Vega 6"), + (0x15D8, 0xC4, "AMD Radeon Vega 3"), + (0x15D8, 0xC5, "AMD Radeon Vega 3"), + (0x15D8, 0xC8, "AMD Radeon RX Vega 11"), + (0x15D8, 0xC9, "AMD Radeon Vega 8"), + (0x15D8, 0xCA, "AMD Radeon RX Vega 11"), + (0x15D8, 0xCB, "AMD Radeon Vega 8"), + (0x15D8, 0xCC, "AMD Radeon Vega 3"), + (0x15D8, 0xCE, "AMD Radeon Vega 3"), + (0x15D8, 0xCF, "AMD Radeon Vega 3"), + (0x15D8, 0xD1, "AMD Radeon Vega 10"), + (0x15D8, 0xD2, "AMD Radeon Vega 8"), + (0x15D8, 0xD3, "AMD Radeon Vega 6"), + (0x15D8, 0xD4, "AMD Radeon Vega 3"), + (0x15D8, 0xD8, "AMD Radeon Vega 11"), + (0x15D8, 0xD9, "AMD Radeon Vega 8"), + (0x15D8, 0xDA, "AMD Radeon Vega 11"), + (0x15D8, 0xDB, "AMD Radeon Vega 3"), + (0x15D8, 0xDC, "AMD Radeon Vega 3"), + (0x15D8, 0xDD, "AMD Radeon Vega 3"), + (0x15D8, 0xDE, "AMD Radeon Vega 3"), + (0x15D8, 0xDF, "AMD Radeon Vega 3"), + (0x15D8, 0xE1, "AMD Radeon RX Vega 11"), + (0x15D8, 0xE2, "AMD Radeon Vega 9"), + (0x15D8, 0xE3, "AMD Radeon Vega 3"), + (0x15D8, 0xE4, "AMD Radeon Vega 3"), + (0x69AF, 0xC0, "AMD Radeon Pro Vega 20"), + (0x69AF, 0xC7, "AMD Radeon Pro Vega 16"), + (0x69AF, 0xD7, "AMD Radeon RX Vega 16"), + (0x66AF, 0xC1, "AMD Radeon VII"), + (0x66A1, 0x06, "AMD Radeon Pro VII"), + (0x740C, 0x01, "AMD Instinct MI250X"), + (0x740F, 0x02, "AMD Instinct MI210"), + (0x74A1, 0x00, "AMD Instinct MI300X"), + (0x74A1, 0x01, "AMD Instinct MI300A"), + (0x7310, 0x00, "AMD Radeon Pro W5700X"), + (0x7312, 0x00, "AMD Radeon Pro W5700"), + (0x7319, 0x40, "AMD Radeon Pro 5700 XT"), + (0x731E, 0xC7, "AMD Radeon RX 5700B"), + (0x731F, 0xC0, "AMD Radeon RX 5700 XT 50th Anniversary"), + (0x731F, 0xC1, "AMD Radeon RX 5700 XT"), + (0x731F, 0xC2, "AMD Radeon RX 5600M"), + (0x731F, 0xC3, "AMD Radeon RX 5700M"), + (0x731F, 0xC4, "AMD Radeon RX 5700"), + (0x731F, 0xC5, "AMD Radeon RX 5700 XT"), + (0x731F, 0xCA, "AMD Radeon RX 5600 XT"), + (0x731F, 0xCB, "AMD Radeon RX 5600"), + (0x7360, 0x41, "AMD Radeon Pro 5600M"), + (0x7360, 0xC3, "AMD Radeon Pro V520"), + (0x7362, 0xC3, "AMD Radeon Pro V520 MxGPU"), + (0x7340, 0x00, "AMD Radeon Pro W5500X"), + (0x7340, 0x41, "AMD Radeon Pro 5500 XT"), + (0x7340, 0x47, "AMD Radeon Pro 5300"), + (0x7340, 0xC1, "AMD Radeon RX 5500M"), + (0x7340, 0xC3, "AMD Radeon RX 5300M"), + (0x7340, 0xC5, "AMD Radeon RX 5500 XT"), + (0x7340, 0xC7, "AMD Radeon RX 5500"), + (0x7340, 0xCF, "AMD Radeon RX 5300"), + (0x7341, 0x00, "AMD Radeon Pro W5500"), + (0x7347, 0x00, "AMD Radeon Pro W5500M"), + (0x734F, 0x00, "AMD Radeon Pro W5300M"), + (0x73A5, 0xC0, "AMD Radeon RX 6950 XT"), + (0x73AF, 0xC0, "AMD Radeon RX 6900 XT"), + (0x73BF, 0xC0, "AMD Radeon RX 6900 XT"), + (0x73BF, 0xC1, "AMD Radeon RX 6800 XT"), + (0x73BF, 0xC3, "AMD Radeon RX 6800"), + (0x73A1, 0x00, "AMD Radeon Pro V620"), + (0x73A3, 0x00, "AMD Radeon Pro W6800"), + (0x73DF, 0xC0, "AMD Radeon RX 6750 XT"), + (0x73DF, 0xC1, "AMD Radeon RX 6700 XT"), + (0x73DF, 0xC5, "AMD Radeon RX 6700 XT"), + (0x73DF, 0xDF, "AMD Radeon RX 6700"), + (0x73DF, 0xC2, "AMD Radeon RX 6800M"), + (0x73DF, 0xC3, "AMD Radeon RX 6800M"), + (0x73DF, 0xCF, "AMD Radeon RX 6700M"), + (0x73DF, 0xFF, "AMD Radeon RX 6700"), + (0x73EF, 0xC0, "AMD Radeon RX 6800S"), + (0x73EF, 0xC1, "AMD Radeon RX 6650 XT"), + (0x73EF, 0xC2, "AMD Radeon RX 6700S"), + (0x73EF, 0xC3, "AMD Radeon RX 6650M"), + (0x73EF, 0xC4, "AMD Radeon RX 6650M XT"), + (0x73FF, 0xC1, "AMD Radeon RX 6600 XT"), + (0x73FF, 0xC7, "AMD Radeon RX 6600"), + (0x73FF, 0xC3, "AMD Radeon RX 6600M"), + (0x73FF, 0xCB, "AMD Radeon RX 6600S"), + (0x73E1, 0x00, "AMD Radeon Pro W6600M"), + (0x73E3, 0x00, "AMD Radeon Pro W6600"), + (0x7422, 0x00, "AMD Radeon Pro W6400"), + (0x743F, 0xC1, "AMD Radeon RX 6500 XT"), + (0x743F, 0xC7, "AMD Radeon RX 6400"), + (0x743F, 0xD7, "AMD Radeon RX 6400"), + (0x7421, 0x00, "AMD Radeon Pro W6500M"), + (0x7423, 0x00, "AMD Radeon Pro W6300M"), + (0x7423, 0x01, "AMD Radeon Pro W6300"), + (0x743F, 0xC3, "AMD Radeon RX 6500M"), + (0x743F, 0xCF, "AMD Radeon RX 6300M"), + (0x743F, 0xC8, "AMD Radeon RX 6550M"), + (0x743F, 0xCC, "AMD Radeon 6550S"), + (0x743F, 0xCE, "AMD Radeon RX 6450M"), + (0x743F, 0xD3, "AMD Radeon RX 6550M"), + (0x744C, 0xC8, "AMD Radeon RX 7900 XTX"), + (0x744C, 0xCC, "AMD Radeon RX 7900 XT"), + (0x7448, 0x00, "AMD Radeon Pro W7900"), + (0x745E, 0xCC, "AMD Radeon Pro W7800"), + (0x747E, 0xC8, "AMD Radeon RX 7800 XT"), + (0x747E, 0xFF, "AMD Radeon RX 7700 XT"), + (0x747E, 0xD8, "AMD Radeon RX 7800M"), + (0x7480, 0xC0, "AMD Radeon RX 7600 XT"), + (0x7480, 0xCF, "AMD Radeon RX 7600"), + (0x7480, 0xC1, "AMD Radeon RX 7700S"), + (0x7480, 0xC3, "AMD Radeon RX 7600S"), + (0x7480, 0xC7, "AMD Radeon RX 7600M XT"), + (0x7483, 0xCF, "AMD Radeon RX 7600M"), + (0x7480, 0x00, "AMD Radeon Pro W7600"), + (0x7489, 0x00, "AMD Radeon Pro W7500"), + (0x15BF, 0x00, "AMD Radeon 780M"), + (0x15BF, 0x01, "AMD Radeon 760M"), + (0x15BF, 0x02, "AMD Radeon 780M"), + (0x15BF, 0x03, "AMD Radeon 760M"), + (0x15BF, 0xC1, "AMD Radeon 780M"), + (0x15BF, 0xC2, "AMD Radeon 780M"), + (0x15BF, 0xC3, "AMD Radeon 760M"), + (0x15BF, 0xC4, "AMD Radeon 780M"), + (0x15BF, 0xC5, "AMD Radeon 740M"), + (0x15BF, 0xC6, "AMD Radeon 780M"), + (0x15BF, 0xC7, "AMD Radeon 780M"), + (0x15BF, 0xC8, "AMD Radeon 760M"), + (0x15BF, 0xC9, "AMD Radeon 780M"), + (0x15BF, 0xCA, "AMD Radeon 740M"), + (0x15BF, 0xCB, "AMD Radeon 760M"), + (0x15BF, 0xCC, "AMD Radeon 740M"), + (0x15BF, 0xCD, "AMD Radeon 760M"), + (0x15BF, 0xCF, "AMD Radeon 780M"), + (0x15BF, 0xD0, "AMD Radeon 780M"), + (0x15BF, 0xD1, "AMD Radeon 780M"), + (0x15BF, 0xD2, "AMD Radeon 780M"), + (0x15BF, 0xD3, "AMD Radeon 780M"), + (0x15BF, 0xD4, "AMD Radeon 780M"), + (0x15BF, 0xD5, "AMD Radeon 760M"), + (0x15BF, 0xD6, "AMD Radeon 760M"), + (0x15BF, 0xD7, "AMD Radeon 780M"), + (0x15BF, 0xD8, "AMD Radeon 740M"), + (0x15BF, 0xD9, "AMD Radeon 780M"), + (0x15BF, 0xDA, "AMD Radeon 780M"), + (0x15BF, 0xDB, "AMD Radeon 760M"), + (0x15BF, 0xDC, "AMD Radeon 760M"), + (0x15BF, 0xDD, "AMD Radeon 780M"), + (0x15BF, 0xDE, "AMD Radeon 740M"), + (0x15BF, 0xDF, "AMD Radeon 760M"), + (0x15BF, 0xF0, "AMD Radeon 760M"), + (0x1900, 0x01, "AMD Radeon 780M"), + (0x1900, 0x02, "AMD Radeon 760M"), + (0x1900, 0x03, "AMD Radeon 780M"), + (0x1900, 0x04, "AMD Radeon 760M"), + (0x1900, 0x05, "AMD Radeon 780M"), + (0x1900, 0x06, "AMD Radeon 780M"), + (0x1900, 0x07, "AMD Radeon 760M"), + (0x1900, 0xB0, "AMD Radeon 780M"), + (0x1900, 0xB1, "AMD Radeon 780M"), + (0x1900, 0xB2, "AMD Radeon 780M"), + (0x1900, 0xB3, "AMD Radeon 780M"), + (0x1900, 0xB4, "AMD Radeon 780M"), + (0x1900, 0xB5, "AMD Radeon 780M"), + (0x1900, 0xB6, "AMD Radeon 780M"), + (0x1900, 0xB7, "AMD Radeon 760M"), + (0x1900, 0xB8, "AMD Radeon 760M"), + (0x1900, 0xB9, "AMD Radeon 780M"), + (0x1900, 0xC0, "AMD Radeon 780M"), + (0x1900, 0xC1, "AMD Radeon 760M"), + (0x1900, 0xC2, "AMD Radeon 780M"), + (0x1900, 0xC3, "AMD Radeon 760M"), + (0x1900, 0xC4, "AMD Radeon 780M"), + (0x1900, 0xC5, "AMD Radeon 780M"), + (0x1900, 0xC6, "AMD Radeon 760M"), + (0x1900, 0xC7, "AMD Radeon 780M"), + (0x1900, 0xC8, "AMD Radeon 760M"), + (0x1900, 0xC9, "AMD Radeon 780M"), + (0x1900, 0xCA, "AMD Radeon 760M"), + (0x1900, 0xCB, "AMD Radeon 780M"), + (0x1900, 0xCC, "AMD Radeon 780M"), + (0x1900, 0xCD, "AMD Radeon 760M"), + (0x1900, 0xCE, "AMD Radeon 780M"), + (0x1900, 0xCF, "AMD Radeon 760M"), + (0x1900, 0xD0, "AMD Radeon 780M"), + (0x1900, 0xD1, "AMD Radeon 760M"), + (0x1900, 0xD2, "AMD Radeon 780M"), + (0x1900, 0xD3, "AMD Radeon 760M"), + (0x1900, 0xD4, "AMD Radeon 780M"), + (0x1900, 0xD5, "AMD Radeon 780M"), + (0x1900, 0xD6, "AMD Radeon 760M"), + (0x1900, 0xD7, "AMD Radeon 780M"), + (0x1900, 0xD8, "AMD Radeon 760M"), + (0x1900, 0xD9, "AMD Radeon 780M"), + (0x1900, 0xDA, "AMD Radeon 760M"), + (0x1900, 0xDB, "AMD Radeon 780M"), + (0x1900, 0xDC, "AMD Radeon 780M"), + (0x1900, 0xDD, "AMD Radeon 760M"), + (0x1900, 0xDE, "AMD Radeon 780M"), + (0x1900, 0xDF, "AMD Radeon 760M"), + (0x1900, 0xF0, "AMD Radeon 780M"), + (0x1900, 0xF1, "AMD Radeon 780M"), + (0x1900, 0xF2, "AMD Radeon 780M"), + (0x1901, 0xC8, "AMD Radeon 740M"), + (0x1901, 0xC9, "AMD Radeon 740M"), + (0x1901, 0xD5, "AMD Radeon 740M"), + (0x1901, 0xD6, "AMD Radeon 740M"), + (0x1901, 0xD7, "AMD Radeon 740M"), + (0x1901, 0xD8, "AMD Radeon 740M"), + (0x15C8, 0xC1, "AMD Radeon 740M"), + (0x15C8, 0xC2, "AMD Radeon 740M"), + (0x15C8, 0xC3, "AMD Radeon 740M"), + (0x15C8, 0xC4, "AMD Radeon 740M"), + (0x15C8, 0xD1, "AMD Radeon 740M"), + (0x15C8, 0xD2, "AMD Radeon 740M"), + (0x15C8, 0xD3, "AMD Radeon 740M"), + (0x15C8, 0xD4, "AMD Radeon 740M"), + (0x1901, 0xC1, "AMD Radeon 740M"), + (0x1901, 0xC2, "AMD Radeon 740M"), + (0x1901, 0xC3, "AMD Radeon 740M"), + (0x1901, 0xC6, "AMD Radeon 740M"), + (0x1901, 0xC7, "AMD Radeon 740M"), + (0x1901, 0xD1, "AMD Radeon 740M"), + (0x1901, 0xD2, "AMD Radeon 740M"), + (0x1901, 0xD3, "AMD Radeon 740M"), + (0x1901, 0xD4, "AMD Radeon 740M"), + (0x150E, 0xC1, "AMD Radeon 890M"), + (0x150E, 0xC4, "AMD Radeon 890M"), + (0x150E, 0xC5, "AMD Radeon 890M"), + (0x150E, 0xC6, "AMD Radeon 890M"), + (0x150E, 0xD1, "AMD Radeon 890M"), + (0x150E, 0xD2, "AMD Radeon 890M"), + (0x150E, 0xD3, "AMD Radeon 890M"), + (0x74A9, 0x00, "AMD Instinct MI300XHF"), + (0x73AE, 0x00, "AMD Radeon Pro V620 MxGPU"), + (0x73CE, 0xFF, "AMD Radeon V520 MxGPU"), + (0x7449, 0x00, "AMD Radeon Pro W7800 48GB"), + (0x744A, 0x00, "AMD Radeon Pro W7900"), + (0x7480, 0xC2, "AMD Radeon RX 7650 GRE"), + (0x7481, 0xC7, "AMD Radeon RX 7600"), + (0x1900, 0xBA, "AMD Radeon 780M"), + (0x1900, 0xBB, "AMD Radeon 780M"), + (0x1901, 0xCA, "AMD Radeon 740M"), + (0x1586, 0xC1, "AMD Radeon 8060S"), + (0x1586, 0xC2, "AMD Radeon 8050S"), + (0x1586, 0xC4, "AMD Radeon 8050S"), + (0x1586, 0xD1, "AMD Radeon 8060S"), + (0x1586, 0xD2, "AMD Radeon 8050S"), + (0x1586, 0xD4, "AMD Radeon 8050S"), + (0x1586, 0xD5, "AMD Radeon 8040S"), + (0x1114, 0xC2, "AMD Radeon 860M"), + (0x1114, 0xC3, "AMD Radeon 840M"), + (0x1114, 0xD2, "AMD Radeon 860M"), + (0x1114, 0xD3, "AMD Radeon 840M"), + (0x7550, 0xC0, "AMD Radeon RX 9070 XT"), + (0x7550, 0xC3, "AMD Radeon RX 9070"), +]; diff --git a/src/collection/batteries.rs b/src/collection/batteries.rs new file mode 100644 index 000000000..6a0ce7dcb --- /dev/null +++ b/src/collection/batteries.rs @@ -0,0 +1,101 @@ +//! Uses the battery crate. +//! +//! Covers battery usage for: +//! - Linux 2.6.39+ +//! - MacOS 10.10+ +//! - iOS +//! - Windows 7+ +//! - FreeBSD +//! - DragonFlyBSD +//! +//! For more information, refer to the [starship_battery](https://github.com/starship/rust-battery) repo/docs. + +use starship_battery::{ + Battery, Manager, State, + units::{power::watt, ratio::percent, time::second}, +}; + +/// Battery state. +#[derive(Debug, Clone)] +pub enum BatteryState { + Charging { + /// Time to full in seconds. + time_to_full: Option, + }, + Discharging { + /// Time to empty in seconds. + time_to_empty: Option, + }, + Empty, + Full, + Unknown, +} + +impl BatteryState { + /// Return the string representation. + pub fn as_str(&self) -> &'static str { + match self { + BatteryState::Charging { .. } => "Charging", + BatteryState::Discharging { .. } => "Discharging", + BatteryState::Empty => "Empty", + BatteryState::Full => "Full", + BatteryState::Unknown => "Unknown", + } + } +} + +#[derive(Debug, Clone)] +pub struct BatteryData { + /// Current charge percent. + pub charge_percent: f64, + /// Power consumption, in watts. + pub power_consumption: f64, + /// Reported battery health. + pub health_percent: f64, + /// The current battery "state" (e.g. is it full, charging, etc.). + pub state: BatteryState, +} + +impl BatteryData { + pub fn watt_consumption(&self) -> String { + format!("{:.2}W", self.power_consumption) + } + + pub fn health(&self) -> String { + format!("{:.2}%", self.health_percent) + } +} + +pub fn refresh_batteries(manager: &Manager, batteries: &mut [Battery]) -> Vec { + batteries + .iter_mut() + .filter_map(|battery| { + if manager.refresh(battery).is_ok() { + Some(BatteryData { + charge_percent: f64::from(battery.state_of_charge().get::()), + power_consumption: f64::from(battery.energy_rate().get::()), + health_percent: f64::from(battery.state_of_health().get::()), + state: match battery.state() { + State::Unknown => BatteryState::Unknown, + State::Charging => BatteryState::Charging { + time_to_full: { + let optional_time = battery.time_to_full(); + optional_time.map(|time| f64::from(time.get::()) as u32) + }, + }, + State::Discharging => BatteryState::Discharging { + time_to_empty: { + let optional_time = battery.time_to_empty(); + optional_time.map(|time| f64::from(time.get::()) as u32) + }, + }, + State::Empty => BatteryState::Empty, + State::Full => BatteryState::Full, + }, + }) + } else { + None + } + }) + .collect::>() +} diff --git a/src/data_collection/cpu.rs b/src/collection/cpu.rs similarity index 80% rename from src/data_collection/cpu.rs rename to src/collection/cpu.rs index 843df161c..7441c3bcd 100644 --- a/src/data_collection/cpu.rs +++ b/src/collection/cpu.rs @@ -14,10 +14,7 @@ pub enum CpuDataType { #[derive(Debug, Clone)] pub struct CpuData { pub data_type: CpuDataType, - pub cpu_usage: f64, + pub usage: f32, } pub type CpuHarvest = Vec; - -pub type PastCpuWork = f64; -pub type PastCpuTotal = f64; diff --git a/src/collection/cpu/sysinfo.rs b/src/collection/cpu/sysinfo.rs new file mode 100644 index 000000000..acf4e7527 --- /dev/null +++ b/src/collection/cpu/sysinfo.rs @@ -0,0 +1,40 @@ +//! CPU stats through sysinfo. +//! Supports FreeBSD. + +use sysinfo::System; + +use super::{CpuData, CpuDataType, CpuHarvest}; +use crate::collection::error::CollectionResult; + +pub fn get_cpu_data_list(sys: &System, show_average_cpu: bool) -> CollectionResult { + let mut cpus = vec![]; + + if show_average_cpu { + cpus.push(CpuData { + data_type: CpuDataType::Avg, + usage: sys.global_cpu_usage(), + }) + } + + cpus.extend( + sys.cpus() + .iter() + .enumerate() + .map(|(i, cpu)| CpuData { + data_type: CpuDataType::Cpu(i), + usage: cpu.cpu_usage(), + }) + .collect::>(), + ); + + Ok(cpus) +} + +#[cfg(target_family = "unix")] +pub(crate) fn get_load_avg() -> crate::collection::cpu::LoadAvgHarvest { + // The API for sysinfo apparently wants you to call it like this, rather than + // using a &System. + let sysinfo::LoadAvg { one, five, fifteen } = sysinfo::System::load_average(); + + [one as f32, five as f32, fifteen as f32] +} diff --git a/src/data_collection/disks.rs b/src/collection/disks.rs similarity index 86% rename from src/data_collection/disks.rs rename to src/collection/disks.rs index 9aba05791..e8748b1ce 100644 --- a/src/data_collection/disks.rs +++ b/src/collection/disks.rs @@ -103,26 +103,26 @@ pub fn keep_disk_entry( disk_name: &str, mount_point: &str, disk_filter: &Option, mount_filter: &Option, ) -> bool { match (disk_filter, mount_filter) { - (Some(d), Some(m)) => match (d.is_list_ignored, m.is_list_ignored) { + (Some(d), Some(m)) => match (d.ignore_matches(), m.ignore_matches()) { (true, true) => !(d.has_match(disk_name) || m.has_match(mount_point)), (true, false) => { if m.has_match(mount_point) { true } else { - d.keep_entry(disk_name) + d.should_keep(disk_name) } } (false, true) => { if d.has_match(disk_name) { true } else { - m.keep_entry(mount_point) + m.should_keep(mount_point) } } (false, false) => d.has_match(disk_name) || m.has_match(mount_point), }, - (Some(d), None) => d.keep_entry(disk_name), - (None, Some(m)) => m.keep_entry(mount_point), + (Some(d), None) => d.should_keep(disk_name), + (None, Some(m)) => m.should_keep(mount_point), (None, None) => true, } } @@ -158,25 +158,10 @@ mod test { #[test] fn test_keeping_disk_entry() { - let disk_ignore = Some(Filter { - is_list_ignored: true, - list: vec![Regex::new("nvme").unwrap()], - }); - - let disk_keep = Some(Filter { - is_list_ignored: false, - list: vec![Regex::new("nvme").unwrap()], - }); - - let mount_ignore = Some(Filter { - is_list_ignored: true, - list: vec![Regex::new("boot").unwrap()], - }); - - let mount_keep = Some(Filter { - is_list_ignored: false, - list: vec![Regex::new("boot").unwrap()], - }); + let disk_ignore = Some(Filter::new(true, vec![Regex::new("nvme").unwrap()])); + let disk_keep = Some(Filter::new(false, vec![Regex::new("nvme").unwrap()])); + let mount_ignore = Some(Filter::new(true, vec![Regex::new("boot").unwrap()])); + let mount_keep = Some(Filter::new(false, vec![Regex::new("boot").unwrap()])); assert_eq!(run_filter(&None, &None), vec![0, 1, 2, 3, 4]); diff --git a/src/data_collection/disks/freebsd.rs b/src/collection/disks/freebsd.rs similarity index 91% rename from src/data_collection/disks/freebsd.rs rename to src/collection/disks/freebsd.rs index a193e6b65..90f2c13d5 100644 --- a/src/data_collection/disks/freebsd.rs +++ b/src/collection/disks/freebsd.rs @@ -5,10 +5,8 @@ use std::io; use hashbrown::HashMap; use serde::Deserialize; -use super::{keep_disk_entry, DiskHarvest, IoHarvest}; -use crate::data_collection::{ - deserialize_xo, disks::IoData, error::CollectionResult, DataCollector, -}; +use super::{DiskHarvest, IoHarvest, keep_disk_entry}; +use crate::collection::{DataCollector, deserialize_xo, disks::IoData, error::CollectionResult}; #[derive(Deserialize, Debug, Default)] #[serde(rename_all = "kebab-case")] @@ -29,7 +27,6 @@ struct FileSystem { pub fn get_io_usage() -> CollectionResult { // TODO: Should this (and other I/O collectors) fail fast? In general, should // collection ever fail fast? - #[allow(unused_mut)] let mut io_harvest: HashMap> = get_disk_info().map(|storage_system_information| { storage_system_information @@ -41,7 +38,7 @@ pub fn get_io_usage() -> CollectionResult { #[cfg(feature = "zfs")] { - use crate::data_collection::disks::zfs_io_counters; + use crate::collection::disks::zfs_io_counters; if let Ok(zfs_io) = zfs_io_counters::zfs_io_stats() { for io in zfs_io.into_iter() { let mount_point = io.device_name().to_string_lossy(); diff --git a/src/data_collection/disks/io_counters.rs b/src/collection/disks/io_counters.rs similarity index 100% rename from src/data_collection/disks/io_counters.rs rename to src/collection/disks/io_counters.rs diff --git a/src/data_collection/disks/other.rs b/src/collection/disks/other.rs similarity index 94% rename from src/data_collection/disks/other.rs rename to src/collection/disks/other.rs index 9e91fbb16..627bf89f8 100644 --- a/src/data_collection/disks/other.rs +++ b/src/collection/disks/other.rs @@ -1,7 +1,7 @@ //! Fallback disk info using sysinfo. -use super::{keep_disk_entry, DiskHarvest}; -use crate::data_collection::DataCollector; +use super::{DiskHarvest, keep_disk_entry}; +use crate::collection::DataCollector; pub(crate) fn get_disk_usage(collector: &DataCollector) -> anyhow::Result> { let disks = &collector.sys.disks; diff --git a/src/data_collection/disks/unix.rs b/src/collection/disks/unix.rs similarity index 96% rename from src/data_collection/disks/unix.rs rename to src/collection/disks/unix.rs index 3178f94c4..1f4dbe9b6 100644 --- a/src/data_collection/disks/unix.rs +++ b/src/collection/disks/unix.rs @@ -24,8 +24,8 @@ cfg_if::cfg_if! { use file_systems::*; use usage::*; -use super::{keep_disk_entry, DiskHarvest}; -use crate::data_collection::DataCollector; +use super::{DiskHarvest, keep_disk_entry}; +use crate::collection::DataCollector; /// Returns the disk usage of the mounted (and for now, physical) disks. pub fn get_disk_usage(collector: &DataCollector) -> anyhow::Result> { diff --git a/src/data_collection/disks/unix/file_systems.rs b/src/collection/disks/unix/file_systems.rs similarity index 98% rename from src/data_collection/disks/unix/file_systems.rs rename to src/collection/disks/unix/file_systems.rs index 39ac0c512..de698cc87 100644 --- a/src/data_collection/disks/unix/file_systems.rs +++ b/src/collection/disks/unix/file_systems.rs @@ -88,7 +88,7 @@ impl FileSystem { matches!(self, FileSystem::Other(..)) } - #[allow(dead_code)] + #[expect(dead_code)] #[inline] /// Returns a string literal identifying this filesystem. pub fn as_str(&self) -> &str { @@ -122,7 +122,6 @@ impl FromStr for FileSystem { type Err = anyhow::Error; #[inline] - fn from_str(s: &str) -> anyhow::Result { // Done like this as `eq_ignore_ascii_case` avoids a string allocation. Ok(if s.eq_ignore_ascii_case("ext2") { @@ -157,7 +156,7 @@ impl FromStr for FileSystem { FileSystem::Bcachefs } else if s.eq_ignore_ascii_case("minix") { FileSystem::Minix - } else if s.eq_ignore_ascii_case("nilfs") { + } else if multi_eq_ignore_ascii_case!(s, "nilfs" | "nilfs2") { FileSystem::Nilfs } else if s.eq_ignore_ascii_case("xfs") { FileSystem::Xfs diff --git a/src/data_collection/disks/unix/linux/counters.rs b/src/collection/disks/unix/linux/counters.rs similarity index 96% rename from src/data_collection/disks/unix/linux/counters.rs rename to src/collection/disks/unix/linux/counters.rs index d0da4c825..fe302fb27 100644 --- a/src/data_collection/disks/unix/linux/counters.rs +++ b/src/collection/disks/unix/linux/counters.rs @@ -7,7 +7,7 @@ use std::{ str::FromStr, }; -use crate::data_collection::disks::IoCounters; +use crate::collection::disks::IoCounters; /// Copied from the `psutil` sources: /// @@ -87,7 +87,7 @@ pub fn io_stats() -> anyhow::Result> { #[cfg(feature = "zfs")] { - use crate::data_collection::disks::zfs_io_counters; + use crate::collection::disks::zfs_io_counters; if let Ok(mut zfs_io) = zfs_io_counters::zfs_io_stats() { results.append(&mut zfs_io); } diff --git a/src/data_collection/disks/unix/linux/mod.rs b/src/collection/disks/unix/linux/mod.rs similarity index 100% rename from src/data_collection/disks/unix/linux/mod.rs rename to src/collection/disks/unix/linux/mod.rs diff --git a/src/data_collection/disks/unix/linux/partition.rs b/src/collection/disks/unix/linux/partition.rs similarity index 87% rename from src/data_collection/disks/unix/linux/partition.rs rename to src/collection/disks/unix/linux/partition.rs index 5da832a09..d02e2797c 100644 --- a/src/data_collection/disks/unix/linux/partition.rs +++ b/src/collection/disks/unix/linux/partition.rs @@ -12,7 +12,7 @@ use std::{ use anyhow::bail; -use crate::data_collection::disks::unix::{FileSystem, Usage}; +use crate::collection::disks::unix::{FileSystem, Usage}; /// Representation of partition details. Based on [`heim`](https://github.com/heim-rs/heim/tree/master). pub(crate) struct Partition { @@ -76,6 +76,7 @@ impl Partition { /// Returns the usage stats for this partition. pub fn usage(&self) -> anyhow::Result { + // TODO: This might be unoptimal. let path = self .mount_point .to_str() @@ -103,12 +104,24 @@ impl Partition { } } +fn fix_mount_point(s: &str) -> String { + const ESCAPED_BACKSLASH: &str = "\\134"; + const ESCAPED_SPACE: &str = "\\040"; + const ESCAPED_TAB: &str = "\\011"; + const ESCAPED_NEWLINE: &str = "\\012"; + + s.replace(ESCAPED_BACKSLASH, "\\") + .replace(ESCAPED_SPACE, " ") + .replace(ESCAPED_TAB, "\t") + .replace(ESCAPED_NEWLINE, "\n") +} + impl FromStr for Partition { type Err = anyhow::Error; fn from_str(line: &str) -> anyhow::Result { // Example: `/dev/sda3 /home ext4 rw,relatime,data=ordered 0 0` - let mut parts = line.splitn(5, ' '); + let mut parts = line.trim_start().splitn(5, ' '); let device = match parts.next() { Some("none") => None, @@ -117,8 +130,9 @@ impl FromStr for Partition { bail!("missing device"); } }; + let mount_point = match parts.next() { - Some(point) => PathBuf::from(point), + Some(mount_point) => PathBuf::from(fix_mount_point(mount_point)), None => { bail!("missing mount point"); } @@ -138,7 +152,7 @@ impl FromStr for Partition { } } -#[allow(dead_code)] +#[expect(dead_code)] /// Returns a [`Vec`] containing all partitions. pub(crate) fn partitions() -> anyhow::Result> { const PROC_MOUNTS: &str = "/proc/mounts"; @@ -191,3 +205,15 @@ pub(crate) fn physical_partitions() -> anyhow::Result> { Ok(results) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fix_mount_point() { + let line = "/run/media/test/Samsung\\040980"; + + assert_eq!(fix_mount_point(line), "/run/media/test/Samsung 980"); + } +} diff --git a/src/data_collection/disks/unix/macos/counters.rs b/src/collection/disks/unix/macos/counters.rs similarity index 97% rename from src/data_collection/disks/unix/macos/counters.rs rename to src/collection/disks/unix/macos/counters.rs index 7d3d67686..81b6c707d 100644 --- a/src/data_collection/disks/unix/macos/counters.rs +++ b/src/collection/disks/unix/macos/counters.rs @@ -1,7 +1,7 @@ //! Based on [heim's implementation](https://github.com/heim-rs/heim/blob/master/heim-disk/src/sys/macos/counters.rs). use super::io_kit::{self, get_dict, get_disks, get_i64, get_string}; -use crate::data_collection::disks::IoCounters; +use crate::collection::disks::IoCounters; fn get_device_io(device: io_kit::IoObject) -> anyhow::Result { let parent = device.service_parent()?; diff --git a/src/data_collection/disks/unix/macos/io_kit.rs b/src/collection/disks/unix/macos/io_kit.rs similarity index 100% rename from src/data_collection/disks/unix/macos/io_kit.rs rename to src/collection/disks/unix/macos/io_kit.rs diff --git a/src/data_collection/disks/unix/macos/io_kit/bindings.rs b/src/collection/disks/unix/macos/io_kit/bindings.rs similarity index 82% rename from src/data_collection/disks/unix/macos/io_kit/bindings.rs rename to src/collection/disks/unix/macos/io_kit/bindings.rs index 93aaf9b8e..ddf4bf7e6 100644 --- a/src/data_collection/disks/unix/macos/io_kit/bindings.rs +++ b/src/collection/disks/unix/macos/io_kit/bindings.rs @@ -6,34 +6,35 @@ //! Ideally, we can remove this if sysinfo ever gains disk I/O capabilities. use core_foundation::{ - base::{mach_port_t, CFAllocatorRef}, + base::{CFAllocatorRef, mach_port_t}, dictionary::CFMutableDictionaryRef, }; use libc::c_char; use mach2::{kern_return::kern_return_t, port::MACH_PORT_NULL}; -#[allow(non_camel_case_types)] +#[expect(non_camel_case_types)] pub type io_object_t = mach_port_t; -#[allow(non_camel_case_types)] +#[expect(non_camel_case_types)] pub type io_iterator_t = io_object_t; -#[allow(non_camel_case_types)] +#[expect(non_camel_case_types)] pub type io_registry_entry_t = io_object_t; pub type IOOptionBits = u32; /// See https://github.com/1kc/librazermacos/pull/27#issuecomment-1042368531. -#[allow(non_upper_case_globals)] +#[expect(non_upper_case_globals)] pub const kIOMasterPortDefault: mach_port_t = MACH_PORT_NULL; -#[allow(non_upper_case_globals)] +#[expect(non_upper_case_globals)] pub const kIOServicePlane: &str = "IOService\0"; -#[allow(non_upper_case_globals)] +#[expect(non_upper_case_globals)] pub const kIOMediaClass: &str = "IOMedia\0"; -// See [here](https://developer.apple.com/documentation/iokit) for more details. -extern "C" { +// SAFETY: Bindings like this are inherently unsafe. See [here](https://developer.apple.com/documentation/iokit) for +// more details. +unsafe extern "C" { pub fn IOServiceGetMatchingServices( mainPort: mach_port_t, matching: CFMutableDictionaryRef, existing: *mut io_iterator_t, diff --git a/src/data_collection/disks/unix/macos/io_kit/io_disks.rs b/src/collection/disks/unix/macos/io_kit/io_disks.rs similarity index 94% rename from src/data_collection/disks/unix/macos/io_kit/io_disks.rs rename to src/collection/disks/unix/macos/io_kit/io_disks.rs index 9552b0b70..0bb13ab64 100644 --- a/src/data_collection/disks/unix/macos/io_kit/io_disks.rs +++ b/src/collection/disks/unix/macos/io_kit/io_disks.rs @@ -1,7 +1,7 @@ use anyhow::bail; use mach2::kern_return; -use super::{bindings::*, IoIterator}; +use super::{IoIterator, bindings::*}; pub fn get_disks() -> anyhow::Result { let mut media_iter: io_iterator_t = 0; diff --git a/src/data_collection/disks/unix/macos/io_kit/io_iterator.rs b/src/collection/disks/unix/macos/io_kit/io_iterator.rs similarity index 100% rename from src/data_collection/disks/unix/macos/io_kit/io_iterator.rs rename to src/collection/disks/unix/macos/io_kit/io_iterator.rs diff --git a/src/data_collection/disks/unix/macos/io_kit/io_object.rs b/src/collection/disks/unix/macos/io_kit/io_object.rs similarity index 98% rename from src/data_collection/disks/unix/macos/io_kit/io_object.rs rename to src/collection/disks/unix/macos/io_kit/io_object.rs index f7aa43ab5..0b64504ea 100644 --- a/src/data_collection/disks/unix/macos/io_kit/io_object.rs +++ b/src/collection/disks/unix/macos/io_kit/io_object.rs @@ -5,7 +5,7 @@ use std::mem; use anyhow::{anyhow, bail}; use core_foundation::{ - base::{kCFAllocatorDefault, CFType, TCFType, ToVoid}, + base::{CFType, TCFType, ToVoid, kCFAllocatorDefault}, dictionary::{ CFDictionary, CFDictionaryGetTypeID, CFDictionaryRef, CFMutableDictionary, CFMutableDictionaryRef, diff --git a/src/data_collection/disks/unix/macos/mod.rs b/src/collection/disks/unix/macos/mod.rs similarity index 100% rename from src/data_collection/disks/unix/macos/mod.rs rename to src/collection/disks/unix/macos/mod.rs diff --git a/src/data_collection/disks/unix/other/bindings.rs b/src/collection/disks/unix/other/bindings.rs similarity index 94% rename from src/data_collection/disks/unix/other/bindings.rs rename to src/collection/disks/unix/other/bindings.rs index 3c5739cc6..3b81c1933 100644 --- a/src/data_collection/disks/unix/other/bindings.rs +++ b/src/collection/disks/unix/other/bindings.rs @@ -5,9 +5,10 @@ use std::io::Error; const MNT_NOWAIT: libc::c_int = 2; -extern "C" { +// SAFETY: Bindings like this are inherently unsafe. +unsafe extern "C" { fn getfsstat64(buf: *mut libc::statfs, bufsize: libc::c_int, flags: libc::c_int) - -> libc::c_int; + -> libc::c_int; } /// Returns all the mounts on the system at the moment. diff --git a/src/data_collection/disks/unix/other/mod.rs b/src/collection/disks/unix/other/mod.rs similarity index 100% rename from src/data_collection/disks/unix/other/mod.rs rename to src/collection/disks/unix/other/mod.rs diff --git a/src/data_collection/disks/unix/other/partition.rs b/src/collection/disks/unix/other/partition.rs similarity index 95% rename from src/data_collection/disks/unix/other/partition.rs rename to src/collection/disks/unix/other/partition.rs index ad97bdb46..d3461e806 100644 --- a/src/data_collection/disks/unix/other/partition.rs +++ b/src/collection/disks/unix/other/partition.rs @@ -8,7 +8,7 @@ use std::{ use anyhow::bail; use super::bindings; -use crate::data_collection::disks::unix::{FileSystem, Usage}; +use crate::collection::disks::unix::{FileSystem, Usage}; pub(crate) struct Partition { device: String, @@ -57,7 +57,7 @@ fn partitions_iter() -> anyhow::Result> { let mounts = bindings::mounts()?; unsafe fn ptr_to_cow<'a>(ptr: *const i8) -> std::borrow::Cow<'a, str> { - CStr::from_ptr(ptr).to_string_lossy() + unsafe { CStr::from_ptr(ptr).to_string_lossy() } } Ok(mounts.into_iter().map(|stat| { @@ -84,7 +84,7 @@ fn partitions_iter() -> anyhow::Result> { })) } -#[allow(dead_code)] +#[expect(dead_code)] /// Returns a [`Vec`] containing all partitions. pub(crate) fn partitions() -> anyhow::Result> { partitions_iter().map(|iter| iter.collect()) diff --git a/src/data_collection/disks/unix/usage.rs b/src/collection/disks/unix/usage.rs similarity index 94% rename from src/data_collection/disks/unix/usage.rs rename to src/collection/disks/unix/usage.rs index 8b78edb28..522bf0329 100644 --- a/src/data_collection/disks/unix/usage.rs +++ b/src/collection/disks/unix/usage.rs @@ -2,7 +2,7 @@ pub struct Usage(libc::statvfs); // Note that x86 returns `u32` values while x86-64 returns `u64`s, so we convert // everything to `u64` for consistency. -#[allow(clippy::useless_conversion)] +#[expect(clippy::useless_conversion)] impl Usage { pub(crate) fn new(vfs: libc::statvfs) -> Self { Self(vfs) @@ -19,7 +19,7 @@ impl Usage { u64::from(self.0.f_bfree) * u64::from(self.0.f_frsize) } - #[allow(dead_code)] + #[expect(dead_code)] /// Returns the total number of bytes used. Equal to `total - available` on /// Unix. pub fn used(&self) -> u64 { diff --git a/src/data_collection/disks/windows.rs b/src/collection/disks/windows.rs similarity index 95% rename from src/data_collection/disks/windows.rs rename to src/collection/disks/windows.rs index da64f5595..a32e94a2d 100644 --- a/src/data_collection/disks/windows.rs +++ b/src/collection/disks/windows.rs @@ -5,8 +5,8 @@ mod bindings; use bindings::*; use itertools::Itertools; -use super::{keep_disk_entry, DiskHarvest}; -use crate::data_collection::{disks::IoCounters, DataCollector}; +use super::{DiskHarvest, keep_disk_entry}; +use crate::collection::{DataCollector, disks::IoCounters}; /// Returns I/O stats. pub(crate) fn io_stats() -> anyhow::Result> { diff --git a/src/data_collection/disks/windows/bindings.rs b/src/collection/disks/windows/bindings.rs similarity index 95% rename from src/data_collection/disks/windows/bindings.rs rename to src/collection/disks/windows/bindings.rs index f5c6b37e4..0c997d1c5 100644 --- a/src/data_collection/disks/windows/bindings.rs +++ b/src/collection/disks/windows/bindings.rs @@ -11,13 +11,13 @@ use anyhow::bail; use windows::Win32::{ Foundation::{self, CloseHandle, HANDLE}, Storage::FileSystem::{ - CreateFileW, FindFirstVolumeW, FindNextVolumeW, FindVolumeClose, - GetVolumeNameForVolumeMountPointW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, - FILE_SHARE_WRITE, OPEN_EXISTING, + CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, + FindFirstVolumeW, FindNextVolumeW, FindVolumeClose, GetVolumeNameForVolumeMountPointW, + OPEN_EXISTING, }, System::{ - Ioctl::{DISK_PERFORMANCE, IOCTL_DISK_PERFORMANCE}, IO::DeviceIoControl, + Ioctl::{DISK_PERFORMANCE, IOCTL_DISK_PERFORMANCE}, }, }; @@ -51,7 +51,7 @@ fn volume_io(volume: &Path) -> anyhow::Result { None, OPEN_EXISTING, FILE_FLAGS_AND_ATTRIBUTES(0), - Foundation::HANDLE::default(), + Some(Foundation::HANDLE::default()), )? }; diff --git a/src/data_collection/disks/zfs_io_counters.rs b/src/collection/disks/zfs_io_counters.rs similarity index 96% rename from src/data_collection/disks/zfs_io_counters.rs rename to src/collection/disks/zfs_io_counters.rs index cb15207b1..20c4adba6 100644 --- a/src/data_collection/disks/zfs_io_counters.rs +++ b/src/collection/disks/zfs_io_counters.rs @@ -1,4 +1,4 @@ -use crate::data_collection::disks::IoCounters; +use crate::collection::disks::IoCounters; /// Returns zpool I/O stats. Pulls data from `sysctl /// kstat.zfs.{POOL}.dataset.{objset-*}` @@ -65,11 +65,7 @@ pub fn zfs_io_stats() -> anyhow::Result> { .filter_map(|e| { e.ok().and_then(|d| { let p = d.path(); - if p.is_dir() { - Some(p) - } else { - None - } + if p.is_dir() { Some(p) } else { None } }) }) .collect(); diff --git a/src/data_collection/error.rs b/src/collection/error.rs similarity index 90% rename from src/data_collection/error.rs rename to src/collection/error.rs index dc5dd7bd3..11eac63dd 100644 --- a/src/data_collection/error.rs +++ b/src/collection/error.rs @@ -7,6 +7,10 @@ pub enum CollectionError { General(anyhow::Error), /// The collection is unsupported. + #[allow( + dead_code, + reason = "this is not used if everything is supported for the platform" + )] Unsupported, } diff --git a/src/collection/linux/utils.rs b/src/collection/linux/utils.rs new file mode 100644 index 000000000..ff148a6ff --- /dev/null +++ b/src/collection/linux/utils.rs @@ -0,0 +1,30 @@ +use std::{fs, path::Path}; + +/// Whether the temperature should *actually* be read during enumeration. +/// Will return false if the state is not D0/unknown, or if it does not support +/// `device/power_state`. +/// +/// `path` is a path to the device itself (e.g. `/sys/class/hwmon/hwmon1/device`). +#[inline] +pub fn is_device_awake(device: &Path) -> bool { + // Whether the temperature should *actually* be read during enumeration. + // Set to false if the device is in ACPI D3cold. + // Documented at https://www.kernel.org/doc/Documentation/ABI/testing/sysfs-devices-power_state + let power_state = device.join("power_state"); + if power_state.exists() { + if let Ok(state) = fs::read_to_string(power_state) { + let state = state.trim(); + // The zenpower3 kernel module (incorrectly?) reports "unknown", causing this + // check to fail and temperatures to appear as zero instead of + // having the file not exist. + // + // Their self-hosted git instance has disabled sign up, so this bug cant be + // reported either. + state == "D0" || state == "unknown" + } else { + true + } + } else { + true + } +} diff --git a/src/collection/memory.rs b/src/collection/memory.rs new file mode 100644 index 000000000..934743b44 --- /dev/null +++ b/src/collection/memory.rs @@ -0,0 +1,36 @@ +//! Memory data collection. + +use std::num::NonZeroU64; + +pub(crate) use self::sysinfo::get_ram_usage; + +pub mod sysinfo; + +cfg_if::cfg_if! { + if #[cfg(target_os = "windows")] { + mod windows; + pub(crate) use self::windows::get_swap_usage; + } else { + pub(crate) use self::sysinfo::{get_cache_usage, get_swap_usage}; + } +} + +#[cfg(feature = "zfs")] +pub mod arc; + +#[derive(Debug, Clone)] +pub struct MemData { + pub used_bytes: u64, + pub total_bytes: NonZeroU64, +} + +impl MemData { + /// Return the use percentage. + #[inline] + pub fn percentage(&self) -> f64 { + let used = self.used_bytes as f64; + let total = self.total_bytes.get() as f64; + + used / total * 100.0 + } +} diff --git a/src/data_collection/memory/arc.rs b/src/collection/memory/arc.rs similarity index 90% rename from src/data_collection/memory/arc.rs rename to src/collection/memory/arc.rs index b1eaae31b..490f31cca 100644 --- a/src/data_collection/memory/arc.rs +++ b/src/collection/memory/arc.rs @@ -1,8 +1,10 @@ -use super::MemHarvest; +use super::MemData; /// Return ARC usage. #[cfg(feature = "zfs")] -pub(crate) fn get_arc_usage() -> Option { +pub(crate) fn get_arc_usage() -> Option { + use std::num::NonZeroU64; + let (mem_total, mem_used) = { cfg_if::cfg_if! { if #[cfg(target_os = "linux")] { @@ -63,13 +65,8 @@ pub(crate) fn get_arc_usage() -> Option { } }; - Some(MemHarvest { - total_bytes: mem_total, + NonZeroU64::new(mem_total).map(|total_bytes| MemData { + total_bytes, used_bytes: mem_used, - use_percent: if mem_total == 0 { - None - } else { - Some(mem_used as f64 / mem_total as f64 * 100.0) - }, }) } diff --git a/src/collection/memory/sysinfo.rs b/src/collection/memory/sysinfo.rs new file mode 100644 index 000000000..0b2d91c48 --- /dev/null +++ b/src/collection/memory/sysinfo.rs @@ -0,0 +1,41 @@ +//! Collecting memory data using sysinfo. + +use std::num::NonZeroU64; + +use sysinfo::System; + +use crate::collection::memory::MemData; + +#[inline] +fn get_usage(used: u64, total: u64) -> Option { + NonZeroU64::new(total).map(|total_bytes| MemData { + total_bytes, + used_bytes: used, + }) +} + +/// Returns RAM usage. +pub(crate) fn get_ram_usage(sys: &System) -> Option { + get_usage(sys.used_memory(), sys.total_memory()) +} + +/// Returns SWAP usage. +#[cfg(not(target_os = "windows"))] +pub(crate) fn get_swap_usage(sys: &System) -> Option { + get_usage(sys.used_swap(), sys.total_swap()) +} + +/// Returns cache usage. sysinfo has no way to do this directly but it should +/// equal the difference between the available and free memory. Free memory is +/// defined as memory not containing any data, which means cache and buffer +/// memory are not "free". Available memory is defined as memory able +/// to be allocated by processes, which includes cache and buffer memory. On +/// Windows, this will always be 0. For more information, see [docs](https://docs.rs/sysinfo/latest/sysinfo/struct.System.html#method.available_memory) +/// and [memory explanation](https://askubuntu.com/questions/867068/what-is-available-memory-while-using-free-command) +#[cfg(not(target_os = "windows"))] +pub(crate) fn get_cache_usage(sys: &System) -> Option { + let mem_used = sys.available_memory().saturating_sub(sys.free_memory()); + let mem_total = sys.total_memory(); + + get_usage(mem_used, mem_total) +} diff --git a/src/collection/memory/windows.rs b/src/collection/memory/windows.rs new file mode 100644 index 000000000..3459187a0 --- /dev/null +++ b/src/collection/memory/windows.rs @@ -0,0 +1,96 @@ +use std::{mem::zeroed, num::NonZeroU64}; + +use sysinfo::System; +use windows::{ + Win32::{ + Foundation::ERROR_SUCCESS, + System::Performance::{ + PDH_FMT_COUNTERVALUE, PDH_FMT_DOUBLE, PDH_HCOUNTER, PDH_HQUERY, PdhAddEnglishCounterW, + PdhCloseQuery, PdhCollectQueryData, PdhGetFormattedCounterValue, PdhOpenQueryW, + PdhRemoveCounter, + }, + }, + core::w, +}; + +use crate::collection::memory::MemData; + +/// Get swap memory usage on Windows. This does it by using checking Windows' performance counters. +/// This is based on the technique done by psutil [here](https://github.com/giampaolo/psutil/pull/2160). +/// +/// Also see: +/// - +/// - +/// - . +/// - +/// - +/// - +pub(crate) fn get_swap_usage(sys: &System) -> Option { + let total_bytes = NonZeroU64::new(sys.total_swap())?; + + // See https://kennykerr.ca/rust-getting-started/string-tutorial.html + let query = w!("\\Paging File(_Total)\\% Usage"); + + // SAFETY: Hits a few Windows APIs; this should be safe as we check each step, and + // we clean up at the end. + unsafe { + let mut query_handle: PDH_HQUERY = zeroed(); + let mut counter_handle: PDH_HCOUNTER = zeroed(); + let mut counter_value: PDH_FMT_COUNTERVALUE = zeroed(); + + if PdhOpenQueryW(None, 0, &mut query_handle) != ERROR_SUCCESS.0 { + return None; + } + + if PdhAddEnglishCounterW(query_handle, query, 0, &mut counter_handle) != ERROR_SUCCESS.0 { + return None; + } + + // May fail if swap is disabled. + if PdhCollectQueryData(query_handle) != ERROR_SUCCESS.0 { + return None; + } + + if PdhGetFormattedCounterValue(counter_handle, PDH_FMT_DOUBLE, None, &mut counter_value) + != ERROR_SUCCESS.0 + { + // If we fail, still clean up. + PdhCloseQuery(query_handle); + return None; + } + + let use_percentage = counter_value.Anonymous.doubleValue; + + // Cleanup. + PdhRemoveCounter(counter_handle); + PdhCloseQuery(query_handle); + + let used_bytes = (total_bytes.get() as f64 / 100.0 * use_percentage) as u64; + Some(MemData { + used_bytes, + total_bytes, + }) + } +} + +#[cfg(all(target_os = "windows", test))] +mod tests { + use sysinfo::{MemoryRefreshKind, RefreshKind}; + + use super::*; + + #[test] + fn test_windows_get_swap_usage() { + let sys = System::new_with_specifics( + RefreshKind::nothing().with_memory(MemoryRefreshKind::nothing().with_swap()), + ); + + let swap_usage = get_swap_usage(&sys); + if sys.total_swap() > 0 { + // Not sure if we can guarantee this to always pass on a machine, so I'll just print out. + println!("swap: {swap_usage:?}"); + } else { + println!("No swap, skipping."); + } + } +} diff --git a/src/data_collection/network.rs b/src/collection/network.rs similarity index 100% rename from src/data_collection/network.rs rename to src/collection/network.rs diff --git a/src/data_collection/network/sysinfo.rs b/src/collection/network/sysinfo.rs similarity index 97% rename from src/data_collection/network/sysinfo.rs rename to src/collection/network/sysinfo.rs index f29180b00..2975fe66c 100644 --- a/src/data_collection/network/sysinfo.rs +++ b/src/collection/network/sysinfo.rs @@ -18,7 +18,7 @@ pub fn get_network_data( for (name, network) in networks { let to_keep = if let Some(filter) = filter { - filter.keep_entry(name) + filter.should_keep(name) } else { true }; diff --git a/src/data_collection/nvidia.rs b/src/collection/nvidia.rs similarity index 71% rename from src/data_collection/nvidia.rs rename to src/collection/nvidia.rs index a6fe293e8..56d04e1e0 100644 --- a/src/data_collection/nvidia.rs +++ b/src/collection/nvidia.rs @@ -1,67 +1,92 @@ -use std::sync::OnceLock; +use std::{num::NonZeroU64, sync::OnceLock}; use hashbrown::HashMap; use nvml_wrapper::{ - enum_wrappers::device::TemperatureSensor, enums::device::UsedGpuMemory, error::NvmlError, Nvml, + Nvml, enum_wrappers::device::TemperatureSensor, enums::device::UsedGpuMemory, error::NvmlError, }; use crate::{ app::{filter::Filter, layout_manager::UsedWidgets}, - data_collection::{ - memory::MemHarvest, - temperature::{is_temp_filtered, TempHarvest, TemperatureType}, - }, + collection::{memory::MemData, temperature::TempSensorData}, }; pub static NVML_DATA: OnceLock> = OnceLock::new(); pub struct GpusData { - pub memory: Option>, - pub temperature: Option>, + pub memory: Option>, + pub temperature: Option>, pub procs: Option<(u64, Vec>)>, } +/// Wrapper around Nvml::init +/// +/// On Linux, if `Nvml::init()` fails, this function attempts to explicitly load +/// the library from `libnvidia-ml.so.1`. On other platforms, it simply calls `Nvml::init`. +/// +/// This is a workaround until https://github.com/Cldfire/nvml-wrapper/pull/63 is accepted. +/// Then, we can go back to calling `Nvml::init` directly on all platforms. +fn init_nvml() -> Result { + #[cfg(not(target_os = "linux"))] + { + Nvml::init() + } + #[cfg(target_os = "linux")] + { + match Nvml::init() { + Ok(nvml) => Ok(nvml), + Err(_) => Nvml::builder() + .lib_path(std::ffi::OsStr::new("libnvidia-ml.so.1")) + .init(), + } + } +} + /// Returns the GPU data from NVIDIA cards. #[inline] pub fn get_nvidia_vecs( - temp_type: &TemperatureType, filter: &Option, widgets_to_harvest: &UsedWidgets, + filter: &Option, widgets_to_harvest: &UsedWidgets, ) -> Option { - if let Ok(nvml) = NVML_DATA.get_or_init(Nvml::init) { + if let Ok(nvml) = NVML_DATA.get_or_init(init_nvml) { if let Ok(num_gpu) = nvml.device_count() { let mut temp_vec = Vec::with_capacity(num_gpu as usize); let mut mem_vec = Vec::with_capacity(num_gpu as usize); let mut proc_vec = Vec::with_capacity(num_gpu as usize); let mut total_mem = 0; + for i in 0..num_gpu { if let Ok(device) = nvml.device_by_index(i) { if let Ok(name) = device.name() { if widgets_to_harvest.use_mem { if let Ok(mem) = device.memory_info() { - mem_vec.push(( - name.clone(), - MemHarvest { - total_bytes: mem.total, - used_bytes: mem.used, - use_percent: if mem.total == 0 { - None - } else { - Some(mem.used as f64 / mem.total as f64 * 100.0) + if let Some(total_bytes) = NonZeroU64::new(mem.total) { + mem_vec.push(( + name.clone(), + MemData { + total_bytes, + used_bytes: mem.used, }, - }, - )); + )); + } } } - if widgets_to_harvest.use_temp && is_temp_filtered(filter, &name) { - if let Ok(temperature) = device.temperature(TemperatureSensor::Gpu) { - let temperature = temp_type.convert_temp_unit(temperature as f32); - temp_vec.push(TempHarvest { - name: name.clone(), - temperature: Some(temperature), + if widgets_to_harvest.use_temp + && Filter::optional_should_keep(filter, &name) + { + if let Ok(temperature) = device.temperature(TemperatureSensor::Gpu) { + temp_vec.push(TempSensorData { + name, + temperature: Some(temperature as f32), + }); + } else { + temp_vec.push(TempSensorData { + name, + temperature: None, }); } } } + if widgets_to_harvest.use_proc { let mut procs = HashMap::new(); @@ -130,6 +155,7 @@ pub fn get_nvidia_vecs( } } } + Some(GpusData { memory: if !mem_vec.is_empty() { Some(mem_vec) diff --git a/src/data_collection/processes.rs b/src/collection/processes.rs similarity index 58% rename from src/data_collection/processes.rs rename to src/collection/processes.rs index 319c16e1f..c409e236d 100644 --- a/src/data_collection/processes.rs +++ b/src/collection/processes.rs @@ -4,6 +4,7 @@ //! For Windows, macOS, FreeBSD, Android, and Linux, this is handled by sysinfo. use cfg_if::cfg_if; +use sysinfo::ProcessStatus; cfg_if! { if #[cfg(target_os = "linux")] { @@ -33,7 +34,7 @@ cfg_if! { use std::{borrow::Cow, time::Duration}; -use super::{error::CollectionResult, DataCollector}; +use super::{DataCollector, error::CollectionResult}; cfg_if! { if #[cfg(target_family = "windows")] { @@ -45,6 +46,8 @@ cfg_if! { } } +pub type Bytes = u64; + #[derive(Debug, Clone, Default)] pub struct ProcessHarvest { /// The pid of the process. @@ -60,7 +63,10 @@ pub struct ProcessHarvest { pub mem_usage_percent: f32, /// Memory usage as bytes. - pub mem_usage_bytes: u64, + pub mem_usage: Bytes, + + /// Virtual memory. + pub virtual_mem: Bytes, /// The name of the process. pub name: String, @@ -69,19 +75,19 @@ pub struct ProcessHarvest { pub command: String, /// Bytes read per second. - pub read_bytes_per_sec: u64, + pub read_per_sec: Bytes, /// Bytes written per second. - pub write_bytes_per_sec: u64, + pub write_per_sec: Bytes, /// The total number of bytes read by the process. - pub total_read_bytes: u64, + pub total_read: Bytes, /// The total number of bytes written by the process. - pub total_write_bytes: u64, + pub total_write: Bytes, /// The current state of the process (e.g. zombie, asleep). - pub process_state: (String, char), + pub process_state: (&'static str, char), /// Cumulative process uptime. pub time: Duration, @@ -110,25 +116,6 @@ pub struct ProcessHarvest { // pub virt_kb: u64, } -impl ProcessHarvest { - pub(crate) fn add(&mut self, rhs: &ProcessHarvest) { - self.cpu_usage_percent += rhs.cpu_usage_percent; - self.mem_usage_bytes += rhs.mem_usage_bytes; - self.mem_usage_percent += rhs.mem_usage_percent; - self.read_bytes_per_sec += rhs.read_bytes_per_sec; - self.write_bytes_per_sec += rhs.write_bytes_per_sec; - self.total_read_bytes += rhs.total_read_bytes; - self.total_write_bytes += rhs.total_write_bytes; - self.time = self.time.max(rhs.time); - #[cfg(feature = "gpu")] - { - self.gpu_mem += rhs.gpu_mem; - self.gpu_util += rhs.gpu_util; - self.gpu_mem_percent += rhs.gpu_mem_percent; - } - } -} - impl DataCollector { pub(crate) fn get_processes(&mut self) -> CollectionResult> { cfg_if! { @@ -144,8 +131,57 @@ impl DataCollector { } else if #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "windows", target_os = "android", target_os = "ios"))] { sysinfo_process_data(self) } else { - Err(crate::data_collection::error::CollectionError::Unsupported) + Err(crate::collection::error::CollectionError::Unsupported) + } + } + } +} + +/// Pulled from [`ProcessStatus::to_string`] to avoid an alloc. +pub(super) fn process_status_str(status: ProcessStatus) -> &'static str { + cfg_if::cfg_if! { + if #[cfg(target_os = "linux")] { + match status { + ProcessStatus::Idle => "Idle", + ProcessStatus::Run => "Runnable", + ProcessStatus::Sleep => "Sleeping", + ProcessStatus::Stop => "Stopped", + ProcessStatus::Zombie => "Zombie", + ProcessStatus::Tracing => "Tracing", + ProcessStatus::Dead => "Dead", + ProcessStatus::Wakekill => "Wakekill", + ProcessStatus::Waking => "Waking", + ProcessStatus::Parked => "Parked", + ProcessStatus::UninterruptibleDiskSleep => "UninterruptibleDiskSleep", + _ => "Unknown", + } + } else if #[cfg(target_os = "windows")] { + match status { + ProcessStatus::Run => "Runnable", + _ => "Unknown", + } + } else if #[cfg(target_os = "macos")] { + match status { + ProcessStatus::Idle => "Idle", + ProcessStatus::Run => "Runnable", + ProcessStatus::Sleep => "Sleeping", + ProcessStatus::Stop => "Stopped", + ProcessStatus::Zombie => "Zombie", + _ => "Unknown", + } + } else if #[cfg(target_os = "freebsd")] { + match status { + ProcessStatus::Idle => "Idle", + ProcessStatus::Run => "Runnable", + ProcessStatus::Sleep => "Sleeping", + ProcessStatus::Stop => "Stopped", + ProcessStatus::Zombie => "Zombie", + ProcessStatus::Dead => "Dead", + ProcessStatus::LockBlocked => "LockBlocked", + _ => "Unknown", } + } else { + "Unknown" } } } diff --git a/src/data_collection/processes/freebsd.rs b/src/collection/processes/freebsd.rs similarity index 90% rename from src/data_collection/processes/freebsd.rs rename to src/collection/processes/freebsd.rs index 5ae31ec9d..7ffee235b 100644 --- a/src/data_collection/processes/freebsd.rs +++ b/src/collection/processes/freebsd.rs @@ -5,7 +5,7 @@ use std::{io, process::Command}; use hashbrown::HashMap; use serde::{Deserialize, Deserializer}; -use crate::data_collection::{deserialize_xo, processes::UnixProcessExt, Pid}; +use crate::collection::{Pid, deserialize_xo, processes::UnixProcessExt}; #[derive(Deserialize, Debug, Default)] #[serde(rename_all = "kebab-case")] @@ -19,7 +19,7 @@ struct ProcessRow { #[serde(deserialize_with = "pid")] pid: i32, #[serde(deserialize_with = "percent_cpu")] - percent_cpu: f64, + percent_cpu: f32, } pub(crate) struct FreeBSDProcessExt; @@ -30,7 +30,7 @@ impl UnixProcessExt for FreeBSDProcessExt { true } - fn backup_proc_cpu(pids: &[Pid]) -> io::Result> { + fn backup_proc_cpu(pids: &[Pid]) -> io::Result> { if pids.is_empty() { return Ok(HashMap::new()); } @@ -60,7 +60,7 @@ where s.parse().map_err(serde::de::Error::custom) } -fn percent_cpu<'de, D>(deserializer: D) -> Result +fn percent_cpu<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { diff --git a/src/data_collection/processes/linux.rs b/src/collection/processes/linux/mod.rs similarity index 80% rename from src/data_collection/processes/linux.rs rename to src/collection/processes/linux/mod.rs index 6de841f1a..d85079c24 100644 --- a/src/data_collection/processes/linux.rs +++ b/src/collection/processes/linux/mod.rs @@ -8,12 +8,13 @@ use std::{ time::Duration, }; +use concat_string::concat_string; use hashbrown::HashSet; use process::*; use sysinfo::ProcessStatus; -use super::{Pid, ProcessHarvest, UserTable}; -use crate::data_collection::{error::CollectionResult, DataCollector}; +use super::{Pid, ProcessHarvest, UserTable, process_status_str}; +use crate::collection::{DataCollector, error::CollectionResult}; /// Maximum character length of a `/proc//stat`` process name. /// If it's equal or greater, then we instead refer to the command for the name. @@ -149,39 +150,9 @@ fn read_proc( uptime, } = args; - let (command, name) = { - let truncated_name = stat.comm.as_str(); - if let Ok(cmdline) = cmdline { - if cmdline.is_empty() { - (format!("[{truncated_name}]"), truncated_name.to_string()) - } else { - ( - cmdline.join(" "), - if truncated_name.len() >= MAX_STAT_NAME_LEN { - if let Some(first_part) = cmdline.first() { - // We're only interested in the executable part... not the file path. - // That's for command. - first_part - .rsplit_once('/') - .map(|(_prefix, suffix)| suffix) - .unwrap_or(truncated_name) - .to_string() - } else { - truncated_name.to_string() - } - } else { - truncated_name.to_string() - }, - ) - } - } else { - (truncated_name.to_string(), truncated_name.to_string()) - } - }; - let process_state_char = stat.state; let process_state = ( - ProcessStatus::from(process_state_char).to_string(), + process_status_str(ProcessStatus::from(process_state_char)), process_state_char, ); let (cpu_usage_percent, new_process_times) = get_linux_cpu_usage( @@ -192,36 +163,31 @@ fn read_proc( use_current_cpu_total, ); let parent_pid = Some(stat.ppid); - let mem_usage_bytes = stat.rss_bytes(); - let mem_usage_percent = (mem_usage_bytes as f64 / total_memory as f64 * 100.0) as f32; - - // This can fail if permission is denied! - let (total_read_bytes, total_write_bytes, read_bytes_per_sec, write_bytes_per_sec) = - if let Ok(io) = io { - let total_read_bytes = io.read_bytes; - let total_write_bytes = io.write_bytes; - let prev_total_read_bytes = prev_proc.total_read_bytes; - let prev_total_write_bytes = prev_proc.total_write_bytes; - - let read_bytes_per_sec = total_read_bytes - .saturating_sub(prev_total_read_bytes) - .checked_div(time_difference_in_secs) - .unwrap_or(0); - - let write_bytes_per_sec = total_write_bytes - .saturating_sub(prev_total_write_bytes) - .checked_div(time_difference_in_secs) - .unwrap_or(0); - - ( - total_read_bytes, - total_write_bytes, - read_bytes_per_sec, - write_bytes_per_sec, - ) - } else { - (0, 0, 0, 0) - }; + let mem_usage = stat.rss_bytes(); + let mem_usage_percent = (mem_usage as f64 / total_memory as f64 * 100.0) as f32; + let virtual_mem = stat.vsize; + + // XXX: This can fail if permission is denied. + let (total_read, total_write, read_per_sec, write_per_sec) = if let Some(io) = io { + let total_read = io.read_bytes; + let total_write = io.write_bytes; + let prev_total_read = prev_proc.total_read_bytes; + let prev_total_write = prev_proc.total_write_bytes; + + let read_per_sec = total_read + .saturating_sub(prev_total_read) + .checked_div(time_difference_in_secs) + .unwrap_or(0); + + let write_per_sec = total_write + .saturating_sub(prev_total_write) + .checked_div(time_difference_in_secs) + .unwrap_or(0); + + (total_read, total_write, read_per_sec, write_per_sec) + } else { + (0, 0, 0, 0) + }; let user = uid .and_then(|uid| { @@ -242,19 +208,51 @@ fn read_proc( Duration::ZERO }; + let (command, name) = { + let truncated_name = stat.comm; + if let Some(cmdline) = cmdline { + if cmdline.is_empty() { + (concat_string!("[", truncated_name, "]"), truncated_name) + } else { + let name = if truncated_name.len() >= MAX_STAT_NAME_LEN { + let first_part = match cmdline.split_once(' ') { + Some((first, _)) => first, + None => &cmdline, + }; + + // We're only interested in the executable part, not the file path (part of command), + // so strip everything but the command name if needed. + let last_part = match first_part.rsplit_once('/') { + Some((_, last)) => last, + None => first_part, + }; + + last_part.to_string() + } else { + truncated_name + }; + + (cmdline, name) + } + } else { + (truncated_name.clone(), truncated_name) + } + }; + Ok(( ProcessHarvest { pid: process.pid, parent_pid, cpu_usage_percent, mem_usage_percent, - mem_usage_bytes, + mem_usage, + virtual_mem, name, command, - read_bytes_per_sec, - write_bytes_per_sec, - total_read_bytes, - total_write_bytes, + read_per_sec, + write_per_sec, + total_read, + total_write, process_state, uid, user, @@ -354,13 +352,15 @@ pub(crate) fn linux_process_data( uptime: sysinfo::System::uptime(), }; + let mut buffer = String::new(); + let process_vector: Vec = pids .filter_map(|pid_path| { - if let Ok(process) = Process::from_path(pid_path) { + if let Ok(process) = Process::from_path(pid_path, &mut buffer) { let pid = process.pid; let prev_proc_details = pid_mapping.entry(pid).or_default(); - #[allow(unused_mut)] + #[cfg_attr(not(feature = "gpu"), expect(unused_mut))] if let Ok((mut process_harvest, new_process_times)) = read_proc(prev_proc_details, process, args, user_table) { @@ -381,8 +381,8 @@ pub(crate) fn linux_process_data( } prev_proc_details.cpu_time = new_process_times; - prev_proc_details.total_read_bytes = process_harvest.total_read_bytes; - prev_proc_details.total_write_bytes = process_harvest.total_write_bytes; + prev_proc_details.total_read_bytes = process_harvest.total_read; + prev_proc_details.total_write_bytes = process_harvest.total_write; pids_to_clear.remove(&pid); return Some(process_harvest); diff --git a/src/data_collection/processes/linux/process.rs b/src/collection/processes/linux/process.rs similarity index 82% rename from src/data_collection/processes/linux/process.rs rename to src/collection/processes/linux/process.rs index 45ed80a67..98d8fd0b0 100644 --- a/src/data_collection/processes/linux/process.rs +++ b/src/collection/processes/linux/process.rs @@ -16,7 +16,7 @@ use rustix::{ path::Arg, }; -use crate::data_collection::processes::Pid; +use crate::collection::processes::Pid; static PAGESIZE: OnceLock = OnceLock::new(); @@ -51,21 +51,25 @@ pub(crate) struct Stat { /// The resident set size, or the number of pages the process has in real /// memory. - pub rss: u64, + rss: u64, + + /// The virtual memory size in bytes. + pub vsize: u64, /// The start time of the process, represented in clock ticks. pub start_time: u64, } impl Stat { - #[inline] + /// Get process stats from a file; this assumes the file is located at + /// `/proc//stat`. fn from_file(mut f: File, buffer: &mut String) -> anyhow::Result { // Since this is just one line, we can read it all at once. However, since it - // might have non-utf8 characters, we can't just use read_to_string. + // (technically) might have non-utf8 characters, we can't just use read_to_string. f.read_to_end(unsafe { buffer.as_mut_vec() })?; - let line = buffer.to_string_lossy(); - let line = line.trim(); + // TODO: Is this needed? + let line = buffer.trim(); let (comm, rest) = { let start_paren = line @@ -97,8 +101,7 @@ impl Stat { let mut rest = rest.skip(6); let start_time: u64 = next_part(&mut rest)?.parse()?; - // Skip one field until rss (vsize) - let mut rest = rest.skip(1); + let vsize: u64 = next_part(&mut rest)?.parse()?; let rss: u64 = next_part(&mut rest)?.parse()?; Ok(Stat { @@ -108,6 +111,7 @@ impl Stat { utime, stime, rss, + vsize, start_time, }) } @@ -204,8 +208,8 @@ pub(crate) struct Process { pub pid: Pid, pub uid: Option, pub stat: Stat, - pub io: anyhow::Result, - pub cmdline: anyhow::Result>, + pub io: Option, + pub cmdline: Option, } #[inline] @@ -223,8 +227,10 @@ impl Process { /// methods. Therefore, this struct is only useful for either fields /// that are unlikely to change, or are short-lived and /// will be discarded quickly. - pub(crate) fn from_path(pid_path: PathBuf) -> anyhow::Result { - // TODO: Pass in a buffer vec/string to share? + /// + /// This takes in a buffer to avoid allocs; this function will clear the buffer. + pub(crate) fn from_path(pid_path: PathBuf, buffer: &mut String) -> anyhow::Result { + buffer.clear(); let fd = rustix::fs::openat( rustix::fs::CWD, @@ -236,7 +242,7 @@ impl Process { let pid = pid_path .as_path() .components() - .last() + .next_back() .and_then(|s| s.to_string_lossy().parse::().ok()) .or_else(|| { rustix::fs::readlinkat(rustix::fs::CWD, &pid_path, vec![]) @@ -254,18 +260,26 @@ impl Process { }; let mut root = pid_path; - let mut buffer = String::new(); // NB: Whenever you add a new stat, make sure to pop the root and clear the // buffer! - let stat = - open_at(&mut root, "stat", &fd).and_then(|file| Stat::from_file(file, &mut buffer))?; - reset(&mut root, &mut buffer); - let cmdline = cmdline(&mut root, &fd, &mut buffer); - reset(&mut root, &mut buffer); + // Stat is pretty long, do this first to pre-allocate up-front. + let stat = + open_at(&mut root, "stat", &fd).and_then(|file| Stat::from_file(file, buffer))?; + reset(&mut root, buffer); + + let cmdline = if cmdline(&mut root, &fd, buffer).is_ok() { + // The clone will give a string with the capacity of the length of buffer, don't worry. + Some(buffer.clone()) + } else { + None + }; + reset(&mut root, buffer); - let io = open_at(&mut root, "io", &fd).and_then(|file| Io::from_file(file, &mut buffer)); + let io = open_at(&mut root, "io", &fd) + .and_then(|file| Io::from_file(file, buffer)) + .ok(); Ok(Process { pid, @@ -278,22 +292,22 @@ impl Process { } #[inline] -fn cmdline(root: &mut PathBuf, fd: &OwnedFd, buffer: &mut String) -> anyhow::Result> { - open_at(root, "cmdline", fd) +fn cmdline(root: &mut PathBuf, fd: &OwnedFd, buffer: &mut String) -> anyhow::Result<()> { + let _ = open_at(root, "cmdline", fd) .map(|mut file| file.read_to_string(buffer)) - .map(|_| { - buffer - .split('\0') - .filter_map(|s| { - if !s.is_empty() { - Some(s.to_string()) - } else { - None - } - }) - .collect::>() - }) - .map_err(Into::into) + .inspect(|_| { + // SAFETY: We are only replacing a single char (NUL) with another single char (space). + let buf_mut = unsafe { buffer.as_mut_vec() }; + + for byte in buf_mut { + if *byte == 0 { + const SPACE: u8 = ' '.to_ascii_lowercase() as u8; + *byte = SPACE; + } + } + })?; + + Ok(()) } /// Opens a path. Note that this function takes in a mutable root - this will diff --git a/src/data_collection/processes/macos.rs b/src/collection/processes/macos.rs similarity index 97% rename from src/data_collection/processes/macos.rs rename to src/collection/processes/macos.rs index b11517379..dc842686b 100644 --- a/src/data_collection/processes/macos.rs +++ b/src/collection/processes/macos.rs @@ -8,7 +8,7 @@ use hashbrown::HashMap; use itertools::Itertools; use super::UnixProcessExt; -use crate::data_collection::Pid; +use crate::collection::Pid; pub(crate) struct MacOSProcessExt; @@ -18,7 +18,7 @@ impl UnixProcessExt for MacOSProcessExt { true } - fn backup_proc_cpu(pids: &[Pid]) -> io::Result> { + fn backup_proc_cpu(pids: &[Pid]) -> io::Result> { let output = Command::new("ps") .args(["-o", "pid=,pcpu=", "-p"]) .arg( diff --git a/src/data_collection/processes/macos/sysctl_bindings.rs b/src/collection/processes/macos/sysctl_bindings.rs similarity index 93% rename from src/data_collection/processes/macos/sysctl_bindings.rs rename to src/collection/processes/macos/sysctl_bindings.rs index 53a7f3dec..fce86e4be 100644 --- a/src/data_collection/processes/macos/sysctl_bindings.rs +++ b/src/collection/processes/macos/sysctl_bindings.rs @@ -3,23 +3,21 @@ use std::mem; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use libc::{ - boolean_t, c_char, c_long, c_short, c_uchar, c_ushort, c_void, dev_t, gid_t, itimerval, pid_t, - rusage, sigset_t, timeval, uid_t, xucred, CTL_KERN, KERN_PROC, KERN_PROC_PID, MAXCOMLEN, + CTL_KERN, KERN_PROC, KERN_PROC_PID, MAXCOMLEN, boolean_t, c_char, c_long, c_short, c_uchar, + c_ushort, c_void, dev_t, gid_t, itimerval, pid_t, rusage, sigset_t, timeval, uid_t, xucred, }; use mach2::vm_types::user_addr_t; -use crate::data_collection::Pid; +use crate::collection::Pid; -#[allow(non_camel_case_types)] #[repr(C)] pub(crate) struct kinfo_proc { pub kp_proc: extern_proc, pub kp_eproc: eproc, } -#[allow(non_camel_case_types)] #[repr(C)] #[derive(Copy, Clone)] pub struct p_st1 { @@ -28,7 +26,6 @@ pub struct p_st1 { p_back: user_addr_t, } -#[allow(non_camel_case_types)] #[repr(C)] pub union p_un { pub p_st1: p_st1, @@ -39,7 +36,6 @@ pub union p_un { /// Exported fields for kern sysctl. See /// [`proc.h`](https://opensource.apple.com/source/xnu/xnu-201/bsd/sys/proc.h) -#[allow(non_camel_case_types)] #[repr(C)] pub(crate) struct extern_proc { pub p_un: p_un, @@ -170,19 +166,18 @@ const WMESGLEN: usize = 7; const COMAPT_MAXLOGNAME: usize = 12; /// See `_caddr_t.h`. -#[allow(non_camel_case_types)] +#[expect(non_camel_case_types)] type caddr_t = *const libc::c_char; /// See `types.h`. -#[allow(non_camel_case_types)] +#[expect(non_camel_case_types)] type segsz_t = i32; /// See `types.h`. -#[allow(non_camel_case_types)] +#[expect(non_camel_case_types)] type fixpt_t = u32; /// See [`proc.h`](https://opensource.apple.com/source/xnu/xnu-201/bsd/sys/proc.h) -#[allow(non_camel_case_types)] #[repr(C)] pub(crate) struct pcred { pub pc_lock: [c_char; 72], @@ -195,7 +190,6 @@ pub(crate) struct pcred { } /// See `vm.h`. -#[allow(non_camel_case_types)] #[repr(C)] pub(crate) struct vmspace { pub dummy: i32, @@ -205,7 +199,6 @@ pub(crate) struct vmspace { } /// See [`sysctl.h`](https://opensource.apple.com/source/xnu/xnu-344/bsd/sys/sysctl.h). -#[allow(non_camel_case_types)] #[repr(C)] pub(crate) struct eproc { /// Address of proc. We just cheat and use a c_void pointer since we aren't diff --git a/src/data_collection/processes/unix.rs b/src/collection/processes/unix.rs similarity index 91% rename from src/data_collection/processes/unix.rs rename to src/collection/processes/unix.rs index 2656a5838..dee9d4fa7 100644 --- a/src/data_collection/processes/unix.rs +++ b/src/collection/processes/unix.rs @@ -12,8 +12,8 @@ cfg_if! { use super::ProcessHarvest; - use crate::data_collection::{DataCollector, processes::*}; - use crate::data_collection::error::CollectionResult; + use crate::collection::{DataCollector, processes::*}; + use crate::collection::error::CollectionResult; pub fn sysinfo_process_data(collector: &mut DataCollector) -> CollectionResult> { let sys = &collector.sys.system; diff --git a/src/data_collection/processes/unix/process_ext.rs b/src/collection/processes/unix/process_ext.rs similarity index 55% rename from src/data_collection/processes/unix/process_ext.rs rename to src/collection/processes/unix/process_ext.rs index 322f56a43..d303323b3 100644 --- a/src/data_collection/processes/unix/process_ext.rs +++ b/src/collection/processes/unix/process_ext.rs @@ -3,10 +3,11 @@ use std::{io, time::Duration}; use hashbrown::HashMap; +use itertools::Itertools; use sysinfo::{ProcessStatus, System}; -use super::ProcessHarvest; -use crate::data_collection::{error::CollectionResult, processes::UserTable, Pid}; +use super::{ProcessHarvest, process_status_str}; +use crate::collection::{Pid, error::CollectionResult, processes::UserTable}; pub(crate) trait UnixProcessExt { fn sysinfo_process_data( @@ -15,14 +16,14 @@ pub(crate) trait UnixProcessExt { ) -> CollectionResult> { let mut process_vector: Vec = Vec::new(); let process_hashmap = sys.processes(); - let cpu_usage = sys.global_cpu_info().cpu_usage() as f64 / 100.0; - let num_processors = sys.cpus().len() as f64; + let cpu_usage = sys.global_cpu_usage() / 100.0; + let num_processors = sys.cpus().len(); for process_val in process_hashmap.values() { let name = if process_val.name().is_empty() { let process_cmd = process_val.cmd(); - if process_cmd.len() > 1 { - process_cmd[0].clone() + if let Some(name) = process_cmd.first() { + name.to_string_lossy().to_string() } else { process_val .exe() @@ -32,35 +33,39 @@ pub(crate) trait UnixProcessExt { .unwrap_or(String::new()) } } else { - process_val.name().to_string() + process_val.name().to_string_lossy().to_string() }; let command = { - let command = process_val.cmd().join(" "); + let command = process_val + .cmd() + .iter() + .map(|s| s.to_string_lossy()) + .join(" "); if command.is_empty() { - name.to_string() + name.clone() } else { command } }; let pcu = { - let usage = process_val.cpu_usage() as f64; - if unnormalized_cpu || num_processors == 0.0 { + let usage = process_val.cpu_usage(); + if unnormalized_cpu || num_processors == 0 { usage } else { - usage / num_processors + usage / num_processors as f32 } }; let process_cpu_usage = if use_current_cpu_total && cpu_usage > 0.0 { pcu / cpu_usage } else { pcu - } as f32; + }; let disk_usage = process_val.disk_usage(); let process_state = { let ps = process_val.status(); - (ps.to_string(), convert_process_status_to_char(ps)) + (process_status_str(ps), convert_process_status_to_char(ps)) }; let uid = process_val.user_id().map(|u| **u); let pid = process_val.pid().as_u32() as Pid; @@ -74,12 +79,13 @@ pub(crate) trait UnixProcessExt { } else { 0.0 }, - mem_usage_bytes: process_val.memory(), + mem_usage: process_val.memory(), + virtual_mem: process_val.virtual_memory(), cpu_usage_percent: process_cpu_usage, - read_bytes_per_sec: disk_usage.read_bytes, - write_bytes_per_sec: disk_usage.written_bytes, - total_read_bytes: disk_usage.total_read_bytes, - total_write_bytes: disk_usage.total_written_bytes, + read_per_sec: disk_usage.read_bytes, + write_per_sec: disk_usage.written_bytes, + total_read: disk_usage.total_read_bytes, + total_write: disk_usage.total_written_bytes, process_state, uid, user: uid @@ -119,11 +125,11 @@ pub(crate) trait UnixProcessExt { let cpu_usages = Self::backup_proc_cpu(&cpu_usage_unknown_pids)?; for process in &mut process_vector { if cpu_usages.contains_key(&process.pid) { - process.cpu_usage_percent = if unnormalized_cpu || num_processors == 0.0 { + process.cpu_usage_percent = if unnormalized_cpu || num_processors == 0 { *cpu_usages.get(&process.pid).unwrap() } else { - *cpu_usages.get(&process.pid).unwrap() / num_processors - } as f32; + *cpu_usages.get(&process.pid).unwrap() / num_processors as f32 + }; } } } @@ -136,7 +142,7 @@ pub(crate) trait UnixProcessExt { false } - fn backup_proc_cpu(_pids: &[Pid]) -> io::Result> { + fn backup_proc_cpu(_pids: &[Pid]) -> io::Result> { Ok(HashMap::default()) } @@ -146,11 +152,57 @@ pub(crate) trait UnixProcessExt { } fn convert_process_status_to_char(status: ProcessStatus) -> char { - match status { - ProcessStatus::Run => 'R', - ProcessStatus::Sleep => 'S', - ProcessStatus::Idle => 'D', - ProcessStatus::Zombie => 'Z', - _ => '?', + // TODO: Based on https://github.com/GuillaumeGomez/sysinfo/blob/baa46efb46d82f21b773088603720262f4a34646/src/unix/freebsd/process.rs#L13? + cfg_if::cfg_if! { + if #[cfg(target_os = "macos")] { + // SAFETY: These are all const and should be valid characters. + const SIDL: char = unsafe { char::from_u32_unchecked(libc::SIDL) }; + + // SAFETY: These are all const and should be valid characters. + const SRUN: char = unsafe { char::from_u32_unchecked(libc::SRUN) }; + + // SAFETY: These are all const and should be valid characters. + const SSLEEP: char = unsafe { char::from_u32_unchecked(libc::SSLEEP) }; + + // SAFETY: These are all const and should be valid characters. + const SSTOP: char = unsafe { char::from_u32_unchecked(libc::SSTOP) }; + + // SAFETY: These are all const and should be valid characters. + const SZOMB: char = unsafe { char::from_u32_unchecked(libc::SZOMB) }; + + match status { + ProcessStatus::Idle => SIDL, + ProcessStatus::Run => SRUN, + ProcessStatus::Sleep => SSLEEP, + ProcessStatus::Stop => SSTOP, + ProcessStatus::Zombie => SZOMB, + _ => '?' + } + } else if #[cfg(target_os = "freebsd")] { + const fn assert_u8(val: i8) -> u8 { + if val < 0 { panic!("there was an invalid i8 constant that is supposed to be a char") } else { val as u8 } + } + + const SIDL: u8 = assert_u8(libc::SIDL); + const SRUN: u8 = assert_u8(libc::SRUN); + const SSLEEP: u8 = assert_u8(libc::SSLEEP); + const SSTOP: u8 = assert_u8(libc::SSTOP); + const SZOMB: u8 = assert_u8(libc::SZOMB); + const SWAIT: u8 = assert_u8(libc::SWAIT); + const SLOCK: u8 = assert_u8(libc::SLOCK); + + match status { + ProcessStatus::Idle => SIDL as char, + ProcessStatus::Run => SRUN as char, + ProcessStatus::Sleep => SSLEEP as char, + ProcessStatus::Stop => SSTOP as char, + ProcessStatus::Zombie => SZOMB as char, + ProcessStatus::Dead => SWAIT as char, + ProcessStatus::LockBlocked => SLOCK as char, + _ => '?' + } + } else { + '?' + } } } diff --git a/src/data_collection/processes/unix/user_table.rs b/src/collection/processes/unix/user_table.rs similarity index 89% rename from src/data_collection/processes/unix/user_table.rs rename to src/collection/processes/unix/user_table.rs index dc8e0ab45..036fe60e3 100644 --- a/src/data_collection/processes/unix/user_table.rs +++ b/src/collection/processes/unix/user_table.rs @@ -1,6 +1,6 @@ use hashbrown::HashMap; -use crate::data_collection::error::{CollectionError, CollectionResult}; +use crate::collection::error::{CollectionError, CollectionResult}; #[derive(Debug, Default)] pub struct UserTable { @@ -12,8 +12,7 @@ impl UserTable { if let Some(user) = self.uid_user_mapping.get(&uid) { Ok(user.clone()) } else { - // SAFETY: getpwuid returns a null pointer if no passwd entry is found for the - // uid + // SAFETY: getpwuid returns a null pointer if no passwd entry is found for the uid. let passwd = unsafe { libc::getpwuid(uid) }; if passwd.is_null() { diff --git a/src/data_collection/processes/windows.rs b/src/collection/processes/windows.rs similarity index 79% rename from src/data_collection/processes/windows.rs rename to src/collection/processes/windows.rs index a597f264b..0f9f8d374 100644 --- a/src/data_collection/processes/windows.rs +++ b/src/collection/processes/windows.rs @@ -2,9 +2,10 @@ use std::time::Duration; -use super::ProcessHarvest; -use crate::data_collection::error::CollectionResult; -use crate::data_collection::DataCollector; +use itertools::Itertools; + +use super::{ProcessHarvest, process_status_str}; +use crate::collection::{DataCollector, error::CollectionResult}; // TODO: There's a lot of shared code with this and the unix impl. pub fn sysinfo_process_data( @@ -18,14 +19,14 @@ pub fn sysinfo_process_data( let mut process_vector: Vec = Vec::new(); let process_hashmap = sys.processes(); - let cpu_usage = sys.global_cpu_info().cpu_usage() as f64 / 100.0; + let cpu_usage = sys.global_cpu_usage() / 100.0; let num_processors = sys.cpus().len(); for process_val in process_hashmap.values() { let name = if process_val.name().is_empty() { let process_cmd = process_val.cmd(); if process_cmd.len() > 1 { - process_cmd[0].clone() + process_cmd[0].to_string_lossy().to_string() } else { process_val .exe() @@ -35,33 +36,38 @@ pub fn sysinfo_process_data( .unwrap_or(String::new()) } } else { - process_val.name().to_string() + process_val.name().to_string_lossy().to_string() }; let command = { - let command = process_val.cmd().join(" "); + let command = process_val + .cmd() + .iter() + .map(|s| s.to_string_lossy()) + .join(" "); if command.is_empty() { - name.to_string() + name.clone() } else { command } }; let pcu = { - let usage = process_val.cpu_usage() as f64; + let usage = process_val.cpu_usage(); if unnormalized_cpu || num_processors == 0 { usage } else { - usage / (num_processors as f64) + usage / num_processors as f32 } }; + let process_cpu_usage = if use_current_cpu_total && cpu_usage > 0.0 { pcu / cpu_usage } else { pcu - } as f32; + }; let disk_usage = process_val.disk_usage(); - let process_state = (process_val.status().to_string(), 'R'); + let process_state = (process_status_str(process_val.status()), 'R'); #[cfg(feature = "gpu")] let (gpu_mem, gpu_util, gpu_mem_percent) = { @@ -92,12 +98,13 @@ pub fn sysinfo_process_data( } else { 0.0 } as f32, - mem_usage_bytes: process_val.memory(), + mem_usage: process_val.memory(), + virtual_mem: process_val.virtual_memory(), cpu_usage_percent: process_cpu_usage, - read_bytes_per_sec: disk_usage.read_bytes, - write_bytes_per_sec: disk_usage.written_bytes, - total_read_bytes: disk_usage.total_read_bytes, - total_write_bytes: disk_usage.total_written_bytes, + read_per_sec: disk_usage.read_bytes, + write_per_sec: disk_usage.written_bytes, + total_read: disk_usage.total_read_bytes, + total_write: disk_usage.total_written_bytes, process_state, user: process_val .user_id() diff --git a/src/collection/temperature.rs b/src/collection/temperature.rs new file mode 100644 index 000000000..4e65e15e7 --- /dev/null +++ b/src/collection/temperature.rs @@ -0,0 +1,23 @@ +//! Data collection for temperature metrics. +//! +//! For Linux, this is handled by custom code. +//! For everything else, this is handled by sysinfo. + +cfg_if::cfg_if! { + if #[cfg(target_os = "linux")] { + pub mod linux; + pub use self::linux::*; + } else if #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "windows", target_os = "android", target_os = "ios"))] { + pub mod sysinfo; + pub use self::sysinfo::*; + } +} + +#[derive(Default, Debug, Clone)] +pub struct TempSensorData { + /// The name of the sensor. + pub name: String, + + /// The temperature in Celsius. + pub temperature: Option, +} diff --git a/src/data_collection/temperature/linux.rs b/src/collection/temperature/linux.rs similarity index 79% rename from src/data_collection/temperature/linux.rs rename to src/collection/temperature/linux.rs index 3f1ac0955..30b50f4d1 100644 --- a/src/data_collection/temperature/linux.rs +++ b/src/collection/temperature/linux.rs @@ -8,15 +8,17 @@ use std::{ use anyhow::Result; use hashbrown::{HashMap, HashSet}; -use super::{is_temp_filtered, TempHarvest, TemperatureType}; -use crate::app::filter::Filter; +use super::TempSensorData; +#[cfg(feature = "gpu")] +use crate::collection::amd::get_amd_name; +use crate::{app::filter::Filter, collection::linux::utils::is_device_awake}; const EMPTY_NAME: &str = "Unknown"; /// Returned results from grabbing hwmon/coretemp temperature sensor -/// values/names. +/// values or names. struct HwmonResults { - temperatures: Vec, + temperatures: Vec, num_hwmon: usize, } @@ -115,40 +117,42 @@ fn counted_name(seen_names: &mut HashMap, name: String) -> String { } } -#[inline] +fn uppercase_first_letter(s: &mut str) { + if let Some(r) = s.get_mut(0..1) { + r.make_ascii_uppercase(); + } +} + fn finalize_name( hwmon_name: Option, sensor_label: Option, fallback_sensor_name: &Option, seen_names: &mut HashMap, ) -> String { let candidate_name = match (hwmon_name, sensor_label) { - (Some(name), Some(label)) => match (name.is_empty(), label.is_empty()) { + (Some(name), Some(mut label)) => match (name.is_empty(), label.is_empty()) { (false, false) => { + uppercase_first_letter(&mut label); format!("{name}: {label}") } - (true, false) => match fallback_sensor_name { - Some(fallback) if !fallback.is_empty() => { - if label.is_empty() { - fallback.to_owned() - } else { + (true, false) => { + uppercase_first_letter(&mut label); + + // We assume label must not be empty. + match fallback_sensor_name { + Some(fallback) if !fallback.is_empty() => { format!("{fallback}: {label}") } + _ => label, } - _ => { - if label.is_empty() { - EMPTY_NAME.to_string() - } else { - label - } - } - }, + } (false, true) => name.to_owned(), (true, true) => EMPTY_NAME.to_string(), }, - (None, Some(label)) => match fallback_sensor_name { + (None, Some(mut label)) => match fallback_sensor_name { Some(fallback) if !fallback.is_empty() => { if label.is_empty() { fallback.to_owned() } else { + uppercase_first_letter(&mut label); format!("{fallback}: {label}") } } @@ -156,6 +160,7 @@ fn finalize_name( if label.is_empty() { EMPTY_NAME.to_string() } else { + uppercase_first_letter(&mut label); label } } @@ -176,34 +181,6 @@ fn finalize_name( counted_name(seen_names, candidate_name) } -/// Whether the temperature should *actually* be read during enumeration. -/// Will return false if the state is not D0/unknown, or if it does not support -/// `device/power_state`. -#[inline] -fn is_device_awake(path: &Path) -> bool { - // Whether the temperature should *actually* be read during enumeration. - // Set to false if the device is in ACPI D3cold. - // Documented at https://www.kernel.org/doc/Documentation/ABI/testing/sysfs-devices-power_state - let device = path.join("device"); - let power_state = device.join("power_state"); - if power_state.exists() { - if let Ok(state) = fs::read_to_string(power_state) { - let state = state.trim(); - // The zenpower3 kernel module (incorrectly?) reports "unknown", causing this - // check to fail and temperatures to appear as zero instead of - // having the file not exist. - // - // Their self-hosted git instance has disabled sign up, so this bug cant be - // reported either. - state == "D0" || state == "unknown" - } else { - true - } - } else { - true - } -} - /// Get temperature sensors from the linux sysfs interface `/sys/class/hwmon` /// and `/sys/devices/platform/coretemp.*`. It returns all found temperature /// sensors, and the number of checked hwmon directories (not coretemp @@ -223,8 +200,8 @@ fn is_device_awake(path: &Path) -> bool { /// the device is already in ACPI D0. This has the notable issue that /// once this happens, the device will be *kept* on through the sensor /// reading, and not be able to re-enter ACPI D3cold. -fn hwmon_temperatures(temp_type: &TemperatureType, filter: &Option) -> HwmonResults { - let mut temperatures: Vec = vec![]; +fn hwmon_temperatures(filter: &Option) -> HwmonResults { + let mut temperatures: Vec = vec![]; let mut seen_names: HashMap = HashMap::new(); let (dirs, num_hwmon) = get_hwmon_candidates(); @@ -243,10 +220,11 @@ fn hwmon_temperatures(temp_type: &TemperatureType, filter: &Option) -> H // also allow easy cancellation/timeouts. for file_path in dirs { let sensor_name = read_to_string_lossy(file_path.join("name")); + let device = file_path.join("device"); - if !is_device_awake(&file_path) { + if !is_device_awake(&device) { let name = finalize_name(None, None, &sensor_name, &mut seen_names); - temperatures.push(TempHarvest { + temperatures.push(TempSensorData { name, temperature: None, }); @@ -284,23 +262,44 @@ fn hwmon_temperatures(temp_type: &TemperatureType, filter: &Option) -> H if drm.exists() { // This should never actually be empty. If it is though, we'll fall back to // the sensor name later on. - let mut gpu = None; - - if let Ok(cards) = drm.read_dir() { - for card in cards.flatten() { - if let Some(name) = card.file_name().to_str() { - if name.starts_with("card") { - gpu = Some(humanize_name( - name.trim().to_string(), - sensor_name.as_ref(), - )); - break; - } - } + + #[cfg(feature = "gpu")] + { + if let Some(amd_gpu_name) = get_amd_name(&device) { + Some(amd_gpu_name) + } else if let Ok(cards) = drm.read_dir() { + cards.flatten().find_map(|card| { + card.file_name().to_str().and_then(|name| { + name.starts_with("card").then(|| { + humanize_name( + name.trim().to_string(), + sensor_name.as_ref(), + ) + }) + }) + }) + } else { + None } } - gpu + #[cfg(not(feature = "gpu"))] + { + if let Ok(cards) = drm.read_dir() { + cards.flatten().find_map(|card| { + card.file_name().to_str().and_then(|name| { + name.starts_with("card").then(|| { + humanize_name( + name.trim().to_string(), + sensor_name.as_ref(), + ) + }) + }) + }) + } else { + None + } + } } else { // This little mess is to account for stuff like k10temp. This is needed // because the `device` symlink points to `nvme*` @@ -327,11 +326,11 @@ fn hwmon_temperatures(temp_type: &TemperatureType, filter: &Option) -> H // TODO: It's possible we may want to move the filter check further up to avoid // probing hwmon if not needed? - if is_temp_filtered(filter, &name) { + if Filter::optional_should_keep(filter, &name) { if let Ok(temp_celsius) = parse_temp(&temp_path) { - temperatures.push(TempHarvest { + temperatures.push(TempSensorData { name, - temperature: Some(temp_type.convert_temp_unit(temp_celsius)), + temperature: Some(temp_celsius), }); } } @@ -351,9 +350,7 @@ fn hwmon_temperatures(temp_type: &TemperatureType, filter: &Option) -> H /// /// See [the Linux kernel documentation](https://www.kernel.org/doc/Documentation/ABI/testing/sysfs-class-thermal) /// for more details. -fn add_thermal_zone_temperatures( - temperatures: &mut Vec, temp_type: &TemperatureType, filter: &Option, -) { +fn add_thermal_zone_temperatures(temperatures: &mut Vec, filter: &Option) { let path = Path::new("/sys/class/thermal"); let Ok(read_dir) = path.read_dir() else { return; @@ -377,14 +374,14 @@ fn add_thermal_zone_temperatures( name }; - if is_temp_filtered(filter, &name) { + if Filter::optional_should_keep(filter, &name) { let temp_path = file_path.join("temp"); if let Ok(temp_celsius) = parse_temp(&temp_path) { let name = counted_name(&mut seen_names, name); - temperatures.push(TempHarvest { + temperatures.push(TempSensorData { name, - temperature: Some(temp_type.convert_temp_unit(temp_celsius)), + temperature: Some(temp_celsius), }); } } @@ -394,13 +391,11 @@ fn add_thermal_zone_temperatures( } /// Gets temperature sensors and data. -pub fn get_temperature_data( - temp_type: &TemperatureType, filter: &Option, -) -> Result>> { - let mut results = hwmon_temperatures(temp_type, filter); +pub fn get_temperature_data(filter: &Option) -> Result>> { + let mut results = hwmon_temperatures(filter); if results.num_hwmon == 0 { - add_thermal_zone_temperatures(&mut results.temperatures, temp_type, filter); + add_thermal_zone_temperatures(&mut results.temperatures, filter); } Ok(Some(results.temperatures)) @@ -423,7 +418,7 @@ mod tests { &Some("test".to_string()), &mut seen_names ), - "hwmon: sensor" + "hwmon: Sensor" ); assert_eq!( @@ -443,7 +438,7 @@ mod tests { &Some("test".to_string()), &mut seen_names ), - "test: sensor" + "test: Sensor" ); assert_eq!( @@ -453,7 +448,7 @@ mod tests { &Some("test".to_string()), &mut seen_names ), - "hwmon: sensor (1)" + "hwmon: Sensor (1)" ); assert_eq!( diff --git a/src/data_collection/temperature/sysinfo.rs b/src/collection/temperature/sysinfo.rs similarity index 52% rename from src/data_collection/temperature/sysinfo.rs rename to src/collection/temperature/sysinfo.rs index 9e6e797ed..79ea08219 100644 --- a/src/data_collection/temperature/sysinfo.rs +++ b/src/collection/temperature/sysinfo.rs @@ -2,21 +2,21 @@ use anyhow::Result; -use super::{is_temp_filtered, TempHarvest, TemperatureType}; +use super::TempSensorData; use crate::app::filter::Filter; pub fn get_temperature_data( - components: &sysinfo::Components, temp_type: &TemperatureType, filter: &Option, -) -> Result>> { - let mut temperature_vec: Vec = Vec::new(); + components: &sysinfo::Components, filter: &Option, +) -> Result>> { + let mut temperatures: Vec = Vec::new(); for component in components { let name = component.label().to_string(); - if is_temp_filtered(filter, &name) { - temperature_vec.push(TempHarvest { + if Filter::optional_should_keep(filter, &name) { + temperatures.push(TempSensorData { name, - temperature: Some(temp_type.convert_temp_unit(component.temperature())), + temperature: component.temperature(), }); } } @@ -32,13 +32,9 @@ pub fn get_temperature_data( for ctl in sysctl::CtlIter::below(root).flatten() { if let (Ok(name), Ok(temp)) = (ctl.name(), ctl.value()) { if let Some(temp) = temp.as_temperature() { - temperature_vec.push(TempHarvest { + temperatures.push(TempSensorData { name, - temperature: Some(match temp_type { - TemperatureType::Celsius => temp.celsius(), - TemperatureType::Kelvin => temp.kelvin(), - TemperatureType::Fahrenheit => temp.fahrenheit(), - }), + temperature: Some(temp.celsius()), }); } } @@ -47,5 +43,5 @@ pub fn get_temperature_data( } // TODO: Should we instead use a hashmap -> vec to skip dupes? - Ok(Some(temperature_vec)) + Ok(Some(temperatures)) } diff --git a/src/constants.rs b/src/constants.rs index 75358234d..a28cb3a3b 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,29 +1,16 @@ -use tui::widgets::Borders; +//! A bunch of constants used throughout the application. +//! +//! FIXME: Move these to where it makes more sense. // Default widget ID pub const DEFAULT_WIDGET_ID: u64 = 56709; -// How much data is SHOWN -pub const DEFAULT_TIME_MILLISECONDS: u64 = 60 * 1000; // Defaults to 1 min. -pub const STALE_MIN_MILLISECONDS: u64 = 30 * 1000; // Lowest is 30 seconds -pub const TIME_CHANGE_MILLISECONDS: u64 = 15 * 1000; // How much to increment each time -pub const AUTOHIDE_TIMEOUT_MILLISECONDS: u64 = 5000; // 5 seconds to autohide - -pub const TICK_RATE_IN_MILLISECONDS: u64 = 200; -// How fast the screen refreshes -pub const DEFAULT_REFRESH_RATE_IN_MILLISECONDS: u64 = 1000; -pub const MAX_KEY_TIMEOUT_IN_MILLISECONDS: u64 = 1000; - // Limits for when we should stop showing table gaps/labels (anything less means // not shown) pub const TABLE_GAP_HEIGHT_LIMIT: u16 = 7; -pub const TIME_LABEL_HEIGHT_LIMIT: u16 = 7; - -// Side borders -pub const SIDE_BORDERS: Borders = Borders::LEFT.union(Borders::RIGHT); // Help text -pub const HELP_CONTENTS_TEXT: [&str; 10] = [ +const HELP_CONTENTS_TEXT: [&str; 10] = [ "Either scroll or press the number key to go to the corresponding help menu section:", "1 - General", "2 - CPU widget", @@ -38,7 +25,7 @@ pub const HELP_CONTENTS_TEXT: [&str; 10] = [ // TODO [Help]: Search in help? // TODO [Help]: Move to using tables for easier formatting? -pub const GENERAL_HELP_TEXT: [&str; 32] = [ +pub(crate) const GENERAL_HELP_TEXT: [&str; 32] = [ "1 - General", "q, Ctrl-c Quit", "Esc Close dialog windows, search, widgets, or exit expanded mode", @@ -73,14 +60,14 @@ pub const GENERAL_HELP_TEXT: [&str; 32] = [ "Mouse click Selects the clicked widget, table entry, dialog option, or tab", ]; -pub const CPU_HELP_TEXT: [&str; 2] = [ +const CPU_HELP_TEXT: [&str; 2] = [ "2 - CPU widget", "Mouse scroll Scrolling over an CPU core/average shows only that entry on the chart", ]; -pub const PROCESS_HELP_TEXT: [&str; 17] = [ +const PROCESS_HELP_TEXT: [&str; 19] = [ "3 - Process widget", - "dd, F9 Kill the selected process", + "dd, F9, Delete Kill the selected process", "c Sort by CPU usage, press again to reverse", "m Sort by memory usage, press again to reverse", "p Sort by PID name, press again to reverse", @@ -92,13 +79,15 @@ pub const PROCESS_HELP_TEXT: [&str; 17] = [ "I Invert current sort", "% Toggle between values and percentages for memory usage", "t, F5 Toggle tree mode", - "+, -, click Collapse/expand a branch while in tree mode", + "Right Collapse a branch while in tree mode", + "Left Expand a branch while in tree mode", + "+, -, click Toggle whether a branch is expanded or collapsed in tree mode", "click on header Sorts the entries by that column, click again to invert the sort", "C Sort by GPU usage, press again to reverse", "M Sort by GPU memory usage, press again to reverse", ]; -pub const SEARCH_HELP_TEXT: [&str; 51] = [ +const SEARCH_HELP_TEXT: [&str; 51] = [ "4 - Process search widget", "Esc Close the search widget (retains the filter)", "Ctrl-a Skip to the start of the search query", @@ -152,7 +141,7 @@ pub const SEARCH_HELP_TEXT: [&str; 51] = [ "TiB ex: read > 1 tib", ]; -pub const SORT_HELP_TEXT: [&str; 6] = [ +const SORT_HELP_TEXT: [&str; 6] = [ "5 - Sort widget", "Down, 'j' Scroll down in list", "Up, 'k' Scroll up in list", @@ -161,13 +150,13 @@ pub const SORT_HELP_TEXT: [&str; 6] = [ "Enter Sort by current selected column", ]; -pub const TEMP_HELP_WIDGET: [&str; 3] = [ +const TEMP_HELP_WIDGET: [&str; 3] = [ "6 - Temperature widget", "'s' Sort by sensor name, press again to reverse", "'t' Sort by temperature, press again to reverse", ]; -pub const DISK_HELP_WIDGET: [&str; 9] = [ +const DISK_HELP_WIDGET: [&str; 9] = [ "7 - Disk widget", "'d' Sort by disk name, press again to reverse", "'m' Sort by disk mount, press again to reverse", @@ -179,18 +168,18 @@ pub const DISK_HELP_WIDGET: [&str; 9] = [ "'w' Sort by disk write activity, press again to reverse", ]; -pub const BATTERY_HELP_TEXT: [&str; 3] = [ +const BATTERY_HELP_TEXT: [&str; 3] = [ "8 - Battery widget", "Left Go to previous battery", "Right Go to next battery", ]; -pub const BASIC_MEM_HELP_TEXT: [&str; 2] = [ +const BASIC_MEM_HELP_TEXT: [&str; 2] = [ "9 - Basic memory widget", "% Toggle between values and percentages for memory usage", ]; -pub const HELP_TEXT: [&[&str]; HELP_CONTENTS_TEXT.len()] = [ +pub(crate) const HELP_TEXT: [&[&str]; HELP_CONTENTS_TEXT.len()] = [ &HELP_CONTENTS_TEXT, &GENERAL_HELP_TEXT, &CPU_HELP_TEXT, @@ -203,8 +192,7 @@ pub const HELP_TEXT: [&[&str]; HELP_CONTENTS_TEXT.len()] = [ &BASIC_MEM_HELP_TEXT, ]; -// Default layouts -pub const DEFAULT_LAYOUT: &str = r#" +pub(crate) const DEFAULT_LAYOUT: &str = r#" [[row]] ratio=30 [[row.child]] @@ -229,7 +217,7 @@ pub const DEFAULT_LAYOUT: &str = r#" default=true "#; -pub const DEFAULT_BATTERY_LAYOUT: &str = r#" +pub(crate) const DEFAULT_BATTERY_LAYOUT: &str = r#" [[row]] ratio=30 [[row.child]] @@ -258,37 +246,46 @@ pub const DEFAULT_BATTERY_LAYOUT: &str = r#" default=true "#; -// Config and flags - // TODO: Eventually deprecate this, or grab from a file. -pub const CONFIG_TEXT: &str = r#"# This is a default config file for bottom. All of the settings are commented +pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bottom. All of the settings are commented # out by default; if you wish to change them uncomment and modify as you see # fit. -# This group of options represents a command-line option. Flags explicitly +# This group of options represents a command-line option. Flags explicitly # added when running (ie: btm -a) will override this config file if an option # is also set here. [flags] # Whether to hide the average cpu entry. #hide_avg_cpu = false + # Whether to use dot markers rather than braille. #dot_marker = false + # The update rate of the application. #rate = "1s" + # Whether to put the CPU legend to the left. #cpu_left_legend = false + # Whether to set CPU% on a process to be based on the total CPU or just current usage. #current_usage = false + # Whether to set CPU% on a process to be based on the total CPU or per-core CPU% (not divided by the number of cpus). #unnormalized_cpu = false -# Whether to group processes with the same name together by default. + +# Whether to group processes with the same name together by default. Doesn't do anything +# if tree is set to true or --tree is set. #group_processes = false + # Whether to make process searching case sensitive by default. #case_sensitive = false + # Whether to make process searching look for matching the entire word by default. #whole_word = false + # Whether to make process searching use regex by default. #regex = false + # The temperature unit. One of the following, defaults to "c" for Celsius: #temperature_type = "c" ##temperature_type = "k" @@ -296,103 +293,176 @@ pub const CONFIG_TEXT: &str = r#"# This is a default config file for bottom. Al ##temperature_type = "kelvin" ##temperature_type = "fahrenheit" ##temperature_type = "celsius" + # The default time interval (in milliseconds). #default_time_value = "60s" + # The time delta on each zoom in/out action (in milliseconds). #time_delta = 15000 + # Hides the time scale. #hide_time = false + # Override layout default widget #default_widget_type = "proc" #default_widget_count = 1 + # Expand selected widget upon starting the app #expanded = true + # Use basic mode #basic = false + # Use the old network legend style #use_old_network_legend = false + # Remove space in tables #hide_table_gap = false + # Show the battery widgets #battery = false + # Disable mouse clicks #disable_click = false + # Show memory values in the processes widget as values by default #process_memory_as_value = false + # Show tree mode by default in the processes widget. #tree = false + # Shows an indicator in table widgets tracking where in the list you are. #show_table_scroll_position = false + # Show processes as their commands by default in the process widget. #process_command = false + # Displays the network widget with binary prefixes. #network_use_binary_prefix = false + # Displays the network widget using bytes. #network_use_bytes = false + # Displays the network widget with a log scale. #network_use_log = false + # Hides advanced options to stop a process on Unix-like systems. #disable_advanced_kill = false -# Shows GPU(s) information -#enable_gpu = false + +# Hide GPU(s) information +#disable_gpu = false + # Shows cache and buffer memory #enable_cache_memory = false + # How much data is stored at once in terms of time. #retention = "10m" + # Where to place the legend for the memory widget. One of "none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right". -#memory_legend = "TopRight". +#memory_legend = "top-right" + # Where to place the legend for the network widget. One of "none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right". -#network_legend = "TopRight". +#network_legend = "top-right" + # Processes widget configuration #[processes] -# The columns shown by the process widget. The following columns are supported: +# The columns shown by the process widget. The following columns are supported (the GPU columns are only available if the GPU feature is enabled when built): # PID, Name, CPU%, Mem%, R/s, W/s, T.Read, T.Write, User, State, Time, GMem%, GPU% #columns = ["PID", "Name", "CPU%", "Mem%", "R/s", "W/s", "T.Read", "T.Write", "User", "State", "GMem%", "GPU%"] + # CPU widget configuration #[cpu] # One of "all" (default), "average"/"avg" -# default = "average" +#default = "average" + # Disk widget configuration #[disk] -#[name_filter] +# The columns shown by the process widget. The following columns are supported: +# Disk, Mount, Used, Free, Total, Used%, Free%, R/s, W/s +#columns = ["Disk", "Mount", "Used", "Free", "Total", "Used%", "R/s", "W/s"] + +# By default, there are no disk name filters enabled. These can be turned on to filter out specific data entries if you +# don't want to see them. An example use case is provided below. +#[disk.name_filter] +# Whether to ignore any matches. Defaults to true. #is_list_ignored = true + +# A list of filters to try and match. #list = ["/dev/sda\\d+", "/dev/nvme0n1p2"] + +# Whether to use regex. Defaults to false. #regex = true + +# Whether to be case-sensitive. Defaults to false. #case_sensitive = false + +# Whether to be require matching the whole word. Defaults to false. #whole_word = false -#[mount_filter] +# By default, there are no mount name filters enabled. An example use case is provided below. +#[disk.mount_filter] +# Whether to ignore any matches. Defaults to true. #is_list_ignored = true + +# A list of filters to try and match. #list = ["/mnt/.*", "/boot"] + +# Whether to use regex. Defaults to false. #regex = true + +# Whether to be case-sensitive. Defaults to false. #case_sensitive = false + +# Whether to be require matching the whole word. Defaults to false. #whole_word = false + # Temperature widget configuration #[temperature] -#[sensor_filter] +# By default, there are no temperature sensor filters enabled. An example use case is provided below. +#[temperature.sensor_filter] +# Whether to ignore any matches. Defaults to true. #is_list_ignored = true + +# A list of filters to try and match. #list = ["cpu", "wifi"] + +# Whether to use regex. Defaults to false. #regex = false + +# Whether to be case-sensitive. Defaults to false. #case_sensitive = false + +# Whether to be require matching the whole word. Defaults to false. #whole_word = false + # Network widget configuration #[network] -#[interface_filter] +# By default, there are no network interface filters enabled. An example use case is provided below. +#[network.interface_filter] +# Whether to ignore any matches. Defaults to true. #is_list_ignored = true + +# A list of filters to try and match. #list = ["virbr0.*"] + +# Whether to use regex. Defaults to false. #regex = true + +# Whether to be case-sensitive. Defaults to false. #case_sensitive = false + +# Whether to be require matching the whole word. Defaults to false. #whole_word = false + # These are all the components that support custom theming. Note that colour support # will depend on terminal support. #[styles] # Uncomment if you want to use custom styling - # Built-in themes. Valid values are: # - "default" # - "default-light" @@ -475,41 +545,6 @@ pub const CONFIG_TEXT: &str = r#"# This is a default config file for bottom. Al # default=true "#; -pub const CONFIG_TOP_HEAD: &str = r##"# This is bottom's config file. -# Values in this config file will change when changed in the interface. -# You can also manually change these values. -# Be aware that contents of this file will be overwritten if something is -# changed in the application; you can disable writing via the -# --no_write flag or no_write config option. - -"##; - -pub const CONFIG_DISPLAY_OPTIONS_HEAD: &str = r#" -# These options represent settings that affect how bottom functions. -# If a setting here corresponds to command-line option, then the flag will temporarily override -# the setting. -"#; - -pub const CONFIG_COLOUR_HEAD: &str = r#" -# These options represent colour values for various parts of bottom. Note that colour support -# will ultimately depend on the terminal - for example, the Terminal for macOS does NOT like -# custom colours and it may glitch out. -"#; - -pub const CONFIG_LAYOUT_HEAD: &str = r#" -# These options represent how bottom will lay out its widgets. Layouts follow a pattern like this: -# [[row]] represents a row in the application. -# [[row.child]] represents either a widget or a column. -# [[row.child.child]] represents a widget. -# -# All widgets must have the valid type value set to one of ["cpu", "mem", "proc", "net", "temp", "disk", "empty"]. -# All layout components have a ratio value - if this is not set, then it defaults to 1. -"#; - -pub const CONFIG_FILTER_HEAD: &str = r#" -# These options represent disabled entries for the temperature and disk widgets. -"#; - #[cfg(test)] mod test { use super::*; @@ -534,13 +569,26 @@ mod test { } } - /// This test exists because previously, [`SIDE_BORDERS`] was set - /// incorrectly after I moved from tui-rs to ratatui. + /// Checks that the default config is valid. #[test] - fn assert_side_border_bits_match() { - assert_eq!( - SIDE_BORDERS, - Borders::ALL.difference(Borders::TOP.union(Borders::BOTTOM)) - ) + #[cfg(feature = "default")] + fn check_default_config() { + use regex::Regex; + + use crate::options::Config; + + let default_config = Regex::new(r"(?m)^#([a-zA-Z\[])") + .unwrap() + .replace_all(CONFIG_TEXT, "$1"); + + let default_config = Regex::new(r"(?m)^#(\s\s+)([a-zA-Z\[])") + .unwrap() + .replace_all(&default_config, "$2"); + + let _config: Config = + toml_edit::de::from_str(&default_config).expect("can parse default config"); + + // TODO: Check this. + // assert_eq!(config, Config::default()); } } diff --git a/src/data_collection/batteries.rs b/src/data_collection/batteries.rs deleted file mode 100644 index a155ad2d2..000000000 --- a/src/data_collection/batteries.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Data collection for batteries. -//! -//! For Linux, macOS, Windows, FreeBSD, Dragonfly, and iOS, this is handled by -//! the battery crate. - -cfg_if::cfg_if! { - if #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "ios"))] { - pub mod battery; - pub use self::battery::*; - } -} diff --git a/src/data_collection/batteries/battery.rs b/src/data_collection/batteries/battery.rs deleted file mode 100644 index ec95ada2c..000000000 --- a/src/data_collection/batteries/battery.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Uses the battery crate from svartalf. -//! Covers battery usage for: -//! - Linux 2.6.39+ -//! - MacOS 10.10+ -//! - iOS -//! - Windows 7+ -//! - FreeBSD -//! - DragonFlyBSD -//! -//! For more information, refer to the [starship_battery](https://github.com/starship/rust-battery) repo/docs. - -use starship_battery::{ - units::{power::watt, ratio::percent, time::second}, - Battery, Manager, State, -}; - -#[derive(Debug, Clone)] -pub struct BatteryHarvest { - pub charge_percent: f64, - pub secs_until_full: Option, - pub secs_until_empty: Option, - pub power_consumption_rate_watts: f64, - pub health_percent: f64, - pub state: State, -} - -pub fn refresh_batteries(manager: &Manager, batteries: &mut [Battery]) -> Vec { - batteries - .iter_mut() - .filter_map(|battery| { - if manager.refresh(battery).is_ok() { - Some(BatteryHarvest { - secs_until_full: { - let optional_time = battery.time_to_full(); - optional_time.map(|time| f64::from(time.get::()) as i64) - }, - secs_until_empty: { - let optional_time = battery.time_to_empty(); - optional_time.map(|time| f64::from(time.get::()) as i64) - }, - charge_percent: f64::from(battery.state_of_charge().get::()), - power_consumption_rate_watts: f64::from(battery.energy_rate().get::()), - health_percent: f64::from(battery.state_of_health().get::()), - state: battery.state(), - }) - } else { - None - } - }) - .collect::>() -} diff --git a/src/data_collection/cpu/sysinfo.rs b/src/data_collection/cpu/sysinfo.rs deleted file mode 100644 index 72a8a5582..000000000 --- a/src/data_collection/cpu/sysinfo.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! CPU stats through sysinfo. -//! Supports FreeBSD. - -use std::collections::VecDeque; - -use sysinfo::{LoadAvg, System}; - -use super::{CpuData, CpuDataType, CpuHarvest}; -use crate::data_collection::{cpu::LoadAvgHarvest, error::CollectionResult}; - -pub fn get_cpu_data_list(sys: &System, show_average_cpu: bool) -> CollectionResult { - let mut cpu_deque: VecDeque<_> = sys - .cpus() - .iter() - .enumerate() - .map(|(i, cpu)| CpuData { - data_type: CpuDataType::Cpu(i), - cpu_usage: cpu.cpu_usage() as f64, - }) - .collect(); - - if show_average_cpu { - let cpu = sys.global_cpu_info(); - - cpu_deque.push_front(CpuData { - data_type: CpuDataType::Avg, - cpu_usage: cpu.cpu_usage() as f64, - }) - } - - Ok(Vec::from(cpu_deque)) -} - -pub fn get_load_avg() -> LoadAvgHarvest { - // The API for sysinfo apparently wants you to call it like this, rather than - // using a &System. - let LoadAvg { one, five, fifteen } = sysinfo::System::load_average(); - - [one as f32, five as f32, fifteen as f32] -} diff --git a/src/data_collection/memory.rs b/src/data_collection/memory.rs deleted file mode 100644 index b953e2bfe..000000000 --- a/src/data_collection/memory.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Memory data collection. - -#[cfg(not(target_os = "windows"))] -pub(crate) use self::sysinfo::get_cache_usage; -pub(crate) use self::sysinfo::{get_ram_usage, get_swap_usage}; - -pub mod sysinfo; -// cfg_if::cfg_if! { -// if #[cfg(target_os = "windows")] { -// mod windows; -// pub(crate) use self::windows::get_committed_usage; -// } -// } - -#[cfg(feature = "zfs")] -pub mod arc; - -#[derive(Debug, Clone, Default)] -pub struct MemHarvest { - pub used_bytes: u64, - pub total_bytes: u64, - pub use_percent: Option, /* TODO: Might be find to just make this an f64, and any - * consumer checks NaN. */ -} diff --git a/src/data_collection/memory/sysinfo.rs b/src/data_collection/memory/sysinfo.rs deleted file mode 100644 index 1f5606dec..000000000 --- a/src/data_collection/memory/sysinfo.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Collecting memory data using sysinfo. - -use sysinfo::System; - -use crate::data_collection::memory::MemHarvest; - -/// Returns RAM usage. -pub(crate) fn get_ram_usage(sys: &System) -> Option { - let mem_used = sys.used_memory(); - let mem_total = sys.total_memory(); - - Some(MemHarvest { - used_bytes: mem_used, - total_bytes: mem_total, - use_percent: if mem_total == 0 { - None - } else { - Some(mem_used as f64 / mem_total as f64 * 100.0) - }, - }) -} - -/// Returns SWAP usage. -pub(crate) fn get_swap_usage(sys: &System) -> Option { - let mem_used = sys.used_swap(); - let mem_total = sys.total_swap(); - - Some(MemHarvest { - used_bytes: mem_used, - total_bytes: mem_total, - use_percent: if mem_total == 0 { - None - } else { - Some(mem_used as f64 / mem_total as f64 * 100.0) - }, - }) -} - -/// Returns cache usage. sysinfo has no way to do this directly but it should -/// equal the difference between the available and free memory. Free memory is -/// defined as memory not containing any data, which means cache and buffer -/// memory are not "free". Available memory is defined as memory able -/// to be allocated by processes, which includes cache and buffer memory. On -/// Windows, this will always be 0. For more information, see [docs](https://docs.rs/sysinfo/latest/sysinfo/struct.System.html#method.available_memory) -/// and [memory explanation](https://askubuntu.com/questions/867068/what-is-available-memory-while-using-free-command) -#[cfg(not(target_os = "windows"))] -pub(crate) fn get_cache_usage(sys: &System) -> Option { - let mem_used = sys.available_memory().saturating_sub(sys.free_memory()); - let mem_total = sys.total_memory(); - - Some(MemHarvest { - total_bytes: mem_total, - used_bytes: mem_used, - use_percent: if mem_total == 0 { - None - } else { - Some(mem_used as f64 / mem_total as f64 * 100.0) - }, - }) -} diff --git a/src/data_collection/memory/windows.rs b/src/data_collection/memory/windows.rs deleted file mode 100644 index 707920792..000000000 --- a/src/data_collection/memory/windows.rs +++ /dev/null @@ -1,32 +0,0 @@ -use std::mem::{size_of, zeroed}; - -use windows::Win32::System::ProcessStatus::{GetPerformanceInfo, PERFORMANCE_INFORMATION}; - -use crate::data_collection::memory::MemHarvest; - -const PERFORMANCE_INFORMATION_SIZE: u32 = size_of::() as _; - -/// Get the committed memory usage. -/// -/// Code based on [sysinfo's](https://github.com/GuillaumeGomez/sysinfo/blob/6f8178495adcf3ca4696a9ec548586cf6a621bc8/src/windows/system.rs#L169). -pub(crate) fn get_committed_usage() -> Option { - // SAFETY: The safety invariant is that we only touch what's in `perf_info` if it succeeds, and that - // the bindings are "safe" to use with how we call them. - unsafe { - let mut perf_info: PERFORMANCE_INFORMATION = zeroed(); - if GetPerformanceInfo(&mut perf_info, PERFORMANCE_INFORMATION_SIZE).is_ok() { - let page_size = perf_info.PageSize; - - let committed_total = page_size.saturating_mul(perf_info.CommitLimit) as u64; - let committed_used = page_size.saturating_mul(perf_info.CommitTotal) as u64; - - Some(MemHarvest { - used_bytes: committed_used, - total_bytes: committed_total, - use_percent: Some(committed_used as f64 / committed_total as f64 * 100.0), - }) - } else { - None - } - } -} diff --git a/src/data_collection/temperature.rs b/src/data_collection/temperature.rs deleted file mode 100644 index 414765f45..000000000 --- a/src/data_collection/temperature.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! Data collection for temperature metrics. -//! -//! For Linux and macOS, this is handled by Heim. -//! For Windows, this is handled by sysinfo. - -cfg_if::cfg_if! { - if #[cfg(target_os = "linux")] { - pub mod linux; - pub use self::linux::*; - } else if #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "windows", target_os = "android", target_os = "ios"))] { - pub mod sysinfo; - pub use self::sysinfo::*; - } -} - -use std::str::FromStr; - -use crate::app::filter::Filter; - -#[derive(Default, Debug, Clone)] -pub struct TempHarvest { - pub name: String, - pub temperature: Option, -} - -#[derive(Clone, Debug, Copy, PartialEq, Eq, Default)] -pub enum TemperatureType { - #[default] - Celsius, - Kelvin, - Fahrenheit, -} - -impl FromStr for TemperatureType { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "fahrenheit" | "f" => Ok(TemperatureType::Fahrenheit), - "kelvin" | "k" => Ok(TemperatureType::Kelvin), - "celsius" | "c" => Ok(TemperatureType::Celsius), - _ => Err(format!( - "'{s}' is an invalid temperature type, use one of: [kelvin, k, celsius, c, fahrenheit, f]." - )), - } - } -} - -impl TemperatureType { - /// Given a temperature in Celsius, covert it if necessary for a different - /// unit. - pub fn convert_temp_unit(&self, temp_celsius: f32) -> f32 { - fn convert_celsius_to_kelvin(celsius: f32) -> f32 { - celsius + 273.15 - } - - fn convert_celsius_to_fahrenheit(celsius: f32) -> f32 { - (celsius * (9.0 / 5.0)) + 32.0 - } - - match self { - TemperatureType::Celsius => temp_celsius, - TemperatureType::Kelvin => convert_celsius_to_kelvin(temp_celsius), - TemperatureType::Fahrenheit => convert_celsius_to_fahrenheit(temp_celsius), - } - } -} - -pub fn is_temp_filtered(filter: &Option, text: &str) -> bool { - if let Some(filter) = filter { - let mut ret = filter.is_list_ignored; - for r in &filter.list { - if r.is_match(text) { - ret = !filter.is_list_ignored; - break; - } - } - ret - } else { - true - } -} - -#[cfg(test)] -mod test { - use crate::data_collection::temperature::TemperatureType; - - #[test] - fn temp_conversions() { - const TEMP: f32 = 100.0; - - assert_eq!( - TemperatureType::Celsius.convert_temp_unit(TEMP), - TEMP, - "celsius to celsius is the same" - ); - - assert_eq!(TemperatureType::Kelvin.convert_temp_unit(TEMP), 373.15); - - assert_eq!(TemperatureType::Fahrenheit.convert_temp_unit(TEMP), 212.0); - } -} diff --git a/src/data_conversion.rs b/src/data_conversion.rs deleted file mode 100644 index d72fae24b..000000000 --- a/src/data_conversion.rs +++ /dev/null @@ -1,702 +0,0 @@ -//! This mainly concerns converting collected data into things that the canvas -//! can actually handle. - -// TODO: Split this up! - -use std::borrow::Cow; - -use crate::{ - app::{data_farmer::DataCollection, AxisScaling}, - canvas::components::time_chart::Point, - data_collection::{cpu::CpuDataType, memory::MemHarvest, temperature::TemperatureType}, - utils::{data_prefixes::*, data_units::DataUnit}, - widgets::{DiskWidgetData, TempWidgetData}, -}; - -#[derive(Debug, Default)] -pub enum BatteryDuration { - ToEmpty(i64), - ToFull(i64), - Empty, - Full, - #[default] - Unknown, -} - -#[derive(Default, Debug)] -pub struct ConvertedBatteryData { - pub charge_percentage: f64, - pub watt_consumption: String, - pub battery_duration: BatteryDuration, - pub health: String, - pub state: String, -} - -#[derive(Default, Debug)] -pub struct ConvertedNetworkData { - pub rx: Vec, - pub tx: Vec, - pub rx_display: String, - pub tx_display: String, - pub total_rx_display: Option, - pub total_tx_display: Option, - // TODO: [NETWORKING] add min/max/mean of each - // min_rx : f64, - // max_rx : f64, - // mean_rx: f64, - // min_tx: f64, - // max_tx: f64, - // mean_tx: f64, -} - -#[derive(Clone, Debug)] -pub enum CpuWidgetData { - All, - Entry { - data_type: CpuDataType, - /// A point here represents time (x) and value (y). - data: Vec, - last_entry: f64, - }, -} - -#[derive(Default)] -pub struct ConvertedData { - pub rx_display: String, - pub tx_display: String, - pub total_rx_display: String, - pub total_tx_display: String, - pub network_data_rx: Vec, - pub network_data_tx: Vec, - - pub mem_labels: Option<(String, String)>, - #[cfg(not(target_os = "windows"))] - pub cache_labels: Option<(String, String)>, - pub swap_labels: Option<(String, String)>, - - pub mem_data: Vec, /* TODO: Switch this and all data points over to a better data - * structure... */ - #[cfg(not(target_os = "windows"))] - pub cache_data: Vec, - pub swap_data: Vec, - - #[cfg(feature = "zfs")] - pub arc_labels: Option<(String, String)>, - #[cfg(feature = "zfs")] - pub arc_data: Vec, - - #[cfg(feature = "gpu")] - pub gpu_data: Option>, - - pub load_avg_data: [f32; 3], - pub cpu_data: Vec, - pub battery_data: Vec, - pub disk_data: Vec, - pub temp_data: Vec, -} - -impl ConvertedData { - // TODO: Can probably heavily reduce this step to avoid clones. - pub fn convert_disk_data(&mut self, data: &DataCollection) { - self.disk_data.clear(); - - data.disk_harvest - .iter() - .zip(&data.io_labels) - .for_each(|(disk, (io_read, io_write))| { - // Because this sometimes does *not* equal to disk.total. - let summed_total_bytes = match (disk.used_space, disk.free_space) { - (Some(used), Some(free)) => Some(used + free), - _ => None, - }; - - self.disk_data.push(DiskWidgetData { - name: Cow::Owned(disk.name.to_string()), - mount_point: Cow::Owned(disk.mount_point.to_string()), - free_bytes: disk.free_space, - used_bytes: disk.used_space, - total_bytes: disk.total_space, - summed_total_bytes, - io_read: Cow::Owned(io_read.to_string()), - io_write: Cow::Owned(io_write.to_string()), - }); - }); - - self.disk_data.shrink_to_fit(); - } - - pub fn convert_temp_data(&mut self, data: &DataCollection, temperature_type: TemperatureType) { - self.temp_data.clear(); - - data.temp_harvest.iter().for_each(|temp_harvest| { - self.temp_data.push(TempWidgetData { - sensor: Cow::Owned(temp_harvest.name.to_string()), - temperature_value: temp_harvest.temperature.map(|temp| temp.ceil() as u64), - temperature_type, - }); - }); - - self.temp_data.shrink_to_fit(); - } - - pub fn convert_cpu_data(&mut self, current_data: &DataCollection) { - let current_time = current_data.current_instant; - - // (Re-)initialize the vector if the lengths don't match... - if let Some((_time, data)) = ¤t_data.timed_data_vec.last() { - if data.cpu_data.len() + 1 != self.cpu_data.len() { - self.cpu_data = Vec::with_capacity(data.cpu_data.len() + 1); - self.cpu_data.push(CpuWidgetData::All); - self.cpu_data.extend( - data.cpu_data - .iter() - .zip(¤t_data.cpu_harvest) - .map(|(cpu_usage, data)| CpuWidgetData::Entry { - data_type: data.data_type, - data: vec![], - last_entry: *cpu_usage, - }) - .collect::>(), - ); - } else { - self.cpu_data - .iter_mut() - .skip(1) - .zip(&data.cpu_data) - .for_each(|(mut cpu, cpu_usage)| match &mut cpu { - CpuWidgetData::All => unreachable!(), - CpuWidgetData::Entry { - data_type: _, - data, - last_entry, - } => { - // A bit faster to just update all the times, so we just clear the - // vector. - data.clear(); - *last_entry = *cpu_usage; - } - }); - } - } - - // TODO: [Opt] Can probably avoid data deduplication - store the shift + data + - // original once. Now push all the data. - for (itx, mut cpu) in &mut self.cpu_data.iter_mut().skip(1).enumerate() { - match &mut cpu { - CpuWidgetData::All => unreachable!(), - CpuWidgetData::Entry { - data_type: _, - data, - last_entry: _, - } => { - for (time, timed_data) in ¤t_data.timed_data_vec { - let time_start: f64 = - (current_time.duration_since(*time).as_millis() as f64).floor(); - - if let Some(val) = timed_data.cpu_data.get(itx) { - data.push((-time_start, *val)); - } - - if *time == current_time { - break; - } - } - - data.shrink_to_fit(); - } - } - } - } -} - -pub fn convert_mem_data_points(data: &DataCollection) -> Vec { - let mut result: Vec = Vec::new(); - let current_time = data.current_instant; - - for (time, data) in &data.timed_data_vec { - if let Some(mem_data) = data.mem_data { - let time_from_start: f64 = - (current_time.duration_since(*time).as_millis() as f64).floor(); - result.push((-time_from_start, mem_data)); - if *time == current_time { - break; - } - } - } - - result -} - -#[cfg(not(target_os = "windows"))] -pub fn convert_cache_data_points(data: &DataCollection) -> Vec { - let mut result: Vec = Vec::new(); - let current_time = data.current_instant; - - for (time, data) in &data.timed_data_vec { - if let Some(cache_data) = data.cache_data { - let time_from_start: f64 = - (current_time.duration_since(*time).as_millis() as f64).floor(); - result.push((-time_from_start, cache_data)); - if *time == current_time { - break; - } - } - } - - result -} - -pub fn convert_swap_data_points(data: &DataCollection) -> Vec { - let mut result: Vec = Vec::new(); - let current_time = data.current_instant; - - for (time, data) in &data.timed_data_vec { - if let Some(swap_data) = data.swap_data { - let time_from_start: f64 = - (current_time.duration_since(*time).as_millis() as f64).floor(); - result.push((-time_from_start, swap_data)); - if *time == current_time { - break; - } - } - } - - result -} - -/// Returns the most appropriate binary prefix unit type (e.g. kibibyte) and -/// denominator for the given amount of bytes. -/// -/// The expected usage is to divide out the given value with the returned -/// denominator in order to be able to use it with the returned binary unit -/// (e.g. divide 3000 bytes by 1024 to have a value in KiB). -#[inline] -fn get_binary_unit_and_denominator(bytes: u64) -> (&'static str, f64) { - match bytes { - b if b < KIBI_LIMIT => ("B", 1.0), - b if b < MEBI_LIMIT => ("KiB", KIBI_LIMIT_F64), - b if b < GIBI_LIMIT => ("MiB", MEBI_LIMIT_F64), - b if b < TEBI_LIMIT => ("GiB", GIBI_LIMIT_F64), - _ => ("TiB", TEBI_LIMIT_F64), - } -} - -/// Returns the unit type and denominator for given total amount of memory in -/// kibibytes. -pub fn convert_mem_label(harvest: &MemHarvest) -> Option<(String, String)> { - if harvest.total_bytes > 0 { - Some((format!("{:3.0}%", harvest.use_percent.unwrap_or(0.0)), { - let (unit, denominator) = get_binary_unit_and_denominator(harvest.total_bytes); - - format!( - " {:.1}{}/{:.1}{}", - harvest.used_bytes as f64 / denominator, - unit, - (harvest.total_bytes as f64 / denominator), - unit - ) - })) - } else { - None - } -} - -pub fn get_network_points( - data: &DataCollection, scale_type: &AxisScaling, unit_type: &DataUnit, use_binary_prefix: bool, -) -> (Vec, Vec) { - let mut rx: Vec = Vec::new(); - let mut tx: Vec = Vec::new(); - - let current_time = data.current_instant; - - for (time, data) in &data.timed_data_vec { - let time_from_start: f64 = (current_time.duration_since(*time).as_millis() as f64).floor(); - - let (rx_data, tx_data) = match scale_type { - AxisScaling::Log => { - if use_binary_prefix { - match unit_type { - DataUnit::Byte => { - // As dividing by 8 is equal to subtracting 4 in base 2! - ((data.rx_data).log2() - 4.0, (data.tx_data).log2() - 4.0) - } - DataUnit::Bit => ((data.rx_data).log2(), (data.tx_data).log2()), - } - } else { - match unit_type { - DataUnit::Byte => { - ((data.rx_data / 8.0).log10(), (data.tx_data / 8.0).log10()) - } - DataUnit::Bit => ((data.rx_data).log10(), (data.tx_data).log10()), - } - } - } - AxisScaling::Linear => match unit_type { - DataUnit::Byte => (data.rx_data / 8.0, data.tx_data / 8.0), - DataUnit::Bit => (data.rx_data, data.tx_data), - }, - }; - - rx.push((-time_from_start, rx_data)); - tx.push((-time_from_start, tx_data)); - if *time == current_time { - break; - } - } - - (rx, tx) -} - -pub fn convert_network_points( - data: &DataCollection, need_four_points: bool, scale_type: &AxisScaling, unit_type: &DataUnit, - use_binary_prefix: bool, -) -> ConvertedNetworkData { - let (rx, tx) = get_network_points(data, scale_type, unit_type, use_binary_prefix); - - let unit = match unit_type { - DataUnit::Byte => "B/s", - DataUnit::Bit => "b/s", - }; - - let (rx_data, tx_data, total_rx_data, total_tx_data) = match unit_type { - DataUnit::Byte => ( - data.network_harvest.rx / 8, - data.network_harvest.tx / 8, - data.network_harvest.total_rx / 8, - data.network_harvest.total_tx / 8, - ), - DataUnit::Bit => ( - data.network_harvest.rx, - data.network_harvest.tx, - data.network_harvest.total_rx / 8, // We always make this bytes... - data.network_harvest.total_tx / 8, - ), - }; - - let (rx_converted_result, total_rx_converted_result): ((f64, String), (f64, &'static str)) = - if use_binary_prefix { - ( - get_binary_prefix(rx_data, unit), /* If this isn't obvious why there's two - * functions, one you can configure the unit, - * the other is always bytes */ - get_binary_bytes(total_rx_data), - ) - } else { - ( - get_decimal_prefix(rx_data, unit), - get_decimal_bytes(total_rx_data), - ) - }; - - let (tx_converted_result, total_tx_converted_result): ((f64, String), (f64, &'static str)) = - if use_binary_prefix { - ( - get_binary_prefix(tx_data, unit), - get_binary_bytes(total_tx_data), - ) - } else { - ( - get_decimal_prefix(tx_data, unit), - get_decimal_bytes(total_tx_data), - ) - }; - - if need_four_points { - let rx_display = format!("{:.*}{}", 1, rx_converted_result.0, rx_converted_result.1); - let total_rx_display = Some(format!( - "{:.*}{}", - 1, total_rx_converted_result.0, total_rx_converted_result.1 - )); - let tx_display = format!("{:.*}{}", 1, tx_converted_result.0, tx_converted_result.1); - let total_tx_display = Some(format!( - "{:.*}{}", - 1, total_tx_converted_result.0, total_tx_converted_result.1 - )); - ConvertedNetworkData { - rx, - tx, - rx_display, - tx_display, - total_rx_display, - total_tx_display, - } - } else { - let rx_display = format!( - "RX: {:<10} All: {}", - if use_binary_prefix { - format!("{:.1}{:3}", rx_converted_result.0, rx_converted_result.1) - } else { - format!("{:.1}{:2}", rx_converted_result.0, rx_converted_result.1) - }, - if use_binary_prefix { - format!( - "{:.1}{:3}", - total_rx_converted_result.0, total_rx_converted_result.1 - ) - } else { - format!( - "{:.1}{:2}", - total_rx_converted_result.0, total_rx_converted_result.1 - ) - } - ); - let tx_display = format!( - "TX: {:<10} All: {}", - if use_binary_prefix { - format!("{:.1}{:3}", tx_converted_result.0, tx_converted_result.1) - } else { - format!("{:.1}{:2}", tx_converted_result.0, tx_converted_result.1) - }, - if use_binary_prefix { - format!( - "{:.1}{:3}", - total_tx_converted_result.0, total_tx_converted_result.1 - ) - } else { - format!( - "{:.1}{:2}", - total_tx_converted_result.0, total_tx_converted_result.1 - ) - } - ); - - ConvertedNetworkData { - rx, - tx, - rx_display, - tx_display, - total_rx_display: None, - total_tx_display: None, - } - } -} - -/// Returns a string given a value that is converted to the closest binary -/// variant. If the value is greater than a gibibyte, then it will return a -/// decimal place. -pub fn binary_byte_string(value: u64) -> String { - let converted_values = get_binary_bytes(value); - if value >= GIBI_LIMIT { - format!("{:.*}{}", 1, converted_values.0, converted_values.1) - } else { - format!("{:.*}{}", 0, converted_values.0, converted_values.1) - } -} - -/// Returns a string given a value that is converted to the closest SI-variant. -/// If the value is greater than a giga-X, then it will return a decimal place. -pub fn dec_bytes_per_string(value: u64) -> String { - let converted_values = get_decimal_bytes(value); - if value >= GIGA_LIMIT { - format!("{:.*}{}", 1, converted_values.0, converted_values.1) - } else { - format!("{:.*}{}", 0, converted_values.0, converted_values.1) - } -} - -/// Returns a string given a value that is converted to the closest SI-variant, -/// per second. If the value is greater than a giga-X, then it will return a -/// decimal place. -pub fn dec_bytes_per_second_string(value: u64) -> String { - let converted_values = get_decimal_bytes(value); - if value >= GIGA_LIMIT { - format!("{:.*}{}/s", 1, converted_values.0, converted_values.1) - } else { - format!("{:.*}{}/s", 0, converted_values.0, converted_values.1) - } -} - -/// Returns a string given a value that is converted to the closest SI-variant. -/// If the value is greater than a giga-X, then it will return a decimal place. -pub fn dec_bytes_string(value: u64) -> String { - let converted_values = get_decimal_bytes(value); - if value >= GIGA_LIMIT { - format!("{:.*}{}", 1, converted_values.0, converted_values.1) - } else { - format!("{:.*}{}", 0, converted_values.0, converted_values.1) - } -} - -#[cfg(feature = "battery")] -pub fn convert_battery_harvest(current_data: &DataCollection) -> Vec { - current_data - .battery_harvest - .iter() - .map(|battery_harvest| ConvertedBatteryData { - charge_percentage: battery_harvest.charge_percent, - watt_consumption: format!("{:.2}W", battery_harvest.power_consumption_rate_watts), - battery_duration: if let Some(secs) = battery_harvest.secs_until_empty { - BatteryDuration::ToEmpty(secs) - } else if let Some(secs) = battery_harvest.secs_until_full { - BatteryDuration::ToFull(secs) - } else { - match battery_harvest.state { - starship_battery::State::Empty => BatteryDuration::Empty, - starship_battery::State::Full => BatteryDuration::Full, - _ => BatteryDuration::Unknown, - } - }, - health: format!("{:.2}%", battery_harvest.health_percent), - state: { - let mut s = battery_harvest.state.to_string(); - if !s.is_empty() { - s[0..1].make_ascii_uppercase(); - } - s - }, - }) - .collect() -} - -#[cfg(feature = "zfs")] -pub fn convert_arc_data_points(current_data: &DataCollection) -> Vec { - let mut result: Vec = Vec::new(); - let current_time = current_data.current_instant; - - for (time, data) in ¤t_data.timed_data_vec { - if let Some(arc_data) = data.arc_data { - let time_from_start: f64 = - (current_time.duration_since(*time).as_millis() as f64).floor(); - result.push((-time_from_start, arc_data)); - if *time == current_time { - break; - } - } - } - - result -} - -#[cfg(feature = "gpu")] -#[derive(Default, Debug)] -pub struct ConvertedGpuData { - pub name: String, - pub mem_total: String, - pub mem_percent: String, - pub points: Vec, -} - -#[cfg(feature = "gpu")] -pub fn convert_gpu_data(current_data: &DataCollection) -> Option> { - let current_time = current_data.current_instant; - - // convert points - let mut point_vec: Vec> = Vec::with_capacity(current_data.gpu_harvest.len()); - for (time, data) in ¤t_data.timed_data_vec { - data.gpu_data.iter().enumerate().for_each(|(index, point)| { - if let Some(data_point) = point { - let time_from_start: f64 = - (current_time.duration_since(*time).as_millis() as f64).floor(); - if let Some(point_slot) = point_vec.get_mut(index) { - point_slot.push((-time_from_start, *data_point)); - } else { - point_vec.push(vec![(-time_from_start, *data_point)]); - } - } - }); - - if *time == current_time { - break; - } - } - - // convert labels - let results = current_data - .gpu_harvest - .iter() - .zip(point_vec) - .map(|(gpu, points)| { - let short_name = { - let last_words = gpu.0.split_whitespace().rev().take(2).collect::>(); - let short_name = format!("{} {}", last_words[1], last_words[0]); - short_name - }; - - ConvertedGpuData { - name: short_name, - points, - mem_percent: format!("{:3.0}%", gpu.1.use_percent.unwrap_or(0.0)), - mem_total: { - let (unit, denominator) = get_binary_unit_and_denominator(gpu.1.total_bytes); - - format!( - " {:.1}{unit}/{:.1}{unit}", - gpu.1.used_bytes as f64 / denominator, - (gpu.1.total_bytes as f64 / denominator), - ) - }, - } - }) - .collect::>(); - - if !results.is_empty() { - Some(results) - } else { - None - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_binary_byte_string() { - assert_eq!(binary_byte_string(0), "0B".to_string()); - assert_eq!(binary_byte_string(1), "1B".to_string()); - assert_eq!(binary_byte_string(1000), "1000B".to_string()); - assert_eq!(binary_byte_string(1023), "1023B".to_string()); - assert_eq!(binary_byte_string(KIBI_LIMIT), "1KiB".to_string()); - assert_eq!(binary_byte_string(KIBI_LIMIT + 1), "1KiB".to_string()); - assert_eq!(binary_byte_string(MEBI_LIMIT), "1MiB".to_string()); - assert_eq!(binary_byte_string(GIBI_LIMIT), "1.0GiB".to_string()); - assert_eq!(binary_byte_string(2 * GIBI_LIMIT), "2.0GiB".to_string()); - assert_eq!( - binary_byte_string((2.5 * GIBI_LIMIT as f64) as u64), - "2.5GiB".to_string() - ); - assert_eq!( - binary_byte_string((10.34 * TEBI_LIMIT as f64) as u64), - "10.3TiB".to_string() - ); - assert_eq!( - binary_byte_string((10.36 * TEBI_LIMIT as f64) as u64), - "10.4TiB".to_string() - ); - } - - #[test] - fn test_dec_bytes_per_second_string() { - assert_eq!(dec_bytes_per_second_string(0), "0B/s".to_string()); - assert_eq!(dec_bytes_per_second_string(1), "1B/s".to_string()); - assert_eq!(dec_bytes_per_second_string(900), "900B/s".to_string()); - assert_eq!(dec_bytes_per_second_string(999), "999B/s".to_string()); - assert_eq!(dec_bytes_per_second_string(KILO_LIMIT), "1KB/s".to_string()); - assert_eq!( - dec_bytes_per_second_string(KILO_LIMIT + 1), - "1KB/s".to_string() - ); - assert_eq!(dec_bytes_per_second_string(KIBI_LIMIT), "1KB/s".to_string()); - assert_eq!(dec_bytes_per_second_string(MEGA_LIMIT), "1MB/s".to_string()); - assert_eq!( - dec_bytes_per_second_string(GIGA_LIMIT), - "1.0GB/s".to_string() - ); - assert_eq!( - dec_bytes_per_second_string(2 * GIGA_LIMIT), - "2.0GB/s".to_string() - ); - assert_eq!( - dec_bytes_per_second_string((2.5 * GIGA_LIMIT as f64) as u64), - "2.5GB/s".to_string() - ); - assert_eq!( - dec_bytes_per_second_string((10.34 * TERA_LIMIT as f64) as u64), - "10.3TB/s".to_string() - ); - assert_eq!( - dec_bytes_per_second_string((10.36 * TERA_LIMIT as f64) as u64), - "10.4TB/s".to_string() - ); - } -} diff --git a/src/event.rs b/src/event.rs index 9b45a8c85..44afe576e 100644 --- a/src/event.rs +++ b/src/event.rs @@ -5,8 +5,8 @@ use std::sync::mpsc::Sender; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; use crate::{ - app::{layout_manager::WidgetDirection, App}, - data_collection::Data, + app::{App, layout_manager::WidgetDirection}, + collection::Data, }; /// Events sent to the main thread. @@ -78,7 +78,7 @@ pub fn handle_key_event_or_break( KeyCode::F(3) => app.toggle_search_regex(), KeyCode::F(5) => app.toggle_tree_mode(), KeyCode::F(6) => app.toggle_sort_menu(), - KeyCode::F(9) => app.start_killing_process(), + KeyCode::F(9) => app.kill_current_process(), KeyCode::PageDown => app.on_page_down(), KeyCode::PageUp => app.on_page_up(), _ => {} diff --git a/src/main.rs b/src/lib.rs similarity index 56% rename from src/main.rs rename to src/lib.rs index bc3195184..208201a1b 100644 --- a/src/main.rs +++ b/src/lib.rs @@ -7,50 +7,51 @@ //! application. If you are instead looking for documentation regarding the //! *usage* of bottom, refer to [here](https://clementtsang.github.io/bottom/stable/). -pub mod app; -pub mod utils { - pub mod data_prefixes; - pub mod data_units; - pub mod general; - pub mod logging; - pub mod strings; +pub(crate) mod app; +mod utils { + pub(crate) mod cancellation_token; + pub(crate) mod conversion; + pub(crate) mod data_units; + pub(crate) mod general; + pub(crate) mod logging; + pub(crate) mod process_killer; + pub(crate) mod strings; } -pub mod canvas; -pub mod constants; -pub mod data_collection; -pub mod data_conversion; -pub mod event; +pub(crate) mod canvas; +pub(crate) mod collection; +pub(crate) mod constants; +pub(crate) mod event; pub mod options; pub mod widgets; use std::{ boxed::Box, - io::{stderr, stdout, Write}, - panic::{self, PanicInfo}, + io::{Write, stderr, stdout}, + panic::{self, PanicHookInfo}, sync::{ + Arc, mpsc::{self, Receiver, Sender}, - Arc, Condvar, Mutex, }, thread::{self, JoinHandle}, time::{Duration, Instant}, }; -use app::{layout_manager::UsedWidgets, App, AppConfigFields, DataFilters}; +use app::{App, AppConfigFields, DataFilters, layout_manager::UsedWidgets}; use crossterm::{ + cursor::{Hide, Show}, event::{ - poll, read, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, - EnableMouseCapture, Event, KeyEventKind, MouseEventKind, + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event, KeyEventKind, MouseEventKind, poll, read, }, execute, - style::Print, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use data_conversion::*; -use event::{handle_key_event_or_break, handle_mouse_event, BottomEvent, CollectionThreadEvent}; +use event::{BottomEvent, CollectionThreadEvent, handle_key_event_or_break, handle_mouse_event}; use options::{args, get_or_create_config, init_app}; -use tui::{backend::CrosstermBackend, Terminal}; -#[allow(unused_imports)] +use tui::{Terminal, backend::CrosstermBackend}; +#[allow(unused_imports, reason = "this is needed if logging is enabled")] use utils::logging::*; +use utils::{cancellation_token::CancellationToken, conversion::*}; // Used for heap allocation debugging purposes. // #[global_allocator] @@ -74,11 +75,13 @@ fn cleanup_terminal( terminal: &mut Terminal>, ) -> anyhow::Result<()> { disable_raw_mode()?; + execute!( terminal.backend_mut(), - DisableBracketedPaste, DisableMouseCapture, - LeaveAlternateScreen + DisableBracketedPaste, + LeaveAlternateScreen, + Show, )?; terminal.show_cursor()?; @@ -99,11 +102,22 @@ fn check_if_terminal() { } } -/// A panic hook to properly restore the terminal in the case of a panic. -/// Originally based on [spotify-tui's implementation](https://github.com/Rigellute/spotify-tui/blob/master/src/main.rs). -fn panic_hook(panic_info: &PanicInfo<'_>) { +/// This manually resets stdout back to normal state. +pub fn reset_stdout() { let mut stdout = stdout(); + let _ = disable_raw_mode(); + let _ = execute!( + stdout, + DisableMouseCapture, + DisableBracketedPaste, + LeaveAlternateScreen, + Show, + ); +} +/// A panic hook to properly restore the terminal in the case of a panic. +/// Originally based on [spotify-tui's implementation](https://github.com/Rigellute/spotify-tui/blob/master/src/main.rs). +fn panic_hook(panic_info: &PanicHookInfo<'_>) { let msg = match panic_info.payload().downcast_ref::<&'static str>() { Some(s) => *s, None => match panic_info.payload().downcast_ref::() { @@ -114,40 +128,33 @@ fn panic_hook(panic_info: &PanicInfo<'_>) { let backtrace = format!("{:?}", backtrace::Backtrace::new()); - let _ = disable_raw_mode(); - let _ = execute!( - stdout, - DisableBracketedPaste, - DisableMouseCapture, - LeaveAlternateScreen - ); + reset_stdout(); // Print stack trace. Must be done after! if let Some(panic_info) = panic_info.location() { - let _ = execute!( - stdout, - Print(format!( - "thread '' panicked at '{msg}', {panic_info}\n\r{backtrace}", - )), - ); + println!("thread '' panicked at '{msg}', {panic_info}\n\r{backtrace}") } + + // TODO: Might be cleaner in the future to use a cancellation token, but that causes some fun issues with + // lifetimes; for now if it panics then shut down the main program entirely ASAP. + std::process::exit(1); } /// Create a thread to poll for user inputs and forward them to the main thread. fn create_input_thread( - sender: Sender, termination_ctrl_lock: Arc>, + sender: Sender, cancellation_token: Arc, ) -> JoinHandle<()> { thread::spawn(move || { let mut mouse_timer = Instant::now(); loop { - if let Ok(is_terminated) = termination_ctrl_lock.try_lock() { - // We don't block. - if *is_terminated { - drop(is_terminated); + // We don't block. + if let Some(is_terminated) = cancellation_token.try_check() { + if is_terminated { break; } } + if let Ok(poll) = poll(Duration::from_millis(20)) { if poll { if let Ok(event) = read() { @@ -206,20 +213,18 @@ fn create_input_thread( /// Create a thread to handle data collection. fn create_collection_thread( sender: Sender, control_receiver: Receiver, - termination_lock: Arc>, termination_cvar: Arc, - app_config_fields: &AppConfigFields, filters: DataFilters, used_widget_set: UsedWidgets, + cancellation_token: Arc, app_config_fields: &AppConfigFields, + filters: DataFilters, used_widget_set: UsedWidgets, ) -> JoinHandle<()> { - let temp_type = app_config_fields.temperature_type; let use_current_cpu_total = app_config_fields.use_current_cpu_total; let unnormalized_cpu = app_config_fields.unnormalized_cpu; let show_average_cpu = app_config_fields.show_average_cpu; let update_time = app_config_fields.update_rate; thread::spawn(move || { - let mut data_state = data_collection::DataCollector::new(filters); + let mut data_state = collection::DataCollector::new(filters); - data_state.set_data_collection(used_widget_set); - data_state.set_temperature_type(temp_type); + data_state.set_collection(used_widget_set); data_state.set_use_current_cpu_total(use_current_cpu_total); data_state.set_unnormalized_cpu(unnormalized_cpu); data_state.set_show_average_cpu(show_average_cpu); @@ -228,9 +233,8 @@ fn create_collection_thread( loop { // Check once at the very top... don't block though. - if let Ok(is_terminated) = termination_lock.try_lock() { - if *is_terminated { - drop(is_terminated); + if let Some(is_terminated) = cancellation_token.try_check() { + if is_terminated { break; } } @@ -247,78 +251,33 @@ fn create_collection_thread( data_state.update_data(); // Yet another check to bail if needed... do not block! - if let Ok(is_terminated) = termination_lock.try_lock() { - if *is_terminated { - drop(is_terminated); + if let Some(is_terminated) = cancellation_token.try_check() { + if is_terminated { break; } } let event = BottomEvent::Update(Box::from(data_state.data)); - data_state.data = data_collection::Data::default(); + data_state.data = collection::Data::default(); if sender.send(event).is_err() { break; } - // This is actually used as a "sleep" that can be interrupted by another thread. - if let Ok((is_terminated, _)) = termination_cvar.wait_timeout( - termination_lock.lock().unwrap(), - Duration::from_millis(update_time), - ) { - if *is_terminated { - drop(is_terminated); - break; - } + // Sleep while allowing for interruptions... + if cancellation_token.sleep_with_cancellation(Duration::from_millis(update_time)) { + break; } } }) } -#[cfg(feature = "generate_schema")] -fn generate_schema() -> anyhow::Result<()> { - let mut schema = schemars::schema_for!(crate::options::config::Config); - { - use itertools::Itertools; - use strum::VariantArray; - - let proc_columns = schema.definitions.get_mut("ProcColumn").unwrap(); - match proc_columns { - schemars::schema::Schema::Object(proc_columns) => { - let enums = proc_columns.enum_values.as_mut().unwrap(); - *enums = options::config::process::ProcColumn::VARIANTS - .iter() - .flat_map(|var| var.get_schema_names()) - .map(|v| serde_json::Value::String(v.to_string())) - .dedup() - .collect(); - } - _ => anyhow::bail!("missing proc columns definition"), - } - } - - let metadata = schema.schema.metadata.as_mut().unwrap(); - metadata.id = Some( - "https://github.com/ClementTsang/bottom/blob/main/schema/nightly/bottom.json".to_string(), - ); - metadata.description = - Some("https://clementtsang.github.io/bottom/nightly/configuration/config-file".to_string()); - println!("{}", serde_json::to_string_pretty(&schema).unwrap()); - - Ok(()) -} - -fn main() -> anyhow::Result<()> { - // TODO: If there is any panic in any thread, send a cancellation token (or similar) to shut down everything. - +/// Main code to call to start bottom. +#[inline] +pub fn start_bottom(enable_error_hook: &mut bool) -> anyhow::Result<()> { // let _profiler = dhat::Profiler::new_heap(); let args = args::get_args(); - #[cfg(feature = "generate_schema")] - if args.other.generate_schema { - return generate_schema(); - } - #[cfg(feature = "logging")] { if let Err(err) = init_logger( @@ -341,12 +300,7 @@ fn main() -> anyhow::Result<()> { // Check if the current environment is in a terminal. check_if_terminal(); - // Create termination mutex and cvar. We use this setup because we need to sleep - // at some points in the update thread, but we want to be able to interrupt - // the "sleep" if a termination occurs. - let termination_lock = Arc::new(Mutex::new(false)); - let termination_cvar = Arc::new(Condvar::new()); - + let cancellation_token = Arc::new(CancellationToken::default()); let (sender, receiver) = mpsc::channel(); // Set up the event loop thread; we set this up early to speed up @@ -355,35 +309,27 @@ fn main() -> anyhow::Result<()> { let _collection_thread = create_collection_thread( sender.clone(), collection_thread_ctrl_receiver, - termination_lock.clone(), - termination_cvar.clone(), + cancellation_token.clone(), &app.app_config_fields, app.filters.clone(), app.used_widgets, ); // Set up the input handling loop thread. - let _input_thread = create_input_thread(sender.clone(), termination_lock.clone()); + let _input_thread = create_input_thread(sender.clone(), cancellation_token.clone()); // Set up the cleaning loop thread. let _cleaning_thread = { - let lock = termination_lock.clone(); - let cvar = termination_cvar.clone(); + let cancellation_token = cancellation_token.clone(); let cleaning_sender = sender.clone(); - let offset_wait_time = app.app_config_fields.retention_ms + 60000; + let offset_wait = Duration::from_millis(app.app_config_fields.retention_ms + 60000); thread::spawn(move || { loop { - let result = cvar.wait_timeout( - lock.lock().unwrap(), - Duration::from_millis(offset_wait_time), - ); - if let Ok(result) = result { - if *(result.0) { - break; - } + if cancellation_token.sleep_with_cancellation(offset_wait) { + break; } + if cleaning_sender.send(BottomEvent::Clean).is_err() { - // debug!("Failed to send cleaning sender..."); break; } } @@ -391,13 +337,15 @@ fn main() -> anyhow::Result<()> { }; // Set up tui and crossterm + *enable_error_hook = true; + let mut stdout_val = stdout(); - execute!( - stdout_val, - EnterAlternateScreen, - EnableMouseCapture, - EnableBracketedPaste - )?; + execute!(stdout_val, Hide, EnterAlternateScreen, EnableBracketedPaste)?; + if app.app_config_fields.disable_click { + execute!(stdout_val, DisableMouseCapture)?; + } else { + execute!(stdout_val, EnableMouseCapture)?; + } enable_raw_mode()?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout_val))?; @@ -421,6 +369,8 @@ fn main() -> anyhow::Result<()> { // Set termination hook ctrlc::set_handler(move || { + // TODO: Consider using signal-hook (https://github.com/vorner/signal-hook) to handle + // more types of signals? let _ = sender.send(BottomEvent::Terminate); })?; @@ -433,9 +383,7 @@ fn main() -> anyhow::Result<()> { loop { if let Ok(recv) = receiver.recv() { match recv { - BottomEvent::Terminate => { - break; - } + BottomEvent::Terminate => break, BottomEvent::Resize => { try_drawing(&mut terminal, &mut app, &mut painter)?; } @@ -457,7 +405,7 @@ fn main() -> anyhow::Result<()> { try_drawing(&mut terminal, &mut app, &mut painter)?; } BottomEvent::Update(data) => { - app.data_collection.eat_data(data); + app.data_store.eat_data(data, &app.app_config_fields); // This thing is required as otherwise, some widgets can't draw correctly w/o // some data (or they need to be re-drawn). @@ -466,109 +414,30 @@ fn main() -> anyhow::Result<()> { app.is_force_redraw = true; } - if !app.frozen_state.is_frozen() { + if !app.data_store.is_frozen() { // Convert all data into data for the displayed widgets. - if app.used_widgets.use_net { - let network_data = convert_network_points( - &app.data_collection, - app.app_config_fields.use_basic_mode - || app.app_config_fields.use_old_network_legend, - &app.app_config_fields.network_scale_type, - &app.app_config_fields.network_unit_type, - app.app_config_fields.network_use_binary_prefix, - ); - app.converted_data.network_data_rx = network_data.rx; - app.converted_data.network_data_tx = network_data.tx; - app.converted_data.rx_display = network_data.rx_display; - app.converted_data.tx_display = network_data.tx_display; - if let Some(total_rx_display) = network_data.total_rx_display { - app.converted_data.total_rx_display = total_rx_display; - } - if let Some(total_tx_display) = network_data.total_tx_display { - app.converted_data.total_tx_display = total_tx_display; - } - } - if app.used_widgets.use_disk { - app.converted_data.convert_disk_data(&app.data_collection); - for disk in app.states.disk_state.widget_states.values_mut() { disk.force_data_update(); } } if app.used_widgets.use_temp { - app.converted_data.convert_temp_data( - &app.data_collection, - app.app_config_fields.temperature_type, - ); - for temp in app.states.temp_state.widget_states.values_mut() { temp.force_data_update(); } } - if app.used_widgets.use_mem { - app.converted_data.mem_data = - convert_mem_data_points(&app.data_collection); - - #[cfg(not(target_os = "windows"))] - { - app.converted_data.cache_data = - convert_cache_data_points(&app.data_collection); - } - - app.converted_data.swap_data = - convert_swap_data_points(&app.data_collection); - - #[cfg(feature = "zfs")] - { - app.converted_data.arc_data = - convert_arc_data_points(&app.data_collection); - } - - #[cfg(feature = "gpu")] - { - app.converted_data.gpu_data = - convert_gpu_data(&app.data_collection); - } - - app.converted_data.mem_labels = - convert_mem_label(&app.data_collection.memory_harvest); - - app.converted_data.swap_labels = - convert_mem_label(&app.data_collection.swap_harvest); - - #[cfg(not(target_os = "windows"))] - { - app.converted_data.cache_labels = - convert_mem_label(&app.data_collection.cache_harvest); - } - - #[cfg(feature = "zfs")] - { - app.converted_data.arc_labels = - convert_mem_label(&app.data_collection.arc_harvest); - } - } - - if app.used_widgets.use_cpu { - app.converted_data.convert_cpu_data(&app.data_collection); - app.converted_data.load_avg_data = app.data_collection.load_avg_harvest; - } - if app.used_widgets.use_proc { for proc in app.states.proc_state.widget_states.values_mut() { proc.force_data_update(); } } - #[cfg(feature = "battery")] - { - if app.used_widgets.use_battery { - app.converted_data.battery_data = - convert_battery_harvest(&app.data_collection); + if app.used_widgets.use_cpu { + for cpu in app.states.cpu_state.widget_states.values_mut() { + cpu.force_data_update(); } } @@ -577,16 +446,16 @@ fn main() -> anyhow::Result<()> { } } BottomEvent::Clean => { - app.data_collection - .clean_data(app.app_config_fields.retention_ms); + app.data_store + .clean_data(Duration::from_millis(app.app_config_fields.retention_ms)); } } } } // I think doing it in this order is safe... - *termination_lock.lock().unwrap() = true; - termination_cvar.notify_all(); + // TODO: maybe move the cancellation token to the ctrl-c handler? + cancellation_token.cancel(); cleanup_terminal(&mut terminal)?; Ok(()) diff --git a/src/options.rs b/src/options.rs index 24571f1a8..2a105e972 100644 --- a/src/options.rs +++ b/src/options.rs @@ -16,8 +16,9 @@ use std::{ }; use anyhow::{Context, Result}; -use config::style::ColourPalette; pub use config::Config; +use config::style::Styles; +use data::TemperatureType; pub(crate) use error::{OptionError, OptionResult}; use hashbrown::{HashMap, HashSet}; use indexmap::IndexSet; @@ -27,13 +28,12 @@ use starship_battery::Manager; use self::{ args::BottomArgs, - config::{layout::Row, IgnoreList, StringOrNum}, + config::{IgnoreList, StringOrNum, layout::Row}, }; use crate::{ app::{filter::Filter, layout_manager::*, *}, - canvas::components::time_chart::LegendPosition, + canvas::components::time_graph::LegendPosition, constants::*, - data_collection::temperature::TemperatureType, utils::data_units::DataUnit, widgets::*, }; @@ -60,36 +60,67 @@ macro_rules! is_flag_enabled { }; } +/// The default config file sub-path. +const DEFAULT_CONFIG_FILE_LOCATION: &str = "bottom/bottom.toml"; + /// Returns the config path to use. If `override_config_path` is specified, then /// we will use that. If not, then return the "default" config path, which is: +/// /// - If a path already exists at `/bottom/bottom.toml`, then use that for /// legacy reasons. /// - Otherwise, use `/bottom/bottom.toml`. /// /// For more details on this, see [dirs](https://docs.rs/dirs/latest/dirs/fn.config_dir.html)' /// documentation. +/// +/// XXX: For macOS, we additionally will manually check `$XDG_CONFIG_HOME` as well first +/// before falling back to `dirs`. fn get_config_path(override_config_path: Option<&Path>) -> Option { - const DEFAULT_CONFIG_FILE_PATH: &str = "bottom/bottom.toml"; - if let Some(conf_loc) = override_config_path { return Some(conf_loc.to_path_buf()); } else if let Some(home_path) = dirs::home_dir() { let mut old_home_path = home_path; old_home_path.push(".config/"); - old_home_path.push(DEFAULT_CONFIG_FILE_PATH); - if old_home_path.exists() { - // We used to create it at `/DEFAULT_CONFIG_FILE_PATH`, but changed it - // to be more correct later. However, for legacy reasons, if it already exists, - // use the old one. - return Some(old_home_path); + old_home_path.push(DEFAULT_CONFIG_FILE_LOCATION); + if let Ok(res) = old_home_path.try_exists() { + if res { + // We used to create it at `/DEFAULT_CONFIG_FILE_PATH`, but changed it + // to be more correct later. However, for legacy reasons, if it already exists, + // use the old one. + return Some(old_home_path); + } } } - // Otherwise, return the "correct" path based on the config dir. - dirs::config_dir().map(|mut path| { - path.push(DEFAULT_CONFIG_FILE_PATH); + let config_path = dirs::config_dir().map(|mut path| { + path.push(DEFAULT_CONFIG_FILE_LOCATION); path - }) + }); + + if cfg!(target_os = "macos") { + if let Ok(xdg_config_path) = std::env::var("XDG_CONFIG_HOME") { + if !xdg_config_path.is_empty() { + // If XDG_CONFIG_HOME exists and is non-empty, _but_ we previously used the Library-based path + // for a config and it exists, then use that instead for backwards-compatibility. + if let Some(old_macos_path) = &config_path { + if let Ok(res) = old_macos_path.try_exists() { + if res { + return config_path; + } + } + } + + // Otherwise, try and use the XDG_CONFIG_HOME-based path. + let mut cfg_path = PathBuf::new(); + cfg_path.push(xdg_config_path); + cfg_path.push(DEFAULT_CONFIG_FILE_LOCATION); + + return Some(cfg_path); + } + } + } + + config_path } fn create_config_at_path(path: &Path) -> anyhow::Result { @@ -113,7 +144,7 @@ fn create_config_at_path(path: &Path) -> anyhow::Result { /// - If the user does NOT pass in a path explicitly, then just show a warning, /// but continue. This is in case they do not want to write a default config file at /// the XDG locations, for example. -pub fn get_or_create_config(config_path: Option<&Path>) -> anyhow::Result { +pub(crate) fn get_or_create_config(config_path: Option<&Path>) -> anyhow::Result { let adjusted_config_path = get_config_path(config_path); match &adjusted_config_path { @@ -164,9 +195,8 @@ pub fn get_or_create_config(config_path: Option<&Path>) -> anyhow::Result Result<(App, BottomLayout, ColourPalette)> { +/// Initialize the app. +pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomLayout, Styles)> { use BottomWidgetType::*; // Since everything takes a reference, but we want to take ownership here to @@ -174,7 +204,7 @@ pub(crate) fn init_app( let args = &args; let config = &config; - let styling = ColourPalette::new(args, config)?; + let styling = Styles::new(args, config)?; let (widget_layout, default_widget_id, default_widget_type_option) = get_widget_layout(args, config) @@ -194,8 +224,10 @@ pub(crate) fn init_app( let is_use_regex = is_flag_enabled!(regex, args.process, config); let is_default_tree = is_flag_enabled!(tree, args.process, config); let is_default_command = is_flag_enabled!(process_command, args.process, config); + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] let is_advanced_kill = !(is_flag_enabled!(disable_advanced_kill, args.process, config)); let process_memory_as_value = is_flag_enabled!(process_memory_as_value, args.process, config); + let is_default_tree_collapsed = is_flag_enabled!(tree_collapse, args.process, config); // For CPU let default_cpu_selection = get_default_cpu_selection(args, config); @@ -230,6 +262,7 @@ pub(crate) fn init_app( if cfg.columns.is_empty() { None } else { + // TODO: Should we be using an indexmap? Or maybe allow dupes. Some(IndexSet::from_iter( cfg.columns.iter().map(ProcWidgetColumn::from), )) @@ -265,6 +298,7 @@ pub(crate) fn init_app( args.general, config ), + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] is_advanced_kill, memory_legend_position, network_legend_position, @@ -273,6 +307,7 @@ pub(crate) fn init_app( network_use_binary_prefix, retention_ms, dedicated_average_row: get_dedicated_avg_row(config), + default_tree_collapse: is_default_tree_collapsed, }; let table_config = ProcTableConfig { @@ -350,9 +385,7 @@ pub(crate) fn init_app( let mode = if is_grouped { ProcWidgetMode::Grouped } else if is_default_tree { - ProcWidgetMode::Tree { - collapsed_pids: Default::default(), - } + ProcWidgetMode::Tree(TreeCollapsed::new(is_default_tree_collapsed)) } else { ProcWidgetMode::Normal }; @@ -371,7 +404,11 @@ pub(crate) fn init_app( Disk => { disk_state_map.insert( widget.widget_id, - DiskTableWidget::new(&app_config_fields, &styling), + DiskTableWidget::new( + &app_config_fields, + &styling, + config.disk.as_ref().map(|cfg| cfg.columns.as_slice()), + ), ); } Temp => { @@ -396,7 +433,6 @@ pub(crate) fn init_app( Proc | Disk | Temp => BasicTableWidgetState { currently_displayed_widget_type: initial_widget_type, currently_displayed_widget_id: initial_widget_id, - widget_id: 100, left_tlc: None, left_brc: None, right_tlc: None, @@ -405,7 +441,6 @@ pub(crate) fn init_app( _ => BasicTableWidgetState { currently_displayed_widget_type: Proc, currently_displayed_widget_id: DEFAULT_WIDGET_ID, - widget_id: 100, left_tlc: None, left_brc: None, right_tlc: None, @@ -460,7 +495,7 @@ pub(crate) fn init_app( proc_state: ProcState::init(proc_state_map), temp_state: TempState::init(temp_state_map), disk_state: DiskState::init(disk_state_map), - battery_state: BatteryState::init(battery_state_map), + battery_state: AppBatteryState::init(battery_state_map), basic_table_widget_state, }; @@ -488,7 +523,7 @@ pub(crate) fn init_app( )) } -pub fn get_widget_layout( +fn get_widget_layout( args: &BottomArgs, config: &Config, ) -> OptionResult<(BottomLayout, u64, Option)> { let cpu_left_legend = is_flag_enabled!(cpu_left_legend, args.cpu, config); @@ -637,8 +672,11 @@ macro_rules! parse_ms_option { }}; } +/// How fast the screen refreshes #[inline] fn get_update_rate(args: &BottomArgs, config: &Config) -> OptionResult { + const DEFAULT_REFRESH_RATE_IN_MILLISECONDS: u64 = 1000; + parse_ms_option!( &args.general.rate, config.flags.as_ref().and_then(|flags| flags.rate.as_ref()), @@ -702,6 +740,8 @@ fn get_dedicated_avg_row(config: &Config) -> bool { fn get_default_time_value( args: &BottomArgs, config: &Config, retention_ms: u64, ) -> OptionResult { + const DEFAULT_TIME_MILLISECONDS: u64 = 60 * 1000; // Defaults to 1 min. + parse_ms_option!( &args.general.default_time_value, config @@ -717,6 +757,8 @@ fn get_default_time_value( #[inline] fn get_time_interval(args: &BottomArgs, config: &Config, retention_ms: u64) -> OptionResult { + const TIME_CHANGE_MILLISECONDS: u64 = 15 * 1000; // How much to increment each time + parse_ms_option!( &args.general.time_delta, config @@ -780,64 +822,70 @@ fn get_default_widget_and_count( } } -#[allow(unused_variables)] +#[cfg(feature = "battery")] fn get_use_battery(args: &BottomArgs, config: &Config) -> bool { - #[cfg(feature = "battery")] - { - // TODO: Move this so it's dynamic in the app itself and automatically hide if - // there are no batteries? - if let Ok(battery_manager) = Manager::new() { - if let Ok(batteries) = battery_manager.batteries() { - if batteries.count() == 0 { - return false; - } + // TODO: Move this so it's dynamic in the app itself and automatically hide if + // there are no batteries? + if let Ok(battery_manager) = Manager::new() { + if let Ok(batteries) = battery_manager.batteries() { + if batteries.count() == 0 { + return false; } } + } - if args.battery.battery { - return true; - } else if let Some(flags) = &config.flags { - if let Some(battery) = flags.battery { - return battery; - } + if args.battery.battery { + return true; + } else if let Some(flags) = &config.flags { + if let Some(battery) = flags.battery { + return battery; } } false } -#[allow(unused_variables)] +#[cfg(not(feature = "battery"))] +fn get_use_battery(_args: &BottomArgs, _config: &Config) -> bool { + false +} + +#[cfg(feature = "gpu")] fn get_enable_gpu(args: &BottomArgs, config: &Config) -> bool { - #[cfg(feature = "gpu")] - { - if args.gpu.enable_gpu { - return true; - } else if let Some(flags) = &config.flags { - if let Some(enable_gpu) = flags.enable_gpu { - return enable_gpu; - } - } + if args.gpu.disable_gpu { + return false; } + !config + .flags + .as_ref() + .and_then(|f| f.disable_gpu) + .unwrap_or(false) +} + +#[cfg(not(feature = "gpu"))] +fn get_enable_gpu(_: &BottomArgs, _: &Config) -> bool { false } -#[allow(unused_variables)] +#[cfg(not(target_os = "windows"))] fn get_enable_cache_memory(args: &BottomArgs, config: &Config) -> bool { - #[cfg(not(target_os = "windows"))] - { - if args.memory.enable_cache_memory { - return true; - } else if let Some(flags) = &config.flags { - if let Some(enable_cache_memory) = flags.enable_cache_memory { - return enable_cache_memory; - } + if args.memory.enable_cache_memory { + return true; + } else if let Some(flags) = &config.flags { + if let Some(enable_cache_memory) = flags.enable_cache_memory { + return enable_cache_memory; } } false } +#[cfg(target_os = "windows")] +fn get_enable_cache_memory(_args: &BottomArgs, _config: &Config) -> bool { + false +} + fn get_ignore_list(ignore_list: &Option) -> OptionResult> { if let Some(ignore_list) = ignore_list { let list: Result, _> = ignore_list @@ -868,10 +916,7 @@ fn get_ignore_list(ignore_list: &Option) -> OptionResult None, - position => Some(parse_config_value!(position.parse(), "network_legend")?), + position => Some(parse_arg_value!(position.parse(), "network_legend")?), } } else if let Some(flags) = &config.flags { - if let Some(legend) = &flags.network_legend { - Some(parse_arg_value!(legend.parse(), "network_legend")?) + if let Some(s) = &flags.network_legend { + match s.to_ascii_lowercase().trim() { + "none" => None, + position => Some(parse_config_value!(position.parse(), "network_legend")?), + } } else { Some(LegendPosition::default()) } @@ -948,11 +996,14 @@ fn get_memory_legend_position( let result = if let Some(s) = &args.memory.memory_legend { match s.to_ascii_lowercase().trim() { "none" => None, - position => Some(parse_config_value!(position.parse(), "memory_legend")?), + position => Some(parse_arg_value!(position.parse(), "memory_legend")?), } } else if let Some(flags) = &config.flags { - if let Some(legend) = &flags.memory_legend { - Some(parse_arg_value!(legend.parse(), "memory_legend")?) + if let Some(s) = &flags.memory_legend { + match s.to_ascii_lowercase().trim() { + "none" => None, + position => Some(parse_config_value!(position.parse(), "memory_legend")?), + } } else { Some(LegendPosition::default()) } @@ -967,7 +1018,7 @@ fn get_memory_legend_position( mod test { use clap::Parser; - use super::{get_time_interval, Config}; + use super::{Config, get_time_interval}; use crate::{ app::App, args::BottomArgs, @@ -1184,4 +1235,50 @@ mod test { } } } + + /// This one has slightly more complex behaviour due to `dirs` not respecting XDG on macOS, so we manually + /// handle it. However, to ensure backwards-compatibility, we also have to do some special cases. + #[cfg(target_os = "macos")] + #[test] + fn test_get_config_path_macos() { + use std::path::PathBuf; + + use super::{DEFAULT_CONFIG_FILE_LOCATION, get_config_path}; + + // Case three: no previous config, no XDG var. + // SAFETY: This is fine, this is just a test, and no other test affects env vars. + unsafe { + std::env::remove_var("XDG_CONFIG_HOME"); + } + + let case_1 = dirs::config_dir() + .map(|mut path| { + path.push(DEFAULT_CONFIG_FILE_LOCATION); + path + }) + .unwrap(); + + // Skip this test if the file already exists. + if !case_1.exists() { + assert_eq!(get_config_path(None), Some(case_1)); + } + + // Case two: no previous config, XDG var exists. + // SAFETY: This is fine, this is just a test, and no other test affects env vars. + unsafe { + std::env::set_var("XDG_CONFIG_HOME", "/tmp"); + } + let mut case_2 = PathBuf::new(); + case_2.push("/tmp"); + case_2.push(DEFAULT_CONFIG_FILE_LOCATION); + + // Skip this test if the file already exists. + if !case_2.exists() { + assert_eq!(get_config_path(None), Some(case_2)); + } + + // Case one: old non-XDG exists already, XDG var exists. + // let case_3 = case_1; + // assert_eq!(get_config_path(None), Some(case_1)); + } } diff --git a/src/options/args.rs b/src/options/args.rs index acfd107d0..feb4420db 100644 --- a/src/options/args.rs +++ b/src/options/args.rs @@ -96,7 +96,8 @@ pub struct GeneralArgs { action = ArgAction::SetTrue, help = "Temporarily shows the time scale in graphs.", long_help = "Automatically hides the time scale in graphs after being shown for a brief moment when zoomed \ - in/out. If time is disabled using --hide_time then this will have no effect." + in/out. If time is disabled using --hide_time then this will have no effect.", + alias = "autohide-time" )] pub autohide_time: bool, @@ -117,7 +118,9 @@ pub struct GeneralArgs { help = "Sets the location of the config file.", long_help = "Sets the location of the config file. Expects a config file in the TOML format. \ If it doesn't exist, a default config file is created at the path. If no path is provided, \ - the default config location will be used." + the default config location will be used.", + alias = "config-location", + alias = "config", )] pub config_location: Option, @@ -127,7 +130,8 @@ pub struct GeneralArgs { value_name = "TIME", help = "Default time value for graphs.", long_help = "Default time value for graphs. Either a number in milliseconds or a 'human duration' \ - (e.g. 60s, 10m). Defaults to 60s, must be at least 30s." + (e.g. 60s, 10m). Defaults to 60s, must be at least 30s.", + alias = "default-time-value" )] pub default_time_value: Option, @@ -151,7 +155,8 @@ pub struct GeneralArgs { And we set our default widget type to 'CPU'. If we set '--default_widget_count 1', then it would use the \ CPU (1) as the default widget. If we set '--default_widget_count 3', it would use CPU (3) as the default \ instead." - } + }, + alias = "default-widget-count" )] pub default_widget_count: Option, @@ -189,6 +194,7 @@ pub struct GeneralArgs { #[cfg(feature = "battery")] "battery", ], + alias = "default-widget-type" )] pub default_widget_type: Option, @@ -196,7 +202,8 @@ pub struct GeneralArgs { long, action = ArgAction::SetTrue, help = "Disables mouse clicks.", - long_help = "Disables mouse clicks from interacting with bottom." + long_help = "Disables mouse clicks from interacting with bottom.", + alias = "disable-click" )] pub disable_click: bool, @@ -206,7 +213,8 @@ pub struct GeneralArgs { long, action = ArgAction::SetTrue, help = "Uses a dot marker for graphs.", - long_help = "Uses a dot marker for graphs as opposed to the default braille marker." + long_help = "Uses a dot marker for graphs as opposed to the default braille marker.", + alias = "dot-marker" )] pub dot_marker: bool, @@ -219,10 +227,15 @@ pub struct GeneralArgs { )] pub expanded: bool, - #[arg(long, action = ArgAction::SetTrue, help = "Hides spacing between table headers and entries.")] + #[arg( + long, + action = ArgAction::SetTrue, + help = "Hides spacing between table headers and entries.", + alias = "hide-table-gap" + )] pub hide_table_gap: bool, - #[arg(long, action = ArgAction::SetTrue, help = "Hides the time scale from being shown.")] + #[arg(long, action = ArgAction::SetTrue, help = "Hides the time scale from being shown.", alias = "hide-time")] pub hide_time: bool, #[arg( @@ -249,7 +262,8 @@ pub struct GeneralArgs { #[arg( long, action = ArgAction::SetTrue, - help = "Shows the list scroll position tracker in the widget title for table widgets." + help = "Shows the list scroll position tracker in the widget title for table widgets.", + alias = "show-table-scroll-position" )] pub show_table_scroll_position: bool, @@ -260,7 +274,8 @@ pub struct GeneralArgs { help = "The amount of time changed upon zooming.", long_help = "The amount of time changed when zooming in/out. Takes a number in \ milliseconds or a human duration (e.g. 30s). The minimum is 1s, and \ - defaults to 15s." + defaults to 15s.", + alias = "time-delta" )] pub time_delta: Option, } @@ -274,7 +289,8 @@ pub struct ProcessArgs { long, action = ArgAction::SetTrue, help = "Enables case sensitivity by default.", - long_help = "Enables case sensitivity by default when searching for a process." + long_help = "Enables case sensitivity by default when searching for a process.", + alias = "case-sensitive" )] pub case_sensitive: bool, @@ -283,17 +299,19 @@ pub struct ProcessArgs { short = 'u', long, action = ArgAction::SetTrue, - help = "Calculates process CPU usage as a percentage of current usage rather than total usage." + help = "Calculates process CPU usage as a percentage of current usage rather than total usage.", + alias = "current-usage" )] pub current_usage: bool, - // TODO: Disable this on Windows? + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] #[arg( long, action = ArgAction::SetTrue, help = "Hides additional stopping options Unix-like systems.", long_help = "Hides additional stopping options Unix-like systems. Signal 15 (TERM) will be sent when \ - stopping a process." + stopping a process.", + alias = "disable-advanced-kill" )] pub disable_advanced_kill: bool, @@ -301,7 +319,10 @@ pub struct ProcessArgs { short = 'g', long, action = ArgAction::SetTrue, - help = "Groups processes with the same name by default." + help = "Groups processes with the same name by default.", + long_help = "Groups processes with the same name by default. Doesn't do anything if --tree is also set, or \ + tree=true in the config.", + alias = "group-processes" )] pub group_processes: bool, @@ -309,14 +330,16 @@ pub struct ProcessArgs { long, action = ArgAction::SetTrue, help = "Defaults to showing process memory usage by value.", - long_help = "Defaults to showing process memory usage by value. Otherwise, it defaults to showing it by percentage." + long_help = "Defaults to showing process memory usage by value. Otherwise, it defaults to showing it by percentage.", + alias = "process-memory-as-value" )] pub process_memory_as_value: bool, #[arg( long, action = ArgAction::SetTrue, - help = "Shows the full command name instead of the process name by default." + help = "Shows the full command name instead of the process name by default.", + alias = "process-command" )] pub process_command: bool, @@ -335,7 +358,8 @@ pub struct ProcessArgs { short = 'n', long, action = ArgAction::SetTrue, - help = "Show process CPU% usage without averaging over the number of CPU cores." + help = "Show process CPU% usage without averaging over the number of CPU cores.", + alias = "unnormalized-cpu" )] pub unnormalized_cpu: bool, @@ -343,9 +367,18 @@ pub struct ProcessArgs { short = 'W', long, action = ArgAction::SetTrue, - help = "Enables whole-word matching by default while searching." + help = "Enables whole-word matching by default while searching.", + alias = "whole-word" )] pub whole_word: bool, + + #[arg( + long, + action = ArgAction::SetTrue, + help = "Collapse process tree by default.", + alias = "tree-collapse" + )] + pub tree_collapse: bool, } /// Temperature arguments/config options. @@ -413,7 +446,8 @@ pub struct CpuArgs { short = 'l', long, action = ArgAction::SetTrue, - help = "Puts the CPU chart legend on the left side." + help = "Puts the CPU chart legend on the left side.", + alias = "cpu-left-legend" )] pub cpu_left_legend: bool, @@ -422,10 +456,17 @@ pub struct CpuArgs { help = "Sets which CPU entry type is selected by default.", value_name = "ENTRY", value_parser = value_parser!(CpuDefault), + alias = "default-cpu-entry" )] pub default_cpu_entry: Option, - #[arg(short = 'a', long, action = ArgAction::SetTrue, help = "Hides the average CPU usage entry.")] + #[arg( + short = 'a', + long, + action = ArgAction::SetTrue, + help = "Hides the average CPU usage entry.", + alias = "hide-avg-cpu" + )] pub hide_avg_cpu: bool, } @@ -439,6 +480,7 @@ pub struct MemoryArgs { value_name = "POSITION", ignore_case = true, help = "Where to place the legend for the memory chart widget.", + alias = "memory-legend" )] pub memory_legend: Option, @@ -446,7 +488,8 @@ pub struct MemoryArgs { #[arg( long, action = ArgAction::SetTrue, - help = "Enables collecting and displaying cache and buffer memory." + help = "Enables collecting and displaying cache and buffer memory.", + alias = "enable-cache-memory" )] pub enable_cache_memory: bool, } @@ -461,6 +504,7 @@ pub struct NetworkArgs { value_name = "POSITION", ignore_case = true, help = "Where to place the legend for the network chart widget.", + alias = "network-legend" )] pub network_legend: Option, @@ -469,7 +513,8 @@ pub struct NetworkArgs { long, action = ArgAction::SetTrue, help = "Displays the network widget using bytes.", - long_help = "Displays the network widget using bytes. Defaults to bits." + long_help = "Displays the network widget using bytes. Defaults to bits.", + alias = "network-use-bytes" )] pub network_use_bytes: bool, @@ -478,7 +523,8 @@ pub struct NetworkArgs { action = ArgAction::SetTrue, help = "Displays the network widget with binary prefixes.", long_help = "Displays the network widget with binary prefixes (e.g. kibibits, mebibits) rather than a decimal \ - prefixes (e.g. kilobits, megabits). Defaults to decimal prefixes." + prefixes (e.g. kilobits, megabits). Defaults to decimal prefixes.", + alias = "network-use-binary-prefix" )] pub network_use_binary_prefix: bool, @@ -486,7 +532,8 @@ pub struct NetworkArgs { long, action = ArgAction::SetTrue, help = "Displays the network widget with a log scale.", - long_help = "Displays the network widget with a log scale. Defaults to a non-log scale." + long_help = "Displays the network widget with a log scale. Defaults to a non-log scale.", + alias = "network-use-log" )] pub network_use_log: bool, @@ -494,7 +541,8 @@ pub struct NetworkArgs { long, action = ArgAction::SetTrue, help = "(DEPRECATED) Uses a separate network legend.", - long_help = "(DEPRECATED) Uses separate network widget legend. This display is not tested and may be broken." + long_help = "(DEPRECATED) Uses separate network widget legend. This display is not tested and may be broken.", + alias = "use-old-network-legend" )] pub use_old_network_legend: bool, } @@ -520,8 +568,8 @@ pub struct BatteryArgs { #[derive(Args, Clone, Debug, Default)] #[command(next_help_heading = "GPU Options", rename_all = "snake_case")] pub struct GpuArgs { - #[arg(long, action = ArgAction::SetTrue, help = "Enable collecting and displaying GPU usage.")] - pub enable_gpu: bool, + #[arg(long, action = ArgAction::SetTrue, help = "Disable collecting and displaying NVIDIA and AMD GPU information.", alias = "disable-gpu")] + pub disable_gpu: bool, } /// Style arguments/config options. @@ -566,13 +614,9 @@ pub struct OtherArgs { #[arg(short = 'V', long, action = ArgAction::Version, help = "Prints version information.")] version: (), - - #[cfg(feature = "generate_schema")] - #[arg(long, action = ArgAction::SetTrue)] - pub generate_schema: bool, } -/// Returns a [`BottomArgs`]. +/// Parse arguments and return a [`BottomArgs`]. If this fails it will exit the program. pub fn get_args() -> BottomArgs { BottomArgs::parse() } @@ -619,8 +663,8 @@ mod test { let allow_list: HashSet<&str> = vec![].into_iter().collect(); let cmd = build_cmd(); - for opts in cmd.get_opts() { - let long_flag = opts.get_long().unwrap(); + for opt in cmd.get_opts() { + let long_flag = opt.get_long().unwrap(); if !allow_list.contains(long_flag) { assert!( @@ -630,4 +674,24 @@ mod test { } } } + + #[test] + fn catch_missing_hyphen_alias() { + let cmd = build_cmd(); + + for opt in cmd.get_opts() { + let long_flag = opt.get_long().unwrap(); + if long_flag.contains("_") { + let aliased_version = long_flag.replace("_", "-"); + let stored_alias = opt.get_aliases().unwrap_or_else(|| { + panic!("'{long_flag}' should have an alias, if not, it's missing") + }); + + assert!( + stored_alias.contains(&aliased_version.as_str()), + "'{long_flag}' has an incorrectly defined alias" + ); + } + } + } } diff --git a/src/options/config.rs b/src/options/config.rs index 258943fa7..073153acf 100644 --- a/src/options/config.rs +++ b/src/options/config.rs @@ -18,13 +18,10 @@ use temperature::TempConfig; pub use self::ignore_list::IgnoreList; use self::{cpu::CpuConfig, layout::Row, process::ProcessesConfig}; +/// Overall config for `bottom`. #[derive(Clone, Debug, Default, Deserialize)] -#[cfg_attr( - feature = "generate_schema", - derive(schemars::JsonSchema), - schemars(title = "Schema for bottom's configs (nightly)") -)] -#[cfg_attr(test, serde(deny_unknown_fields))] +#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] pub struct Config { pub(crate) flags: Option, pub(crate) styles: Option, @@ -39,6 +36,7 @@ pub struct Config { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(untagged)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] +#[cfg_attr(test, derive(PartialEq, Eq))] pub(crate) enum StringOrNum { String(String), Num(u64), @@ -55,3 +53,29 @@ impl From for StringOrNum { StringOrNum::Num(value) } } + +#[cfg(test)] +mod test { + + // Test all valid configs in the integration test folder and ensure they are accepted. + // We need this separated as only test library code sets `serde(deny_unknown_fields)`. + #[test] + #[cfg(feature = "default")] + fn test_integration_valid_configs() { + use std::fs; + + use super::Config; + + for config_path in fs::read_dir("./tests/valid_configs").unwrap() { + let config_path = config_path.unwrap(); + let config_path_str = config_path.path().display().to_string(); + let config_str = fs::read_to_string(config_path.path()).unwrap(); + + toml_edit::de::from_str::(&config_str) + .unwrap_or_else(|_| panic!("incorrectly rejected '{config_path_str}'")); + } + } + + // I didn't do an invalid config test as a lot of them _are_ valid Config when parsed, + // but fail other checks. +} diff --git a/src/options/config/cpu.rs b/src/options/config/cpu.rs index c9ba25296..b6dcc63da 100644 --- a/src/options/config/cpu.rs +++ b/src/options/config/cpu.rs @@ -5,7 +5,8 @@ use serde::Deserialize; #[derive(Clone, Copy, Debug, Default, Deserialize)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] -pub enum CpuDefault { +#[cfg_attr(test, derive(PartialEq, Eq))] +pub(crate) enum CpuDefault { #[default] All, #[serde(alias = "avg")] @@ -15,10 +16,10 @@ pub enum CpuDefault { /// CPU column settings. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] -#[cfg_attr(test, serde(deny_unknown_fields))] -pub struct CpuConfig { +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] +pub(crate) struct CpuConfig { #[serde(default)] - pub default: CpuDefault, + pub(crate) default: CpuDefault, } #[cfg(test)] diff --git a/src/options/config/disk.rs b/src/options/config/disk.rs index 13373a115..d6c32a51e 100644 --- a/src/options/config/disk.rs +++ b/src/options/config/disk.rs @@ -1,15 +1,44 @@ use serde::Deserialize; use super::IgnoreList; +use crate::options::DiskColumn; /// Disk configuration. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] -#[cfg_attr(test, serde(deny_unknown_fields))] -pub struct DiskConfig { +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] +pub(crate) struct DiskConfig { /// A filter over the disk names. - pub name_filter: Option, + pub(crate) name_filter: Option, /// A filter over the mount names. - pub mount_filter: Option, + pub(crate) mount_filter: Option, + + /// A list of disk widget columns. + #[serde(default)] + pub(crate) columns: Vec, // TODO: make this more composable(?) in the future, we might need to rethink how it's done for custom widgets +} + +#[cfg(test)] +mod test { + use super::DiskConfig; + + #[test] + fn empty_column_setting() { + let config = ""; + let generated: DiskConfig = toml_edit::de::from_str(config).unwrap(); + assert!(generated.columns.is_empty()); + } + + #[test] + fn valid_disk_column_settings() { + let config = r#"columns = ["disk", "mount", "used", "free", "total", "used%", "free%", "r/s", "w/s"]"#; + toml_edit::de::from_str::(config).expect("Should succeed!"); + } + + #[test] + fn bad_disk_column_settings() { + let config = r#"columns = ["diskk"]"#; + toml_edit::de::from_str::(config).expect_err("Should error out!"); + } } diff --git a/src/options/config/flags.rs b/src/options/config/flags.rs index 39f18eaa1..621d5e15a 100644 --- a/src/options/config/flags.rs +++ b/src/options/config/flags.rs @@ -4,7 +4,7 @@ use super::StringOrNum; #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] -#[cfg_attr(test, serde(deny_unknown_fields))] +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] pub(crate) struct FlagConfig { pub(crate) hide_avg_cpu: Option, pub(crate) dot_marker: Option, @@ -36,12 +36,14 @@ pub(crate) struct FlagConfig { pub(crate) tree: Option, pub(crate) show_table_scroll_position: Option, pub(crate) process_command: Option, - pub(crate) disable_advanced_kill: Option, + // #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + pub(crate) disable_advanced_kill: Option, // This does nothing on Windows, but we leave it enabled to make the config file consistent across platforms. pub(crate) network_use_bytes: Option, pub(crate) network_use_log: Option, pub(crate) network_use_binary_prefix: Option, - pub(crate) enable_gpu: Option, + pub(crate) disable_gpu: Option, pub(crate) enable_cache_memory: Option, pub(crate) retention: Option, - pub(crate) average_cpu_row: Option, + pub(crate) average_cpu_row: Option, // FIXME: This makes no sense outside of basic mode, add a basic mode config section. + pub(crate) tree_collapse: Option, } diff --git a/src/options/config/ignore_list.rs b/src/options/config/ignore_list.rs index 5950cbb42..3dd2a73f5 100644 --- a/src/options/config/ignore_list.rs +++ b/src/options/config/ignore_list.rs @@ -7,7 +7,7 @@ fn default_as_true() -> bool { #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] -#[cfg_attr(test, serde(deny_unknown_fields))] +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] pub struct IgnoreList { #[serde(default = "default_as_true")] // TODO: Deprecate and/or rename, current name sounds awful. diff --git a/src/options/config/layout.rs b/src/options/config/layout.rs index d68b29587..dbf642067 100644 --- a/src/options/config/layout.rs +++ b/src/options/config/layout.rs @@ -6,7 +6,7 @@ use crate::{app::layout_manager::*, options::OptionResult}; /// of children. #[derive(Clone, Deserialize, Debug, Serialize)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] -#[cfg_attr(test, serde(deny_unknown_fields))] +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] #[serde(rename = "row")] pub struct Row { pub ratio: Option, @@ -218,7 +218,7 @@ impl Row { #[derive(Clone, Deserialize, Debug, Serialize)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] #[serde(untagged)] -#[cfg_attr(test, serde(deny_unknown_fields))] +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] pub enum RowChildren { Widget(FinalWidget), Col { @@ -230,7 +230,7 @@ pub enum RowChildren { /// Represents a widget. #[derive(Clone, Deserialize, Debug, Serialize)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] -#[cfg_attr(test, serde(deny_unknown_fields))] +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] pub struct FinalWidget { pub ratio: Option, #[serde(rename = "type")] diff --git a/src/options/config/network.rs b/src/options/config/network.rs index 2d8bc6f96..da2bf18ed 100644 --- a/src/options/config/network.rs +++ b/src/options/config/network.rs @@ -5,8 +5,8 @@ use super::IgnoreList; /// Network configuration. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] -#[cfg_attr(test, serde(deny_unknown_fields))] -pub struct NetworkConfig { +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] +pub(crate) struct NetworkConfig { /// A filter over the network interface names. - pub interface_filter: Option, + pub(crate) interface_filter: Option, } diff --git a/src/options/config/process.rs b/src/options/config/process.rs index 8f34fd6db..7490e380f 100644 --- a/src/options/config/process.rs +++ b/src/options/config/process.rs @@ -1,124 +1,15 @@ use serde::Deserialize; -use crate::widgets::ProcWidgetColumn; +use crate::widgets::ProcColumn; /// Process configuration. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] -#[cfg_attr(test, serde(deny_unknown_fields))] -pub struct ProcessesConfig { +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] +pub(crate) struct ProcessesConfig { /// A list of process widget columns. #[serde(default)] - pub columns: Vec, -} - -/// A column in the process widget. -#[derive(Clone, Debug)] -#[cfg_attr( - feature = "generate_schema", - derive(schemars::JsonSchema, strum::VariantArray) -)] -pub enum ProcColumn { - Pid, - Count, - Name, - Command, - CpuPercent, - Mem, - MemPercent, - Read, - Write, - TotalRead, - TotalWrite, - State, - User, - Time, - #[cfg(feature = "gpu")] - GpuMem, - #[cfg(feature = "gpu")] - GpuPercent, -} - -impl ProcColumn { - /// An ugly hack to generate the JSON schema. - #[cfg(feature = "generate_schema")] - pub fn get_schema_names(&self) -> &[&'static str] { - match self { - ProcColumn::Pid => &["PID"], - ProcColumn::Count => &["Count"], - ProcColumn::Name => &["Name"], - ProcColumn::Command => &["Command"], - ProcColumn::CpuPercent => &["CPU%"], - ProcColumn::Mem => &["Mem"], - ProcColumn::MemPercent => &["Mem%"], - ProcColumn::Read => &["R/s", "Read", "Rps"], - ProcColumn::Write => &["W/s", "Write", "Wps"], - ProcColumn::TotalRead => &["T.Read", "TWrite"], - ProcColumn::TotalWrite => &["T.Write", "TRead"], - ProcColumn::State => &["State"], - ProcColumn::User => &["User"], - ProcColumn::Time => &["Time"], - #[cfg(feature = "gpu")] - ProcColumn::GpuMem => &["GMem", "GMem%"], - #[cfg(feature = "gpu")] - ProcColumn::GpuPercent => &["GPU%"], - } - } -} - -impl<'de> Deserialize<'de> for ProcColumn { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?.to_lowercase(); - match value.as_str() { - "cpu%" => Ok(ProcColumn::CpuPercent), - "mem" => Ok(ProcColumn::Mem), - "mem%" => Ok(ProcColumn::Mem), - "pid" => Ok(ProcColumn::Pid), - "count" => Ok(ProcColumn::Count), - "name" => Ok(ProcColumn::Name), - "command" => Ok(ProcColumn::Command), - "read" | "r/s" | "rps" => Ok(ProcColumn::Read), - "write" | "w/s" | "wps" => Ok(ProcColumn::Write), - "tread" | "t.read" => Ok(ProcColumn::TotalRead), - "twrite" | "t.write" => Ok(ProcColumn::TotalWrite), - "state" => Ok(ProcColumn::State), - "user" => Ok(ProcColumn::User), - "time" => Ok(ProcColumn::Time), - #[cfg(feature = "gpu")] - "gmem" | "gmem%" => Ok(ProcColumn::GpuMem), - #[cfg(feature = "gpu")] - "gpu%" => Ok(ProcColumn::GpuPercent), - _ => Err(serde::de::Error::custom("doesn't match any column type")), - } - } -} - -impl From<&ProcColumn> for ProcWidgetColumn { - fn from(value: &ProcColumn) -> Self { - match value { - ProcColumn::Pid => ProcWidgetColumn::PidOrCount, - ProcColumn::Count => ProcWidgetColumn::PidOrCount, - ProcColumn::Name => ProcWidgetColumn::ProcNameOrCommand, - ProcColumn::Command => ProcWidgetColumn::ProcNameOrCommand, - ProcColumn::CpuPercent => ProcWidgetColumn::Cpu, - ProcColumn::Mem => ProcWidgetColumn::Mem, - ProcColumn::MemPercent => ProcWidgetColumn::Mem, - ProcColumn::Read => ProcWidgetColumn::ReadPerSecond, - ProcColumn::Write => ProcWidgetColumn::WritePerSecond, - ProcColumn::TotalRead => ProcWidgetColumn::TotalRead, - ProcColumn::TotalWrite => ProcWidgetColumn::TotalWrite, - ProcColumn::State => ProcWidgetColumn::State, - ProcColumn::User => ProcWidgetColumn::User, - ProcColumn::Time => ProcWidgetColumn::Time, - #[cfg(feature = "gpu")] - ProcColumn::GpuMem => ProcWidgetColumn::GpuMem, - #[cfg(feature = "gpu")] - ProcColumn::GpuPercent => ProcWidgetColumn::GpuUtil, - } - } + pub(crate) columns: Vec, // TODO: make this more composable(?) in the future, we might need to rethink how it's done for custom widgets } #[cfg(test)] @@ -141,9 +32,9 @@ mod test { } #[test] - fn process_column_settings() { + fn valid_process_column_config() { let config = r#" - columns = ["CPU%", "PiD", "user", "MEM", "Tread", "T.Write", "Rps", "W/s", "tiMe", "USER", "state"] + columns = ["CPU%", "PiD", "user", "MEM", "virt", "Tread", "T.Write", "Rps", "W/s", "tiMe", "USER", "state"] "#; let generated: ProcessesConfig = toml_edit::de::from_str(config).unwrap(); @@ -154,6 +45,7 @@ mod test { ProcWidgetColumn::PidOrCount, ProcWidgetColumn::User, ProcWidgetColumn::Mem, + ProcWidgetColumn::VirtualMem, ProcWidgetColumn::TotalRead, ProcWidgetColumn::TotalWrite, ProcWidgetColumn::ReadPerSecond, @@ -166,13 +58,13 @@ mod test { } #[test] - fn process_column_settings_2() { + fn bad_process_column_config() { let config = r#"columns = ["MEM", "TWrite", "Cpuz", "read", "wps"]"#; toml_edit::de::from_str::(config).expect_err("Should error out!"); } #[test] - fn process_column_settings_3() { + fn valid_process_column_config_2() { let config = r#"columns = ["Twrite", "T.Write"]"#; let generated: ProcessesConfig = toml_edit::de::from_str(config).unwrap(); assert_eq!( diff --git a/src/options/config/style.rs b/src/options/config/style.rs index 921e1cce2..96d8cdf4e 100644 --- a/src/options/config/style.rs +++ b/src/options/config/style.rs @@ -1,6 +1,7 @@ //! Config options around styling. mod battery; +mod borders; mod cpu; mod graphs; mod memory; @@ -19,23 +20,23 @@ use memory::MemoryStyle; use network::NetworkStyle; use serde::{Deserialize, Serialize}; use tables::TableStyle; -use tui::style::Style; +use tui::{style::Style, widgets::BorderType}; use utils::{opt, set_colour, set_colour_list, set_style}; use widgets::WidgetStyle; -use crate::options::{args::BottomArgs, OptionError, OptionResult}; - use super::Config; +use crate::options::{OptionError, OptionResult, args::BottomArgs}; #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] +#[cfg_attr(test, derive(PartialEq, Eq))] pub(crate) struct ColorStr(Cow<'static, str>); /// A style for text. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(untagged)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] -#[cfg_attr(test, serde(deny_unknown_fields))] +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] pub(crate) enum TextStyleConfig { Colour(ColorStr), TextStyle { @@ -60,6 +61,7 @@ pub(crate) enum TextStyleConfig { /// Style-related configs. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] pub(crate) struct StyleConfig { /// A built-in theme. /// @@ -91,45 +93,47 @@ pub(crate) struct StyleConfig { pub(crate) widgets: Option, } -/// The actual internal representation of the configured colours, -/// as a "palette". +/// The actual internal representation of the configured styles. #[derive(Debug)] -pub struct ColourPalette { - pub selected_text_style: Style, - pub table_header_style: Style, - pub ram_style: Style, +pub struct Styles { + pub(crate) ram_style: Style, #[cfg(not(target_os = "windows"))] - pub cache_style: Style, - pub swap_style: Style, - pub arc_style: Style, - pub gpu_colours: Vec